Next session — CHIP-8 emulator in Quartz → WASM → browser
Baseline: unikernel-site branch at f9a76123 (or wherever
the tip is when this starts; the plan tolerates small drift).
Fixpoint 2144, guard stamp valid. Live production at
https://unikernel.mattkelly.io/.
Spec: docs/CHIP8_WASM_DEMO.md — read that
FIRST. It has the architecture, instruction set notes, file
plan, and phased milestones. This handoff is the driving doc,
not the reference doc.
Estimate: 5–8 quartz-hours spanning 3–5 context sessions. Each phase fits in one clean session with budget to spare.
Start here (first session — Phase 1 only)
Goal of this session: get @export attribute working in the
WASM backend, prove it with a 2-function test binary, commit +
guard. Nothing beyond that. Resist the urge to start the
emulator in the same session — Phase 1 is gating for all of
Phase 2–5 and you want the fixpoint clean before you pile more
on top.
Step 1 — probe and confirm the current state
cd /Users/mathisto/projects/quartz/.claude/worktrees/unikernel-site
# Confirm the WASM backend still has only 2 exports today:
wasm-objdump -x site/public/playground/demo/hello.wasm | grep -A2 "^Export"
# Expected:
# Export[2]:
# - memory[0] -> "memory"
# - func[N] <_start> -> "_start"
If you see more than 2 exports already, someone beat you to it — read the commit and skip to Step 3.
Step 2 — implement @export
Touch points (see CHIP8_WASM_DEMO.md §7 for the underlying design):
-
self-hosted/frontend/parser.qz— register"export"as a valid attribute name alongside"weak","panic_handler", etc. Probably theps_parse_attribute_decldispatch around the lines that already handleweak = 1/panic_handler = 1. -
AST propagation — if func nodes already carry an
is_weak/is_panic_handlerflag, add anis_exportflag parallel to those. (AST schema is inself-hosted/frontend/ast.qz.) -
self-hosted/backend/codegen_wasm.qz— update_wasm_build_export_sectionat ~line 281. Today it emits a literal2for export count, then memory +_start. Change to:- Walk all module-level
defnodes, filter tois_export == 1. - Count them + 2 (memory +
_start) for the initialu32. - Emit memory +
_startas before, then loop through the tagged funcs emitting one export each withWASM_EXPORT_FUNC+ the func’s codegen index.
- Walk all module-level
-
Function-index lookup. Each
@exportfunction needs its codegen-assigned function index, which is established during the WASM function emission pass. Easiest: keep a parallelvec<(name, func_idx)>that’s populated as functions are emitted, then consumed by the export section.
Step 3 — spec
Create spec/qspec/wasm_export_attr_spec.qz:
# Runs via the existing subprocess-style WASM spec infra.
# Approximate shape — match the existing wasm_core_spec.qz
# conventions when you write this.
import * from qspec
def helper_42(): Int = 42
# Stand-alone test program:
@export def answer(): Int = 42
@export def greet(n: Int): Int = n + 1
def main(): Int
describe("@export attribute") do ->
it("appears in WASM export section") do ->
# Compile self-file with --backend wasm, inspect exports.
# Should see 4: memory, _start, answer, greet.
end
end
return qspec_main()
end
Exact shape depends on how wasm_core_spec.qz structures its
assertions — read that first and mimic.
Step 4 — guard + commit
export PATH="/opt/homebrew/opt/llvm/bin:$PATH"
# Save escape hatch — compiler source is about to change:
cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-export-attr-golden
# Build + fixpoint + stage binary
./self-hosted/bin/quake guard
# Smoke-test against non-self programs (Rule 2):
./self-hosted/bin/quartz examples/style_demo.qz | llc -filetype=obj -o /tmp/sd.o
clang /tmp/sd.o -o /tmp/sd -lm -lpthread && /tmp/sd | head -3
./self-hosted/bin/quartz examples/brainfuck.qz | llc -filetype=obj -o /tmp/bf.o
clang /tmp/bf.o -o /tmp/bf -lm -lpthread && /tmp/bf | head -3
# Verify @export actually works end-to-end via wasmtime/wasm-objdump:
# (need the --feature wasm binary — see next section)
Step 5 — verify the WASM output
The --feature wasm compiler binary lives at
/tmp/wasm-build/quartz_wasm from a prior session and may be
stale. Rebuild it if needed:
./self-hosted/bin/quartz --feature wasm \
-I self-hosted/frontend -I self-hosted/middle -I self-hosted/backend \
-I self-hosted/shared -I std -I tools --no-cache self-hosted/quartz.qz \
> /tmp/wasm-build/quartz_wasm.ll
llc -filetype=obj /tmp/wasm-build/quartz_wasm.ll -o /tmp/wasm-build/quartz_wasm.o
clang /tmp/wasm-build/quartz_wasm.o -o /tmp/wasm-build/quartz_wasm -lm -lpthread
# ~60 seconds total.
Then drive the test program:
# /tmp/export_test.qz
@export def answer(): Int = 42
@export def greet(n: Int): Int = n + 1
def main(): Int
return 0
end
/tmp/wasm-build/quartz_wasm --backend wasm /tmp/export_test.qz -o /tmp/et.wasm
wasm-objdump -x /tmp/et.wasm | grep -A5 "^Export"
# Expected:
# Export[4]:
# - memory[0] -> "memory"
# - func[N] <_start> -> "_start"
# - func[M] <answer> -> "answer"
# - func[K] <greet> -> "greet"
Commit as one tight patch:
[codegen] Add @export attribute for WASM backend
Adds parser support for @export, propagates through AST, and
extends _wasm_build_export_section in codegen_wasm.qz to emit
all tagged functions as WASM exports alongside _start and
memory.
Unblocks the CHIP-8 demo (see docs/CHIP8_WASM_DEMO.md) which
needs 8 named entry points for JS to drive.
Spec: spec/qspec/wasm_export_attr_spec.qz — asserts a binary
with two @export defs produces 4 exports in its WASM binary.
Fixpoint 2144 held; style_demo + brainfuck smoke-tested green.
Stop here. Phase 2 is a fresh session.
Phase 2 — emulator core (second session)
Read CHIP8_WASM_DEMO.md §1–§6 first. Core structure is all documented.
Sequence:
- Scaffold
examples/chip8/chip8.qzwith the globals and init function. - Implement the instruction decoder (the
match nibblein §3) one group at a time. Start with 0x1/0x2/0x6/0x7/0xA — those are the simplest and let you run MAZE.ch8, the smallest ROM. - Add 0x3/0x4/0x5/0x9 (skip ops) and 0x8 (arithmetic).
- Add 0xD (draw sprite) — the biggest single op. Test against a known-output state from a cycle-accurate reference.
- Add 0xE (keypad), 0xF (timers + BCD + reg store).
examples/chip8/test_chip8.qz— harness that loads a ROM, runs N steps, prints CPU state. Verify against a reference trace if you have one, else sanity-check manually.
Test the LLVM backend, not WASM yet. Iteration is fast, bugs are in logic only, no WASM backend quirks to confuse things.
Reference resources (add to the session’s research Fetch queue):
- The CHIP-8 wiki on Wikipedia (instruction table)
https://github.com/kripod/chip8-romsfor ROM fileshttps://github.com/Timendus/chip8-test-suitefor a test suite that ships with a cycle-accurate trace you can diff against.
Expected LOC: ~400 for chip8.qz. Don’t panic about size — the
emulator is ~35 opcodes, each 5–20 lines.
Phase 3 — WASM adaptation (third session)
With Phase 1 merged and Phase 2’s emulator passing the LLVM harness:
- Add
@exportto the 8 entry points in §3 of the spec:chip8_init,chip8_load_rom,chip8_reset,chip8_step_frame,chip8_tick_timers,chip8_ram_addr,chip8_display_addr,chip8_keys_addr,chip8_sound_playing. - Compile with
--backend wasm. - Write a Node.js harness (or use wasmtime with a custom host
function module) that loads a ROM into memory, calls
chip8_step_framea few times, dumps the display buffer, diffs against expected. - Any opcode that misbehaves on WASM is a backend bug — file
under
docs/bugs/and work around if cheap, patch if hard.
Most likely WASM-only issue: 8-bit wraparound semantics. The
emulator should be written as g_v[x] = (g_v[x] + kk) & 0xFF to
be backend-agnostic; if a mask is missing, LLVM’s
sign-extension may paper over it while WASM doesn’t. Audit.
Phase 4 — browser integration (fourth session)
See CHIP8_WASM_DEMO.md §9 for the page layout. Deliverable:
site/src/pages/chip8.astro with:
<canvas id="screen" width="640" height="320" style="image-rendering: pixelated">- Dropdown
<select>populated from a JS list of ROM names <input type="file">for user uploads- Inline JS with instantiate + rAF loop + keyboard mapper + Web Audio beep
Test locally with the Astro dev server + a ROM file served out of
site/public/chip8/roms/PONG.ch8. Confirm PONG plays by pressing
1/4 (up/down on left paddle).
Phase 5 — bake + deploy (fifth session)
- Grab 6 MIT-licensed ROMs from kripod/chip8-roms:
- PONG, BRIX, INVADERS, TETRIS, MERLIN, MAZE
- Drop into
site/public/chip8/roms/. - Add
.ch8→application/octet-streamtotools/bake_assets.qz::detect_mime. - Build the site, bake assets, rebuild ELF, scp, restart.
- Smoke-test each ROM over HTTPS.
- Add a “Play CHIP-8 in-browser” card to the landing, linking to
/chip8. - Link from playground page too.
Gotchas from prior sessions
- WASM backend builds take ~60s (not the stale 15-min claim). Compile IR 33s + llc 15s + link 2s. Plan rebuilds accordingly.
.each() { it }/.filter() { it }on Vec fails on WASM (filed indocs/bugs/WASM_IMPLICIT_IT_LOCAL_OOB.md). Usefor x in viteration in emulator code. Shouldn’t be tempting anyway — the emulator is indexed access over fixed-size byte arrays.#{string_var}prints a pointer on WASM (filed indocs/bugs/WASM_STRING_INTERP_PTR.md). The emulator doesn’t print strings, but if you add debug output, know this.- The compiler’s own build pipeline.
quake guardis MANDATORY before any commit touchingself-hosted/*.qz. The pre-commit hook will block you. Run it; don’t bypass. - Don’t overwrite the rolling
quartz-goldenduring a multi-rebuild debugging session. Save a fix-specific golden first (quartz-pre-export-attr-golden), as shown in Step 4 above. - Shell cwd resets between Bash calls in the harness. Use
absolute paths or explicit
cdprefixes. Caught me twice yesterday. site/distis a symlink to main repo’ssite/dist, butsite/publicis real-in-worktree. Worktree has its ownsite/srcandsite/public, sharessite/distwith main repo via symlink, and hassite/node_modulessymlinked to main (set up in a prior session).
What happens if Phase 1 is harder than expected
The @export attribute MAY surface a deeper issue: the WASM
backend’s function-index tracking during export-section
emission. If the fix takes more than 2 quartz-hours, alternatives
in decreasing order of preference:
-
Fallback — export ALL module-level
deffunctions by default in the WASM backend. Simple (no attribute needed), exposes more surface than ideal but unblocks everything. Can refine to@exportlater. -
Fallback — export by naming convention. Functions prefixed
wasm_get exported. 10 LOC change. Ugly, but works. -
Bailout — run CHIP-8 via repeated
_startcalls. Would require re-instantiating the WASM module per frame, losing internal state. DO NOT take this path; it doesn’t work for actual games. If Phase 1 is totally blocked, file a deeper compiler task and pivot to a different demo.
Out of scope
- HTTPS for
/chip8(already handled — runs under the existing cert). - Caddy config changes (none needed).
- Compiler changes OTHER than
@export. If the emulator needs a missing intrinsic, file a bug and work around, don’t spike scope.
Success criteria
End of Phase 5, these should all be true:
-
https://unikernel.mattkelly.io/chip8loads a working CHIP-8 emulator page in < 500 ms. - Picking “PONG” from the dropdown and pressing Run plays PONG in the canvas.
- Keyboard mapping works — 1/Q = paddle up/down on the left side, 4/R on the right.
- Sound timer > 0 makes an audible beep.
- A user-uploaded
.ch8file also loads and plays. - Branch is merge-ready into trunk.
-
docs/CHIP8_WASM_DEMO.mdis updated with what shipped and what didn’t (any phase-5 deviations get a short “reality” section). - The unikernel’s PMM page count is still flat across many requests (no regression on the leak fix).
If all green, ship. If anything red, last session’s handoff loop continues.