Next session — CHIP-8 post-launch: rebake, un-hack Caddy, polish
Baseline: unikernel-site branch at 0badc5e8 (or wherever the
tip is when you start; the plan tolerates small drift).
Fixpoint 2148 functions, guard stamp valid.
Live: https://chip8.mattkelly.io/ — real CHIP-8 emulator. Pick a
ROM, hit Run, play in the browser. 15 public-domain ROMs baked in,
custom .ch8 upload works, keyboard mapping live, Web Audio beep
on sound-timer.
But — there’s a hack propping up “live.” The /chip8/chip8.wasm
request does NOT come from the unikernel. It comes from Caddy
serving /opt/quartz/chip8-override.wasm out of the VPS filesystem,
because the baked unikernel’s copy is from before a Vec-indexing
fix landed. The next session’s first priority is root-causing
the baremetal compile bug that blocks re-baking, then removing the
Caddy override. See §3.
Read FIRST:
.scratch/DEPLOYMENT.md(gitignored; local only) — VPS/Caddy/ systemd layout, deploy pipeline, SSH details.docs/CHIP8_WASM_DEMO.md— original spec, architecture, phase plan.docs/bugs/WASM_VOID_CALL_STACK_MISMATCH.md— open compiler gap (Void-returning user fns produce invalid WASM).docs/bugs/WASM_VEC_INDEX_BROKEN.md— closed this session (447f06b1). Keep for the write-up.
1. What shipped this session
Seven commits on unikernel-site:
| # | commit | what |
|---|---|---|
| 1 | c66b788b | @export attribute: parser → AST → MIR → codegen_wasm, 6-test QSpec green |
| 2 | ad560f73 | /chip8 page skeleton + 15 public-domain ROMs + nav entry |
| 3 | 41bc53d1 | Phase 2: 35-opcode CHIP-8 emulator in pure Quartz, 40/40 LLVM tests |
| 4 | 20ef103b | Phase 3 structural: @export on 8 emulator entry points, 2 WASM bugs filed |
| 5 | 447f06b1 | Compiler fix: WASM Vec[i] loads via header[2] data_ptr, INDEX_STORE reads args[0] |
| 6 | cb78d570 | Phase 4: live browser driver (canvas + rAF + keymap + Web Audio) |
| 7 | 0badc5e8 | Compiler fix: _start calls __qz_module_init before main (module-level Vec globals); footer removed |
LLVM fixpoint unchanged (2148 fns) because all WASM-path edits are in emission code paths the compiler’s self-compile doesn’t exercise.
Also deployed this session but not in repo:
/opt/quartz/quartz-unikernel.elf— last known-good ELF (Apr 19 20:35 build) running live./opt/quartz/quartz-unikernel.elf.prev— rollback target./opt/quartz/chip8-override.wasm— current fixed WASM, Caddy routes/chip8/chip8.wasmto this file on all three hostnames./etc/caddy/Caddyfile— includes a(chip8_override)snippet,chip8.mattkelly.ioblock withrewrite / /chip8./etc/caddy/Caddyfile.pre-wasm-override— rollback if the snippet needs to come out.
2. Current state (ground truth, verified end-of-session)
| check | expect |
|---|---|
curl -sS https://chip8.mattkelly.io/ | <title>CHIP-8 in Quartz… |
curl -sS https://chip8.mattkelly.io/chip8/chip8.wasm ∣ wc -c | 14076 |
curl -sS https://chip8.mattkelly.io/chip8/chip8.wasm ∣ sha256sum | 2d999fd203b9…0fd01 (matches site/public/chip8/chip8.wasm) |
Node-side chip8_self_test on live WASM | 67 (0x43) |
Node-side stray pixels after chip8_init | 0 |
LLVM examples/chip8/test_chip8.qz | 40/40 |
| Fixpoint | 2148 functions, gen1 == gen2 |
style_demo + brainfuck smoke | green |
3. The compile-blocking parser bug — root-cause this first
Running ./self-hosted/bin/quake baremetal:build_elf fails with
~30 errors of the form:
error[QZ0200]: Duplicate loop label :
error[QZ0401]: Undefined function: push
error[QZ0200]: Cannot index non-array type
Notice the first three have no source location at all — the
error message is literally Duplicate loop label : with an empty
label name. That’s a typecheck dump from
typecheck_walk.qz:2802, and it’s a giveaway that the parser’s
label state is corrupt by the time typecheck runs.
Verified pre-existing. All of these binaries reproduce it
against the same tmp/baremetal/quartz-unikernel-src.qz:
self-hosted/bin/backups/quartz-pre-export-attr-golden(before ANY of this session’s changes)self-hosted/bin/backups/quartz-pre-wasm-vec-dataptr-goldenself-hosted/bin/backups/quartz-pre-wasm-start-init-golden
So this is not from the last three guard cycles. The trigger
is the content of the concatenated quartz-unikernel-src.qz, and
specifically tools/baremetal/site_assets.qz (the auto-generated
asset table). Lowering tx_ceiling in tools/bake_assets.qz to
skip the largest files (/docs/roadmap at 198 KB, others) did
NOT make the error go away at 150000.
Candidates to investigate
- String-literal size limit. The largest lines in
site_assets.qzare ~794 KB (the/docs/roadmappage’s hex-escaped body on a single line). The lexer may have a buffer-growth or int-overflow bug at that size. Checkfrontend/lexer.qzfor any fixed-size scratch space or counter that could wrap at half-a-meg. - Label tracking leaking across functions.
tc.loop_labelsis aVec<String>— if it isn’t cleared between functions (or between parses), labeled loops in early-parsed code stay “visible” to later labeled loops. Check the push/pop discipline aroundtc.loop_labelsinmiddle/typecheck_walk.qz(lines 2798-2805, 2849-2856). - Something new in Astro output. The page build now
includes
chip8.astro_astro_type_script_*.jsand the de-footered HTML is slightly smaller. Diff the currentsite_assets.qzagainst the state inad560f73(which baked + built + shipped successfully this morning) to see what’s new. If one specific asset triggers the issue, excluding it via the bake’s skip list is a clean workaround.
Minimal repro
cd /Users/mathisto/projects/quartz/.claude/worktrees/unikernel-site
./self-hosted/bin/quartz --no-cache --target x86_64-unknown-none-elf \
tmp/baremetal/quartz-unikernel-src.qz 2>&1 | head -20
# → "Duplicate loop label :" on lines 1-3
If tmp/baremetal/quartz-unikernel-src.qz doesn’t exist, run
./self-hosted/bin/quake baremetal:bake_assets first; the
concat step in build_elf creates it.
Success criteria for this piece
quake baremetal:build_elf produces an ELF; scp + restart
serves /chip8/chip8.wasm from the new baked copy (byte-identical
to /opt/quartz/chip8-override.wasm); the Caddy override snippet
can be pulled and the live hash still matches.
4. Once rebake works — remove the Caddy override
# 1. Local: rebuild + ship
cd /Users/mathisto/projects/quartz/.claude/worktrees/unikernel-site
pnpm --prefix site build
./self-hosted/bin/quake baremetal:bake_assets
./self-hosted/bin/quake baremetal:build_elf
ssh mattkelly.io 'cp /opt/quartz/quartz-unikernel.elf /opt/quartz/quartz-unikernel.elf.prev'
scp tmp/baremetal/quartz-unikernel.elf mattkelly.io:/opt/quartz/quartz-unikernel.elf
ssh mattkelly.io 'systemctl restart quartz-unikernel'
# 2. Verify the unikernel now serves the CORRECT WASM
curl -sS https://chip8.mattkelly.io/chip8/chip8.wasm | sha256sum
# Expect: 2d999fd203b97a2760c09e0a18b8b1156883a538d059dac69602e65bc200fd01
# 3. Pull the Caddy override
ssh mattkelly.io
# Edit /etc/caddy/Caddyfile; remove the entire `(chip8_override)`
# snippet and the three `import chip8_override` lines in each
# hostname block. Diff against /etc/caddy/Caddyfile.pre-wasm-override
# to see exactly what came in with the override.
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
# 4. Verify — bytes come from the unikernel, not /opt/quartz
curl -sS https://chip8.mattkelly.io/chip8/chip8.wasm | sha256sum
# Still 2d999f...; but now the response comes from the unikernel.
# ssh mattkelly.io 'tail -5 /var/log/quartz-unikernel.log'
# should show the GET being served.
# 5. Remove the override file
ssh mattkelly.io 'rm /opt/quartz/chip8-override.wasm'
5. Compiler debt worth chasing next
WASM_VOID_CALL_STACK_MISMATCH (still open)
docs/bugs/WASM_VOID_CALL_STACK_MISMATCH.md. Every _chip8_*
helper in examples/chip8/chip8.qz is : Int that return 0
purely to dodge this. Fix the compiler, then revert those
to : Void and re-verify. LLVM regression: test_chip8.qz
still 40/40 after revert.
Per-byte WASM↔JS crossings are slow
chip8_display_read(idx) is called 2048 times per frame from
the browser. At 60 Hz that’s 120k crossings/sec — fine for CHIP-8
but embarrassing for a demo people read about. Two routes:
chip8_display_bulk_copy(dst_ptr)— one Quartz export that iterates the display and writes bytes to a caller-provided WASM memory region. JS reads the region in one DataView pass.- Shared memory via raw pointers. Add a
chip8_display_addr()export that returns the data pointer. JS wraps it innew Uint8Array(mem.buffer, addr, 2048)and reads directly. Faster, but the Quartz Vec layout has 8 bytes per element currently — JS would see every 8th byte. Either pack the display as bytes (new Quartz feature) or have JS stride.
Pick one and file it. Priority is low; emulator already runs at 120 FPS on the user’s machine.
6. Polish items (phase 5 of the original plan)
In rough descending impact order:
- Landing page (/) link to /chip8. Add a card or link block so visitors discover the emulator from the main page. Currently only discoverable from the top nav.
- Playground page cross-link — “Want more? Play CHIP-8 →”.
- Open Graph / meta tags.
<meta property="og:image">with a rendered screenshot of the emulator so link-unfurling in chat apps shows something recognizable. - ROM descriptions. Below the canvas, a one-liner for the currently-selected ROM (“Pong — two-paddle breakout from 1990”) plus keypad hints specific to that game.
- Persistence. LocalStorage the last-selected ROM so reloads resume where you were. Small, cheap, nice UX.
- Fullscreen toggle. Canvas is 64×32 scaled — clicking it could go fullscreen. One JS function, one CSS rule.
- FPS + cycles always-on (currently updates every 500 ms). At 120 FPS the telemetry feels laggy — tighten to 100 ms.
None of these are required; the demo is already “live and playable.” Treat as nice-to-haves if a future session has budget.
7. Gotchas from this session
curl -Ireturns 404 on the unikernel. The HTTP router only handles GET — HEAD falls to the 404 path with a styled dark-mode body. Verify routes withcurl -sSor explicit GET viaprintf ... | nc, nevercurl -I.wasmtime --invokestarts a fresh instance per call. State does NOT persist between--invokecalls — each one re-runs_start(or not, see gotcha 3). Any test that assumes “call init, then call step” across two invokes will see a zeroed global state on the second call.wasmtime --invokedoes NOT auto-run_start. Unlikewasmtime foo.wasm(no —invoke), which auto-runs_startfor Command modules. The JS driver explicitly calls_startonce after instantiate; the Node smoke test does the same. If a future test “somehow” shows aliased Vecs again, check this first.- WASM i64 ↔ JS BigInt. All emulator exports take/return
i64. JS coerces Number → BigInt automatically for i64 args on modern browsers, but comparisons need BigInt (=== 1nnot=== 1).Number(x)is fine for small values, which is everything in CHIP-8. - Vec layout is
[capacity, size, data_ptr, elem_width]as four i64 slots. The WASM backend had two separate bugs related to this in-session —MIR_INDEXwas readingvec_handle + idx*8instead of loadingdata_ptrfrom offset 16 first (447f06b1), and_startwasn’t calling__qz_module_initsodata_ptrwas 0 in every global vec (0badc5e8). If anything Vec-on-WASM looks weird, check these first. .scratch/is gitignored and is where deployment / infra notes live. Every future session should update.scratch/DEPLOYMENT.mdif VPS state changes.- Base
docs/roadmapis ~198 KB and produces ~794 KB of hex-escaped text insite_assets.qz. If the parser bug (§3) turns out to be size-related, lowering the bake’stx_ceilingbelow 180000 will skip it at the cost of the/docs/roadmapURL on the live site.
8. Success criteria — when this session is “done”
- Parser error from §3 root-caused + fixed OR a clean workaround merged (with a filed bug if deferred).
-
quake baremetal:build_elfproduces a working ELF. - New ELF deployed to VPS, serves the correct chip8.wasm from its own asset table.
- Caddy override snippet removed,
rmthe override file. -
curl -sS https://chip8.mattkelly.io/chip8/chip8.wasm | sha256sumstill matches local (proves the unikernel is serving the fixed WASM now, not the disk file). - Open the page in a browser, pick a ROM, verify zero stray pixels at load and that PONG is playable.
-
.scratch/DEPLOYMENT.mdupdated: override removed from the caddy section, any new gotchas added. -
docs/bugs/WASM_VEC_INDEX_BROKEN.mdupdated with final “closed by commit X” note if not already.
If all green, ship.