Commit6afcfb8aRecorded12 Jul 2026Repositorycinder-cadence

cinder web: fix three native-codegen runtime gaps; game renders + plays

Message

Runtime integration fixes that get the full game rendering + playing music + responding to input in-browser under backend: 'native (verified SwiftShader/CDP, screenshot /tmp/web-game-verify.png: real engine/scene/enemies/bullets/HUD at 60fps, motif soundtrack streaming via the WebAudio sink, zero console errors).

Three native-codegen limitations surfaced + worked around (see folio investigations/sigil-native-codegen-cond-expand-define-gap):

1. cond-expand-wrapped top-level (define ...) is NOT registered under native codegen — the binding resolves to an immediate and calling it traps "not a procedure (type=-1)". audio.sgl's device shims (audio-setup etc.) and play-sample were cond-expand-defines. Fix: use cond-expand only as an EXPRESSION inside normal top-level defines (device-setup!/device-shutdown!/ resume-audio!/play-sample).

2. web-app.sgl called max without importing (sigil math) (the shell was committed but never exercised until the real game drove it). Added the import.

3. reset/shift delimited continuations don't work under native codegen, so (sigil coroutines) — which web-app's run-game/wait-frame frame loop relied on — broke (car/cdr traps in the continuation machinery). Fix: drive the web frame loop directly, one render per RAF "frame" dispatch (web-tick), no coroutine. web-app gains web-app-set-key!/-js-key->sym/-end-frame! so the entry feeds keys + advances the key-edge snapshot itself.

examples/web-game/verify.mjs: the CDP acceptance harness (audio RMS + zero errors + screenshot). Native desktop behaviour unchanged (all cond-expand else-arms identical; web-app is wasm-only; game/composition sample-rate defaults to 44100). Native full build unverifiable headless (sokol_gfx needs GL/gl.h — same M2/M3 limitation).

Changed
 examples/web-game/src/web-game/main.sgl |  77 ++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
 examples/web-game/verify.mjs            | 157 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/cinder-cadence/audio.sgl            |  85 ++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
 src/cinder-cadence/web-app.sgl          |  17 ++++++++++++++++-
 4 files changed, 274 insertions(+), 62 deletions(-)
