Quartz v5.25

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):

  1. self-hosted/frontend/parser.qz — register "export" as a valid attribute name alongside "weak", "panic_handler", etc. Probably the ps_parse_attribute_decl dispatch around the lines that already handle weak = 1 / panic_handler = 1.

  2. AST propagation — if func nodes already carry an is_weak / is_panic_handler flag, add an is_export flag parallel to those. (AST schema is in self-hosted/frontend/ast.qz.)

  3. self-hosted/backend/codegen_wasm.qz — update _wasm_build_export_section at ~line 281. Today it emits a literal 2 for export count, then memory + _start. Change to:

    • Walk all module-level def nodes, filter to is_export == 1.
    • Count them + 2 (memory + _start) for the initial u32.
    • Emit memory + _start as before, then loop through the tagged funcs emitting one export each with WASM_EXPORT_FUNC + the func’s codegen index.
  4. Function-index lookup. Each @export function needs its codegen-assigned function index, which is established during the WASM function emission pass. Easiest: keep a parallel vec<(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:

  1. Scaffold examples/chip8/chip8.qz with the globals and init function.
  2. Implement the instruction decoder (the match nibble in §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.
  3. Add 0x3/0x4/0x5/0x9 (skip ops) and 0x8 (arithmetic).
  4. Add 0xD (draw sprite) — the biggest single op. Test against a known-output state from a cycle-accurate reference.
  5. Add 0xE (keypad), 0xF (timers + BCD + reg store).
  6. 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-roms for ROM files
  • https://github.com/Timendus/chip8-test-suite for 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:

  1. Add @export to 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.
  2. Compile with --backend wasm.
  3. Write a Node.js harness (or use wasmtime with a custom host function module) that loads a ROM into memory, calls chip8_step_frame a few times, dumps the display buffer, diffs against expected.
  4. 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)

  1. Grab 6 MIT-licensed ROMs from kripod/chip8-roms:
    • PONG, BRIX, INVADERS, TETRIS, MERLIN, MAZE
  2. Drop into site/public/chip8/roms/.
  3. Add .ch8application/octet-stream to tools/bake_assets.qz::detect_mime.
  4. Build the site, bake assets, rebuild ELF, scp, restart.
  5. Smoke-test each ROM over HTTPS.
  6. Add a “Play CHIP-8 in-browser” card to the landing, linking to /chip8.
  7. Link from playground page too.

Gotchas from prior sessions

  1. WASM backend builds take ~60s (not the stale 15-min claim). Compile IR 33s + llc 15s + link 2s. Plan rebuilds accordingly.
  2. .each() { it } / .filter() { it } on Vec fails on WASM (filed in docs/bugs/WASM_IMPLICIT_IT_LOCAL_OOB.md). Use for x in v iteration in emulator code. Shouldn’t be tempting anyway — the emulator is indexed access over fixed-size byte arrays.
  3. #{string_var} prints a pointer on WASM (filed in docs/bugs/WASM_STRING_INTERP_PTR.md). The emulator doesn’t print strings, but if you add debug output, know this.
  4. The compiler’s own build pipeline. quake guard is MANDATORY before any commit touching self-hosted/*.qz. The pre-commit hook will block you. Run it; don’t bypass.
  5. Don’t overwrite the rolling quartz-golden during a multi-rebuild debugging session. Save a fix-specific golden first (quartz-pre-export-attr-golden), as shown in Step 4 above.
  6. Shell cwd resets between Bash calls in the harness. Use absolute paths or explicit cd prefixes. Caught me twice yesterday.
  7. site/dist is a symlink to main repo’s site/dist, but site/public is real-in-worktree. Worktree has its own site/src and site/public, shares site/dist with main repo via symlink, and has site/node_modules symlinked 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:

  1. Fallback — export ALL module-level def functions by default in the WASM backend. Simple (no attribute needed), exposes more surface than ideal but unblocks everything. Can refine to @export later.

  2. Fallback — export by naming convention. Functions prefixed wasm_ get exported. 10 LOC change. Ugly, but works.

  3. Bailout — run CHIP-8 via repeated _start calls. 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/chip8 loads 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 .ch8 file also loads and plays.
  • Branch is merge-ready into trunk.
  • docs/CHIP8_WASM_DEMO.md is 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.