Quartz v5.25

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:

#commitwhat
1c66b788b@export attribute: parser → AST → MIR → codegen_wasm, 6-test QSpec green
2ad560f73/chip8 page skeleton + 15 public-domain ROMs + nav entry
341bc53d1Phase 2: 35-opcode CHIP-8 emulator in pure Quartz, 40/40 LLVM tests
420ef103bPhase 3 structural: @export on 8 emulator entry points, 2 WASM bugs filed
5447f06b1Compiler fix: WASM Vec[i] loads via header[2] data_ptr, INDEX_STORE reads args[0]
6cb78d570Phase 4: live browser driver (canvas + rAF + keymap + Web Audio)
70badc5e8Compiler 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.wasm to this file on all three hostnames.
  • /etc/caddy/Caddyfile — includes a (chip8_override) snippet, chip8.mattkelly.io block with rewrite / /chip8.
  • /etc/caddy/Caddyfile.pre-wasm-override — rollback if the snippet needs to come out.

2. Current state (ground truth, verified end-of-session)

checkexpect
curl -sS https://chip8.mattkelly.io/<title>CHIP-8 in Quartz…
curl -sS https://chip8.mattkelly.io/chip8/chip8.wasm ∣ wc -c14076
curl -sS https://chip8.mattkelly.io/chip8/chip8.wasm ∣ sha256sum2d999fd203b9…0fd01 (matches site/public/chip8/chip8.wasm)
Node-side chip8_self_test on live WASM67 (0x43)
Node-side stray pixels after chip8_init0
LLVM examples/chip8/test_chip8.qz40/40
Fixpoint2148 functions, gen1 == gen2
style_demo + brainfuck smokegreen

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-golden
  • self-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

  1. String-literal size limit. The largest lines in site_assets.qz are ~794 KB (the /docs/roadmap page’s hex-escaped body on a single line). The lexer may have a buffer-growth or int-overflow bug at that size. Check frontend/lexer.qz for any fixed-size scratch space or counter that could wrap at half-a-meg.
  2. Label tracking leaking across functions. tc.loop_labels is a Vec<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 around tc.loop_labels in middle/typecheck_walk.qz (lines 2798-2805, 2849-2856).
  3. Something new in Astro output. The page build now includes chip8.astro_astro_type_script_*.js and the de-footered HTML is slightly smaller. Diff the current site_assets.qz against the state in ad560f73 (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:

  1. 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.
  2. Shared memory via raw pointers. Add a chip8_display_addr() export that returns the data pointer. JS wraps it in new 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:

  1. 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.
  2. Playground page cross-link — “Want more? Play CHIP-8 →”.
  3. Open Graph / meta tags. <meta property="og:image"> with a rendered screenshot of the emulator so link-unfurling in chat apps shows something recognizable.
  4. 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.
  5. Persistence. LocalStorage the last-selected ROM so reloads resume where you were. Small, cheap, nice UX.
  6. Fullscreen toggle. Canvas is 64×32 scaled — clicking it could go fullscreen. One JS function, one CSS rule.
  7. 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

  1. curl -I returns 404 on the unikernel. The HTTP router only handles GET — HEAD falls to the 404 path with a styled dark-mode body. Verify routes with curl -sS or explicit GET via printf ... | nc, never curl -I.
  2. wasmtime --invoke starts a fresh instance per call. State does NOT persist between --invoke calls — 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.
  3. wasmtime --invoke does NOT auto-run _start. Unlike wasmtime foo.wasm (no —invoke), which auto-runs _start for Command modules. The JS driver explicitly calls _start once after instantiate; the Node smoke test does the same. If a future test “somehow” shows aliased Vecs again, check this first.
  4. 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 (=== 1n not === 1). Number(x) is fine for small values, which is everything in CHIP-8.
  5. 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_INDEX was reading vec_handle + idx*8 instead of loading data_ptr from offset 16 first (447f06b1), and _start wasn’t calling __qz_module_init so data_ptr was 0 in every global vec (0badc5e8). If anything Vec-on-WASM looks weird, check these first.
  6. .scratch/ is gitignored and is where deployment / infra notes live. Every future session should update .scratch/DEPLOYMENT.md if VPS state changes.
  7. Base docs/roadmap is ~198 KB and produces ~794 KB of hex-escaped text in site_assets.qz. If the parser bug (§3) turns out to be size-related, lowering the bake’s tx_ceiling below 180000 will skip it at the cost of the /docs/roadmap URL 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_elf produces a working ELF.
  • New ELF deployed to VPS, serves the correct chip8.wasm from its own asset table.
  • Caddy override snippet removed, rm the override file.
  • curl -sS https://chip8.mattkelly.io/chip8/chip8.wasm | sha256sum still 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.md updated: override removed from the caddy section, any new gotchas added.
  • docs/bugs/WASM_VEC_INDEX_BROKEN.md updated with final “closed by commit X” note if not already.

If all green, ship.