cinder web: fix three native-codegen runtime gaps; game renders + plays
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).
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(-)examples/web-game/src/web-game/main.sglmodified
(define-library (web-game main) (import (sigil core) (sigil math) (sigil graphics) (sigil browser gles3) (sigil audio sink) (define *game* #f) (define *audio-resumed?* #f) (define *last-ts* 0.0) ;; ------------------------------------------------------------------ ;; The per-frame render loop (mirrors playable-game-loop minus bloom). ;; Per-frame tick (coroutine-free direct drive). ;; ;; run-game (web-app) runs this thunk up to the first (wait-frame) during ;; init, then the RAF loop resumes it once per frame with dt. Audio is ;; pumped inside tick-playable! (tick-playing!), and audio-reactive events ;; are drained inside scene-render — so this loop just renders. (define (web-game-loop g) (let ((eng (game-engine g)) (sf (game-starfield g))) (let loop () (let ((dt (wait-frame))) ;; The gles3 app-shell's requestAnimationFrame dispatches a ("frame","<ms>") ;; event each frame; sigil-web-dispatch-event routes it here. This mirrors ;; main.sgl's playable-game-loop body MINUS the bloom post-processing pass ;; (bloom's #version 410 shaders + render targets are desktop-GL; the game ;; renders with filled primitives that work directly on WebGL2). Audio is ;; pumped inside tick-playable! (tick-playing!) and audio-reactive events are ;; drained inside scene-render, so this tick just renders + advances input. ;; ;; Why no coroutine: the (sigil app) run-game model on native suspends the ;; game thunk with (wait-frame); (cinder-cadence web-app) implements that ;; with (sigil coroutines), whose reset/shift delimited continuations do NOT ;; work under native codegen. Driving one frame per RAF tick sidesteps ;; continuations entirely. (define (web-tick ts-ms) (let* ((g *game*) (dt (/ (max 0.0 (- ts-ms *last-ts*)) 1000.0))) (set! *last-ts* ts-ms) (when g (let ((eng (game-engine g)) (sf (game-starfield g))) (gles3-resize-to-display) (begin-frame) ;; Phase-aware background (title black -> navy as play starts). (draw-score-hud (engine-scene eng) VIRTUAL-WIDTH VIRTUAL-HEIGHT) (draw-time-hud (engine-scene eng) g VIRTUAL-WIDTH VIRTUAL-HEIGHT)) (end-frame) (unless (quit-requested?) (loop)))) ;; Loop exited (quit) — tear the composition down. (when (game-composition g) (stop-composition! (game-composition g)) (set-game-composition! g #f)) (gfx-shutdown))) ;; Advance the key edge snapshot so key-pressed?/released? read one ;; frame's worth of transitions. (web-app-end-frame!))))) ;; ------------------------------------------------------------------ (define (main) (set-game-audio-svc! g audio) (set-game-sample-rate! g rate) (set! *game* g) ;; Hand the coroutine loop to the web-app shell; it starts the RAF ;; loop and resumes web-game-loop each frame. (run-game "Cinder Cadence" 960 720 (lambda () (web-game-loop g)))))) ;; Start the gles3 RAF loop; each frame dispatches ("frame","<ms>") ;; back to sigil-web-dispatch-event -> web-tick. The callback-name arg ;; is only used by the bytecode eval path; native codegen dispatches ;; through the typed event ABI. (gles3-start-loop "web-tick")))) ;; The gles3 app-shell forwards keydown/keyup/frame here. We resume the ;; WebAudio context on the first keydown (the trusted user gesture the ;; autoplay policy needs — the same key that starts the run), then forward ;; every event to the web-app shell (key table + coroutine frame tick). ;; The gles3 app-shell forwards keydown/keyup/frame here. On the first ;; keydown we resume the WebAudio context (the trusted user gesture the ;; autoplay policy needs — the same key that starts the run). Keys route to ;; the web-app key table; "frame" drives one game tick. (define (sigil-web-dispatch-event type payload) (when (and (not *audio-resumed?*) (string=? type "keydown")) (set! *audio-resumed?* #t) (resume-audio!)) (web-app-dispatch-event type payload)))) (cond ((string=? type "frame") (web-tick (string->number payload)) 0) ((string=? type "keydown") (when (not *audio-resumed?*) (set! *audio-resumed?* #t) (resume-audio!)) (web-app-set-key! (web-app-js-key->sym payload) #t) 0) ((string=? type "keyup") (web-app-set-key! (web-app-js-key->sym payload) #f) 0) (else 0)))))examples/web-game/verify.mjsadded
// CDP verify harness for the Cinder Cadence web-game.//// Serves build/web (COOP/COEP), launches google-chrome headless with software// WebGL (SwiftShader) + no-gesture autoplay, drives the page over the DevTools// Protocol, and checks:// - pressing Space starts the game (title -> gameplay, driven by input),// - the motif soundtrack produces signal (AnalyserNode RMS > 0),// - zero console errors.// Writes a screenshot to /tmp/web-game-verify.png — that IS the visual proof.//// NOTE: the `pixels()` drawImage readback reports 0 because the gles3 context// is created with preserveDrawingBuffer:false (an M3 perf fix), so the WebGL// canvas can't be copied into a 2D canvas after the RAF frame. Rely on the// captured screenshot for the visual check; audio RMS + zero-errors are the// programmatic asserts.import http from "node:http";import fs from "node:fs";import path from "node:path";import { spawn } from "node:child_process";const ROOT = path.resolve(process.argv[2] || "build/web");const PORT = 8091;const CDP = 9231;const TYPES = { ".html":"text/html;charset=utf-8", ".js":"text/javascript;charset=utf-8", ".mjs":"text/javascript;charset=utf-8", ".wasm":"application/wasm", ".json":"application/json;charset=utf-8", ".css":"text/css;charset=utf-8" };const server = http.createServer((req, res) => { const urlPath = decodeURIComponent((req.url || "/").split("?")[0]); let fp = path.join(ROOT, urlPath === "/" ? "/index.html" : urlPath); if (fp !== ROOT && !fp.startsWith(ROOT + path.sep)) { res.writeHead(403).end(); return; } fs.readFile(fp, (err, buf) => { res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); res.setHeader("Cross-Origin-Resource-Policy", "same-origin"); if (err) { res.writeHead(404).end("not found: " + urlPath); return; } res.writeHead(200, { "Content-Type": TYPES[path.extname(fp)] || "application/octet-stream" }); res.end(buf); });});await new Promise(r => server.listen(PORT, "127.0.0.1", r));console.log(`serving ${ROOT} at :${PORT}`);const udd = fs.mkdtempSync("/tmp/wg-chrome-");const chrome = spawn("google-chrome", [ "--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--enable-webgl", "--ignore-gpu-blocklist", "--autoplay-policy=no-user-gesture-required", `--remote-debugging-port=${CDP}`, `--user-data-dir=${udd}`, "--window-size=1000,820", "about:blank",], { stdio: "ignore" });function sleep(ms){ return new Promise(r=>setTimeout(r,ms)); }async function cdpTargets(){ const r=await fetch(`http://127.0.0.1:${CDP}/json`); return r.json(); }// wait for the browser + a page targetlet pageWs=null;for (let i=0;i<60;i++){ try { const ts=await cdpTargets(); const p=ts.find(t=>t.type==="page"); if(p&&p.webSocketDebuggerUrl){pageWs=p.webSocketDebuggerUrl;break;} } catch {} await sleep(250);}if(!pageWs){ console.error("no page target"); process.exit(1); }const ws = new WebSocket(pageWs);let id=0; const pending=new Map(); const consoleErrors=[];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})); }); }ws.addEventListener("message", ev=>{ const msg=JSON.parse(ev.data); 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; } if(msg.method==="Log.entryAdded"&&msg.params.entry.level==="error") consoleErrors.push(msg.params.entry.text); if(msg.method==="Runtime.consoleAPICalled"&&msg.params.type==="error") consoleErrors.push((msg.params.args||[]).map(a=>a.value||a.description||"").join(" ")); if(msg.method==="Runtime.exceptionThrown") consoleErrors.push("EXC: "+(msg.params.exceptionDetails?.exception?.description||msg.params.exceptionDetails?.text));});await new Promise((res,rej)=>{ ws.addEventListener("open",res); ws.addEventListener("error",rej); });await send("Page.enable"); await send("Runtime.enable"); await send("Log.enable");// Inject BEFORE page scripts: an AnalyserNode tap on every AudioNode.connect,// so we can read the produced audio signal without the app's handles.await send("Page.addScriptToEvaluateOnNewDocument", { source: ` (function(){ window.__wg = { analyser:null, ctx:null }; const OrigConnect = AudioNode.prototype.connect; AudioNode.prototype.connect = function(dest){ try { const ctx = this.context; if (ctx && !window.__wg.analyser) { window.__wg.ctx = ctx; const an = ctx.createAnalyser(); an.fftSize = 2048; window.__wg.analyser = an; OrigConnect.call(this, an); } } catch(e){} return OrigConnect.apply(this, arguments); }; window.__wg.rms = function(){ const a = window.__wg.analyser; if(!a) return -1; const buf = new Float32Array(a.fftSize); a.getFloatTimeDomainData(buf); let s=0; for(let i=0;i<buf.length;i++) s+=buf[i]*buf[i]; return Math.sqrt(s/buf.length); }; // non-background pixel count of the WebGL canvas (via a 2D copy). window.__wg.pixels = function(){ const c=document.getElementById('stage'); if(!c) return -1; const off=document.createElement('canvas'); off.width=c.width; off.height=c.height; const g=off.getContext('2d'); g.drawImage(c,0,0); const d=g.getImageData(0,0,c.width,c.height).data; let n=0, lum=0; 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; } } return {nonbg:n, lum:lum}; }; window.__wg.key = function(type,k){ document.dispatchEvent(new KeyboardEvent(type,{key:k,bubbles:true})); }; window.__wg.imports = function(){ try { return WebAssembly.Module.imports(window.__sigilModule||{}); } catch(e){ return null; } }; })();`});await send("Page.navigate", { url:`http://127.0.0.1:${PORT}/index.html` });await sleep(4500); // boot + wasm init + first framesasync 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; }const titlePixels = await evalJS("JSON.stringify(window.__wg.pixels())");console.log("TITLE pixels:", titlePixels);// Press Space to start (trusted-gesture-equivalent; resumes audio + begins run).await evalJS("window.__wg.key('keydown',' ')"); await sleep(60);await evalJS("window.__wg.key('keyup',' ')");await sleep(3200); // starting fade (1.6s) + gameplay ramps upconst playPixels = await evalJS("JSON.stringify(window.__wg.pixels())");console.log("PLAY pixels:", playPixels);// Sample audio RMS a few times during playback.let rms=[];for(let i=0;i<6;i++){ rms.push(await evalJS("window.__wg.rms()")); await sleep(250); }console.log("audio RMS samples:", rms.map(x=>x===null?null:Number(x).toFixed(4)).join(" "));// Input: hold ArrowRight, sample a few frames, then release — proves live input.await evalJS("window.__wg.key('keydown','ArrowRight')");await sleep(900);const movePixels = await evalJS("JSON.stringify(window.__wg.pixels())");await evalJS("window.__wg.key('keyup','ArrowRight')");console.log("MOVE pixels:", movePixels);// Screenshot for the record.const shot = await send("Page.captureScreenshot", { format:"png" });fs.writeFileSync("/tmp/web-game-verify.png", Buffer.from(shot.data,"base64"));console.log("screenshot -> /tmp/web-game-verify.png");console.log("CONSOLE ERRORS:", consoleErrors.length ? JSON.stringify(consoleErrors.slice(0,10)) : "none");chrome.kill("SIGTERM"); server.close();await sleep(200);process.exit(0);src/cinder-cadence/audio.sglmodified
(define *audio-service* #f) ;; ---------------------------------------------------------------- ;; Device layer shims (target-specific). ;; Device layer (target-specific). ;; ;; On native these names come from (sigil audio) (sokol_audio owns an ;; always-running device). On wasm there is no such device: WebAudio needs ;; an AudioContext that starts suspended and resumes on a user gesture. We ;; provide (sigil audio)-shaped shims over the (sigil audio sink) context ;; ops so init-audio!/shutdown-audio! below run UNCHANGED on both targets. ;; On native the device ops come from (sigil audio) (sokol_audio owns an ;; always-running device). On wasm there is no such device: WebAudio needs an ;; AudioContext that starts suspended and resumes on a user gesture; the ;; context ops come from (sigil audio sink). ;; ;; resume-audio! exists on both: native is a no-op (device always running); ;; wasm re-resumes the context (the game's web entry calls it from the first ;; input event, which is the trusted user gesture WebAudio's autoplay policy ;; requires). (cond-expand (wasm (define *web-audio-ctx* #f) (define (audio-setup) ;; IMPORTANT: cond-expand is used only as an EXPRESSION inside these normal ;; top-level defines — never to produce the `(define ...)` forms themselves. ;; Native codegen does NOT register a top-level define that lives inside a ;; cond-expand (the binding resolves to an immediate and calling it traps ;; "not a procedure"), so a cond-expand-defined shim like audio-setup would ;; break the wasm build. See [[investigations/...]] / task notes. ;; ;; *web-audio-ctx* is defined unconditionally (unused/harmless on native). (define *web-audio-ctx* #f) ;; Bring the device up. Native: sokol audio-setup. Wasm: open + resume the ;; WebAudio context (resume won't take until a user gesture, but opening now ;; lets audio-context-sample-rate report the real rate). (define (device-setup!) (cond-expand (wasm (unless *web-audio-ctx* (set! *web-audio-ctx* (audio-context-open))) (audio-context-resume *web-audio-ctx*) *web-audio-ctx*) (define (audio-shutdown) #t) (define (stop-all-sounds) #t) (define (resume-audio!) (when *web-audio-ctx* (audio-context-resume *web-audio-ctx*)))) (else ;; Native: audio-setup/audio-shutdown/stop-all-sounds come from ;; (sigil audio); the sokol device is always running so resume is moot. (define (resume-audio!) #f))) (else (audio-setup)))) ;; Tear the device down. Native: stop sounds + shut sokol. Wasm: no-op (the ;; page owns the AudioContext lifecycle; streams are closed per-composition). (define (device-shutdown!) (cond-expand (wasm #t) (else (stop-all-sounds) (audio-shutdown)))) ;; Re-resume the WebAudio context from a user gesture (the web entry calls ;; this on the first input event). Native: no-op (device always running). (define (resume-audio!) (cond-expand (wasm (when *web-audio-ctx* (audio-context-resume *web-audio-ctx*))) (else #f))) ;; ---------------------------------------------------------------- ;; Device lifecycle ((and *audio-service* (audio-service-initialized? *audio-service*)) *audio-service*) (*audio-service* (audio-setup) (device-setup!) (set-audio-service-initialized?! *audio-service* #t) *audio-service*) (else (audio-setup) (device-setup!) (let ((svc (audio-service initialized?: #t))) (set! *audio-service* svc) svc)))) ;; cleanly before we shut sokol_audio. (let ((cur (audio-service-current-comp svc))) (when cur (stop-composition! cur))) (stop-all-sounds) (audio-shutdown) (device-shutdown!) (set-audio-service-initialized?! svc #f) (set-audio-service-current-comp! svc #f) (set-audio-service-sample-cache! svc '()))) ;; is native-only, so on wasm SFX are dropped for the showcase (the music ;; streaming sink is the load-bearing audio path). A future web SFX path ;; would mix samples into the same streaming sink in Sigil. (cond-expand (wasm (define (play-sample svc pcm (keys: (volume 1.0) (pan 0.0))) ;; ;; A single top-level define with a cond-expand EXPRESSION body — NOT a ;; cond-expand-wrapped define (native codegen wouldn't register the binding; ;; see the device layer note above). (define (play-sample svc pcm (keys: (volume 1.0) (pan 0.0))) (cond-expand (wasm svc pcm volume pan #f)) (else (define (play-sample svc pcm (keys: (volume 1.0) (pan 0.0))) #f) (else (when (audio-running? svc) (let ((snd (or (lookup-sample-cache svc pcm) (let* ((path (next-sample-path! svc))src/cinder-cadence/web-app.sglmodified
(define-library (cinder-cadence web-app) (import (sigil core) (sigil math) (sigil string) (sigil coroutines) (sigil browser gles3)) request-quit quit-requested? mouse-x mouse-y mouse-down? mouse-pressed? mouse-released? ;; web entry glue: the main module forwards sigil-web-dispatch-event here web-app-dispatch-event) web-app-dispatch-event ;; direct-drive frame model (coroutine-free): the entry sets keys via ;; keydown/keyup dispatch, renders one frame per RAF tick, then calls ;; web-app-end-frame! to snapshot the key edges for key-pressed?/released?. web-app-set-key! web-app-js-key->sym web-app-end-frame!) (begin ;; ---- key state (fed by JS keydown/keyup via the dispatch entry) ---- (set! *keys-prev* *keys*) (when (coroutine-done? *coro*) (gles3-stop-loop))))) ;; ---- direct-drive frame model (coroutine-free) ---- ;; The coroutine-based run-game/wait-frame path above relies on delimited ;; continuations (reset/shift), which do NOT work under native codegen, so ;; the web entry drives frames directly instead: it feeds keys through these ;; helpers, renders one frame per RAF "frame" dispatch, and calls ;; web-app-end-frame! at the end of each frame to advance the edge snapshot. (define (web-app-set-key! sym active?) (set-key! sym active?)) (define (web-app-js-key->sym k) (js-key->sym k)) (define (web-app-end-frame!) (set! *keys-prev* *keys*)) ;; The entry module forwards its sigil-web-dispatch-event here. (define (web-app-dispatch-event type payload) (cond