Diff
examples/web-game/src/web-game/main.sglmodified
@@ -21,6 +21,7 @@
21
22
(define-library (web-game main)
23
(import (sigil core)
+24
(sigil math)
25
(sigil graphics)
26
(sigil browser gles3)
27
(sigil audio sink)
@@ -46,19 +47,31 @@
47
48
(define *game* #f)
49
(define *audio-resumed?* #f)
+50
(define *last-ts* 0.0)
51
52
;; ------------------------------------------------------------------
51
;; The per-frame render loop (mirrors playable-game-loop minus bloom).
+53
;; Per-frame tick (coroutine-free direct drive).
54
;;
53
;; run-game (web-app) runs this thunk up to the first (wait-frame) during
54
;; init, then the RAF loop resumes it once per frame with dt. Audio is
55
;; pumped inside tick-playable! (tick-playing!), and audio-reactive events
56
;; are drained inside scene-render — so this loop just renders.
57
(define (web-game-loop g)
58
(let ((eng (game-engine g))
59
(sf (game-starfield g)))
60
(let loop ()
61
(let ((dt (wait-frame)))
+55
;; The gles3 app-shell's requestAnimationFrame dispatches a ("frame","<ms>")
+56
;; event each frame; sigil-web-dispatch-event routes it here. This mirrors
+57
;; main.sgl's playable-game-loop body MINUS the bloom post-processing pass
+58
;; (bloom's #version 410 shaders + render targets are desktop-GL; the game
+59
;; renders with filled primitives that work directly on WebGL2). Audio is
+60
;; pumped inside tick-playable! (tick-playing!) and audio-reactive events are
+61
;; drained inside scene-render, so this tick just renders + advances input.
+62
;;
+63
;; Why no coroutine: the (sigil app) run-game model on native suspends the
+64
;; game thunk with (wait-frame); (cinder-cadence web-app) implements that
+65
;; with (sigil coroutines), whose reset/shift delimited continuations do NOT
+66
;; work under native codegen. Driving one frame per RAF tick sidesteps
+67
;; continuations entirely.
+68
(define (web-tick ts-ms)
+69
(let* ((g *game*)
+70
(dt (/ (max 0.0 (- ts-ms *last-ts*)) 1000.0)))
+71
(set! *last-ts* ts-ms)
+72
(when g
+73
(let ((eng (game-engine g))
+74
(sf (game-starfield g)))
75
(gles3-resize-to-display)
76
(begin-frame)
77
;; Phase-aware background (title black -> navy as play starts).
@@ -84,12 +97,9 @@
97
(draw-score-hud (engine-scene eng) VIRTUAL-WIDTH VIRTUAL-HEIGHT)
98
(draw-time-hud (engine-scene eng) g VIRTUAL-WIDTH VIRTUAL-HEIGHT))
99
(end-frame)
87
(unless (quit-requested?) (loop))))
88
;; Loop exited (quit) — tear the composition down.
89
(when (game-composition g)
90
(stop-composition! (game-composition g))
91
(set-game-composition! g #f))
92
(gfx-shutdown)))
+100
;; Advance the key edge snapshot so key-pressed?/released? read one
+101
;; frame's worth of transitions.
+102
(web-app-end-frame!)))))
103
104
;; ------------------------------------------------------------------
105
(define (main)
@@ -117,17 +127,28 @@
127
(set-game-audio-svc! g audio)
128
(set-game-sample-rate! g rate)
129
(set! *game* g)
120
;; Hand the coroutine loop to the web-app shell; it starts the RAF
121
;; loop and resumes web-game-loop each frame.
122
(run-game "Cinder Cadence" 960 720
123
(lambda () (web-game-loop g))))))
+130
;; Start the gles3 RAF loop; each frame dispatches ("frame","<ms>")
+131
;; back to sigil-web-dispatch-event -> web-tick. The callback-name arg
+132
;; is only used by the bytecode eval path; native codegen dispatches
+133
;; through the typed event ABI.
+134
(gles3-start-loop "web-tick"))))
135
125
;; The gles3 app-shell forwards keydown/keyup/frame here. We resume the
126
;; WebAudio context on the first keydown (the trusted user gesture the
127
;; autoplay policy needs — the same key that starts the run), then forward
128
;; every event to the web-app shell (key table + coroutine frame tick).
+136
;; The gles3 app-shell forwards keydown/keyup/frame here. On the first
+137
;; keydown we resume the WebAudio context (the trusted user gesture the
+138
;; autoplay policy needs — the same key that starts the run). Keys route to
+139
;; the web-app key table; "frame" drives one game tick.
140
(define (sigil-web-dispatch-event type payload)
130
(when (and (not *audio-resumed?*) (string=? type "keydown"))
131
(set! *audio-resumed?* #t)
132
(resume-audio!))
133
(web-app-dispatch-event type payload))))
+141
(cond
+142
((string=? type "frame")
+143
(web-tick (string->number payload))
+144
0)
+145
((string=? type "keydown")
+146
(when (not *audio-resumed?*)
+147
(set! *audio-resumed?* #t)
+148
(resume-audio!))
+149
(web-app-set-key! (web-app-js-key->sym payload) #t)
+150
0)
+151
((string=? type "keyup")
+152
(web-app-set-key! (web-app-js-key->sym payload) #f)
+153
0)
+154
(else 0)))))
examples/web-game/verify.mjsadded
@@ -0,0 +1,157 @@
+1
// CDP verify harness for the Cinder Cadence web-game.
+2
//
+3
// Serves build/web (COOP/COEP), launches google-chrome headless with software
+4
// WebGL (SwiftShader) + no-gesture autoplay, drives the page over the DevTools
+5
// Protocol, and checks:
+6
// - pressing Space starts the game (title -> gameplay, driven by input),
+7
// - the motif soundtrack produces signal (AnalyserNode RMS > 0),
+8
// - zero console errors.
+9
// Writes a screenshot to /tmp/web-game-verify.png — that IS the visual proof.
+10
//
+11
// NOTE: the `pixels()` drawImage readback reports 0 because the gles3 context
+12
// is created with preserveDrawingBuffer:false (an M3 perf fix), so the WebGL
+13
// canvas can't be copied into a 2D canvas after the RAF frame. Rely on the
+14
// captured screenshot for the visual check; audio RMS + zero-errors are the
+15
// programmatic asserts.
+16
+17
import http from "node:http";
+18
import fs from "node:fs";
+19
import path from "node:path";
+20
import { spawn } from "node:child_process";
+21
+22
const ROOT = path.resolve(process.argv[2] || "build/web");
+23
const PORT = 8091;
+24
const CDP = 9231;
+25
const TYPES = { ".html":"text/html;charset=utf-8", ".js":"text/javascript;charset=utf-8",
+26
".mjs":"text/javascript;charset=utf-8", ".wasm":"application/wasm",
+27
".json":"application/json;charset=utf-8", ".css":"text/css;charset=utf-8" };
+28
+29
const server = http.createServer((req, res) => {
+30
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
+31
let fp = path.join(ROOT, urlPath === "/" ? "/index.html" : urlPath);
+32
if (fp !== ROOT && !fp.startsWith(ROOT + path.sep)) { res.writeHead(403).end(); return; }
+33
fs.readFile(fp, (err, buf) => {
+34
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
+35
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
+36
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
+37
if (err) { res.writeHead(404).end("not found: " + urlPath); return; }
+38
res.writeHead(200, { "Content-Type": TYPES[path.extname(fp)] || "application/octet-stream" });
+39
res.end(buf);
+40
});
+41
});
+42
await new Promise(r => server.listen(PORT, "127.0.0.1", r));
+43
console.log(`serving ${ROOT} at :${PORT}`);
+44
+45
const udd = fs.mkdtempSync("/tmp/wg-chrome-");
+46
const chrome = spawn("google-chrome", [
+47
"--headless=new", "--no-sandbox", "--disable-dev-shm-usage",
+48
"--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader",
+49
"--enable-webgl", "--ignore-gpu-blocklist",
+50
"--autoplay-policy=no-user-gesture-required",
+51
`--remote-debugging-port=${CDP}`, `--user-data-dir=${udd}`,
+52
"--window-size=1000,820", "about:blank",
+53
], { stdio: "ignore" });
+54
+55
function sleep(ms){ return new Promise(r=>setTimeout(r,ms)); }
+56
async function cdpTargets(){ const r=await fetch(`http://127.0.0.1:${CDP}/json`); return r.json(); }
+57
+58
// wait for the browser + a page target
+59
let pageWs=null;
+60
for (let i=0;i<60;i++){
+61
try { const ts=await cdpTargets(); const p=ts.find(t=>t.type==="page"); if(p&&p.webSocketDebuggerUrl){pageWs=p.webSocketDebuggerUrl;break;} } catch {}
+62
await sleep(250);
+63
}
+64
if(!pageWs){ console.error("no page target"); process.exit(1); }
+65
+66
const ws = new WebSocket(pageWs);
+67
let id=0; const pending=new Map(); const consoleErrors=[];
+68
function send(method,params={}){ return new Promise((res,rej)=>{ const m=++id; pending.set(m,{res,rej}); ws.send(JSON.stringify({id:m,method,params})); }); }
+69
ws.addEventListener("message", ev=>{
+70
const msg=JSON.parse(ev.data);
+71
if(msg.id&&pending.has(msg.id)){ const {res,rej}=pending.get(msg.id); pending.delete(msg.id); msg.error?rej(new Error(JSON.stringify(msg.error))):res(msg.result); return; }
+72
if(msg.method==="Log.entryAdded"&&msg.params.entry.level==="error") consoleErrors.push(msg.params.entry.text);
+73
if(msg.method==="Runtime.consoleAPICalled"&&msg.params.type==="error") consoleErrors.push((msg.params.args||[]).map(a=>a.value||a.description||"").join(" "));
+74
if(msg.method==="Runtime.exceptionThrown") consoleErrors.push("EXC: "+(msg.params.exceptionDetails?.exception?.description||msg.params.exceptionDetails?.text));
+75
});
+76
await new Promise((res,rej)=>{ ws.addEventListener("open",res); ws.addEventListener("error",rej); });
+77
+78
await send("Page.enable"); await send("Runtime.enable"); await send("Log.enable");
+79
+80
// Inject BEFORE page scripts: an AnalyserNode tap on every AudioNode.connect,
+81
// so we can read the produced audio signal without the app's handles.
+82
await send("Page.addScriptToEvaluateOnNewDocument", { source: `
+83
(function(){
+84
window.__wg = { analyser:null, ctx:null };
+85
const OrigConnect = AudioNode.prototype.connect;
+86
AudioNode.prototype.connect = function(dest){
+87
try {
+88
const ctx = this.context;
+89
if (ctx && !window.__wg.analyser) {
+90
window.__wg.ctx = ctx;
+91
const an = ctx.createAnalyser(); an.fftSize = 2048;
+92
window.__wg.analyser = an;
+93
OrigConnect.call(this, an);
+94
}
+95
} catch(e){}
+96
return OrigConnect.apply(this, arguments);
+97
};
+98
window.__wg.rms = function(){
+99
const a = window.__wg.analyser; if(!a) return -1;
+100
const buf = new Float32Array(a.fftSize); a.getFloatTimeDomainData(buf);
+101
let s=0; for(let i=0;i<buf.length;i++) s+=buf[i]*buf[i];
+102
return Math.sqrt(s/buf.length);
+103
};
+104
// non-background pixel count of the WebGL canvas (via a 2D copy).
+105
window.__wg.pixels = function(){
+106
const c=document.getElementById('stage'); if(!c) return -1;
+107
const off=document.createElement('canvas'); off.width=c.width; off.height=c.height;
+108
const g=off.getContext('2d'); g.drawImage(c,0,0);
+109
const d=g.getImageData(0,0,c.width,c.height).data; let n=0, lum=0;
+110
for(let i=0;i<d.length;i+=4){ const r=d[i],gg=d[i+1],b=d[i+2]; if(r>14||gg>18||b>28){ n++; lum+=r+gg+b; } }
+111
return {nonbg:n, lum:lum};
+112
};
+113
window.__wg.key = function(type,k){ document.dispatchEvent(new KeyboardEvent(type,{key:k,bubbles:true})); };
+114
window.__wg.imports = function(){
+115
try { return WebAssembly.Module.imports(window.__sigilModule||{}); } catch(e){ return null; }
+116
};
+117
})();
+118
`});
+119
+120
await send("Page.navigate", { url:`http://127.0.0.1:${PORT}/index.html` });
+121
await sleep(4500); // boot + wasm init + first frames
+122
+123
async function evalJS(expr){ const r=await send("Runtime.evaluate",{expression:expr,returnByValue:true,awaitPromise:true}); if(r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails)); return r.result.value; }
+124
+125
const titlePixels = await evalJS("JSON.stringify(window.__wg.pixels())");
+126
console.log("TITLE pixels:", titlePixels);
+127
+128
// Press Space to start (trusted-gesture-equivalent; resumes audio + begins run).
+129
await evalJS("window.__wg.key('keydown',' ')"); await sleep(60);
+130
await evalJS("window.__wg.key('keyup',' ')");
+131
await sleep(3200); // starting fade (1.6s) + gameplay ramps up
+132
+133
const playPixels = await evalJS("JSON.stringify(window.__wg.pixels())");
+134
console.log("PLAY pixels:", playPixels);
+135
+136
// Sample audio RMS a few times during playback.
+137
let rms=[];
+138
for(let i=0;i<6;i++){ rms.push(await evalJS("window.__wg.rms()")); await sleep(250); }
+139
console.log("audio RMS samples:", rms.map(x=>x===null?null:Number(x).toFixed(4)).join(" "));
+140
+141
// Input: hold ArrowRight, sample a few frames, then release — proves live input.
+142
await evalJS("window.__wg.key('keydown','ArrowRight')");
+143
await sleep(900);
+144
const movePixels = await evalJS("JSON.stringify(window.__wg.pixels())");
+145
await evalJS("window.__wg.key('keyup','ArrowRight')");
+146
console.log("MOVE pixels:", movePixels);
+147
+148
// Screenshot for the record.
+149
const shot = await send("Page.captureScreenshot", { format:"png" });
+150
fs.writeFileSync("/tmp/web-game-verify.png", Buffer.from(shot.data,"base64"));
+151
console.log("screenshot -> /tmp/web-game-verify.png");
+152
+153
console.log("CONSOLE ERRORS:", consoleErrors.length ? JSON.stringify(consoleErrors.slice(0,10)) : "none");
+154
+155
chrome.kill("SIGTERM"); server.close();
+156
await sleep(200);
+157
process.exit(0);
src/cinder-cadence/audio.sglmodified
@@ -152,35 +152,52 @@
152
(define *audio-service* #f)
153
154
;; ----------------------------------------------------------------
155
;; Device layer shims (target-specific).
+155
;; Device layer (target-specific).
156
;;
157
;; On native these names come from (sigil audio) (sokol_audio owns an
158
;; always-running device). On wasm there is no such device: WebAudio needs
159
;; an AudioContext that starts suspended and resumes on a user gesture. We
160
;; provide (sigil audio)-shaped shims over the (sigil audio sink) context
161
;; ops so init-audio!/shutdown-audio! below run UNCHANGED on both targets.
+157
;; On native the device ops come from (sigil audio) (sokol_audio owns an
+158
;; always-running device). On wasm there is no such device: WebAudio needs an
+159
;; AudioContext that starts suspended and resumes on a user gesture; the
+160
;; context ops come from (sigil audio sink).
161
;;
163
;; resume-audio! exists on both: native is a no-op (device always running);
164
;; wasm re-resumes the context (the game's web entry calls it from the first
165
;; input event, which is the trusted user gesture WebAudio's autoplay policy
166
;; requires).
167
(cond-expand
168
(wasm
169
(define *web-audio-ctx* #f)
170
(define (audio-setup)
+162
;; IMPORTANT: cond-expand is used only as an EXPRESSION inside these normal
+163
;; top-level defines — never to produce the `(define ...)` forms themselves.
+164
;; Native codegen does NOT register a top-level define that lives inside a
+165
;; cond-expand (the binding resolves to an immediate and calling it traps
+166
;; "not a procedure"), so a cond-expand-defined shim like audio-setup would
+167
;; break the wasm build. See [[investigations/...]] / task notes.
+168
;;
+169
;; *web-audio-ctx* is defined unconditionally (unused/harmless on native).
+170
(define *web-audio-ctx* #f)
+171
+172
;; Bring the device up. Native: sokol audio-setup. Wasm: open + resume the
+173
;; WebAudio context (resume won't take until a user gesture, but opening now
+174
;; lets audio-context-sample-rate report the real rate).
+175
(define (device-setup!)
+176
(cond-expand
+177
(wasm
178
(unless *web-audio-ctx*
179
(set! *web-audio-ctx* (audio-context-open)))
180
(audio-context-resume *web-audio-ctx*)
181
*web-audio-ctx*)
175
(define (audio-shutdown) #t)
176
(define (stop-all-sounds) #t)
177
(define (resume-audio!)
178
(when *web-audio-ctx*
179
(audio-context-resume *web-audio-ctx*))))
180
(else
181
;; Native: audio-setup/audio-shutdown/stop-all-sounds come from
182
;; (sigil audio); the sokol device is always running so resume is moot.
183
(define (resume-audio!) #f)))
+182
(else
+183
(audio-setup))))
+184
+185
;; Tear the device down. Native: stop sounds + shut sokol. Wasm: no-op (the
+186
;; page owns the AudioContext lifecycle; streams are closed per-composition).
+187
(define (device-shutdown!)
+188
(cond-expand
+189
(wasm #t)
+190
(else
+191
(stop-all-sounds)
+192
(audio-shutdown))))
+193
+194
;; Re-resume the WebAudio context from a user gesture (the web entry calls
+195
;; this on the first input event). Native: no-op (device always running).
+196
(define (resume-audio!)
+197
(cond-expand
+198
(wasm (when *web-audio-ctx*
+199
(audio-context-resume *web-audio-ctx*)))
+200
(else #f)))
201
202
;; ----------------------------------------------------------------
203
;; Device lifecycle
@@ -190,11 +207,11 @@
207
((and *audio-service* (audio-service-initialized? *audio-service*))
208
*audio-service*)
209
(*audio-service*
193
(audio-setup)
+210
(device-setup!)
211
(set-audio-service-initialized?! *audio-service* #t)
212
*audio-service*)
213
(else
197
(audio-setup)
+214
(device-setup!)
215
(let ((svc (audio-service initialized?: #t)))
216
(set! *audio-service* svc)
217
svc))))
@@ -205,8 +222,7 @@
222
;; cleanly before we shut sokol_audio.
223
(let ((cur (audio-service-current-comp svc)))
224
(when cur (stop-composition! cur)))
208
(stop-all-sounds)
209
(audio-shutdown)
+225
(device-shutdown!)
226
(set-audio-service-initialized?! svc #f)
227
(set-audio-service-current-comp! svc #f)
228
(set-audio-service-sample-cache! svc '())))
@@ -241,13 +257,16 @@
257
;; is native-only, so on wasm SFX are dropped for the showcase (the music
258
;; streaming sink is the load-bearing audio path). A future web SFX path
259
;; would mix samples into the same streaming sink in Sigil.
244
(cond-expand
245
(wasm
246
(define (play-sample svc pcm (keys: (volume 1.0) (pan 0.0)))
+260
;;
+261
;; A single top-level define with a cond-expand EXPRESSION body — NOT a
+262
;; cond-expand-wrapped define (native codegen wouldn't register the binding;
+263
;; see the device layer note above).
+264
(define (play-sample svc pcm (keys: (volume 1.0) (pan 0.0)))
+265
(cond-expand
+266
(wasm
267
svc pcm volume pan
248
#f))
249
(else
250
(define (play-sample svc pcm (keys: (volume 1.0) (pan 0.0)))
+268
#f)
+269
(else
270
(when (audio-running? svc)
271
(let ((snd (or (lookup-sample-cache svc pcm)
272
(let* ((path (next-sample-path! svc))
src/cinder-cadence/web-app.sglmodified
@@ -15,6 +15,7 @@
15
16
(define-library (cinder-cadence web-app)
17
(import (sigil core)
+18
(sigil math)
19
(sigil string)
20
(sigil coroutines)
21
(sigil browser gles3))
@@ -26,7 +27,11 @@
27
request-quit quit-requested?
28
mouse-x mouse-y mouse-down? mouse-pressed? mouse-released?
29
;; web entry glue: the main module forwards sigil-web-dispatch-event here
29
web-app-dispatch-event)
+30
web-app-dispatch-event
+31
;; direct-drive frame model (coroutine-free): the entry sets keys via
+32
;; keydown/keyup dispatch, renders one frame per RAF tick, then calls
+33
;; web-app-end-frame! to snapshot the key edges for key-pressed?/released?.
+34
web-app-set-key! web-app-js-key->sym web-app-end-frame!)
35
(begin
36
37
;; ---- key state (fed by JS keydown/keyup via the dispatch entry) ----
@@ -111,6 +116,16 @@
116
(set! *keys-prev* *keys*)
117
(when (coroutine-done? *coro*) (gles3-stop-loop)))))
118
+119
;; ---- direct-drive frame model (coroutine-free) ----
+120
;; The coroutine-based run-game/wait-frame path above relies on delimited
+121
;; continuations (reset/shift), which do NOT work under native codegen, so
+122
;; the web entry drives frames directly instead: it feeds keys through these
+123
;; helpers, renders one frame per RAF "frame" dispatch, and calls
+124
;; web-app-end-frame! at the end of each frame to advance the edge snapshot.
+125
(define (web-app-set-key! sym active?) (set-key! sym active?))
+126
(define (web-app-js-key->sym k) (js-key->sym k))
+127
(define (web-app-end-frame!) (set! *keys-prev* *keys*))
+128
129
;; The entry module forwards its sigil-web-dispatch-event here.
130
(define (web-app-dispatch-event type payload)
131
(cond