1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 17:06:04 +00:00
nescript/tests/emulator/run_examples.mjs

410 lines
15 KiB
JavaScript
Raw Normal View History

tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
// End-to-end smoke test: runs every compiled `.nes` in `examples/`
// through a local `jsnes` (wrapped by `harness.html` in a
// puppeteer-driven headless Chrome), lets it render ~180 frames,
// grabs the raw canvas pixels, and diffs them byte-for-byte against
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
// a committed golden PNG under `goldens/`. It also hashes the
// audio samples jsnes produced during those frames and compares
// them against a committed `<name>.audio.hash` golden in the same
// directory — a one-line FNV-1a hash of the full int16 stereo
// buffer. Silent programs all hash to the same well-known value;
// audio-producing programs get a distinct hash that will trip as
// soon as the audio driver changes behavior.
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
//
// The goldens are the whole contract. Any change to the compiler
// (or any regression in jsnes, or any change to this harness that
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
// affects rendering or audio) will change at least one golden,
// and the diff will fail CI loudly. That's the point — it's the
// only way to catch "silently emits wrong code" bugs without
// writing a full-fat CPU test vector per example.
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
//
// Updating goldens:
//
// UPDATE_GOLDENS=1 node run_examples.mjs
// # or
// node run_examples.mjs --update-goldens
//
// When a diff is legitimate, rerun with that flag. It rewrites the
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
// PNGs and audio hashes in `goldens/` from whatever the harness
// just produced. Then check the new files in git — `git diff
// goldens/` lets you eye each change, and the commit message is
// where you document why.
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
//
// When a diff is not legitimate, the runner writes:
//
// actual/<name>.png the actual pixels for this run
// actual/<name>.diff.png red-highlighted pixel diff vs. golden
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
// actual/<name>.wav the actual audio samples (stereo PCM)
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
//
// so you can upload them as CI artifacts or inspect locally. The
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
// `actual/` directory is gitignored. Video diffs write the PNG +
// diff PNG; audio diffs write the WAV so you can listen to what
// actually came out of the emulator.
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import puppeteer from "puppeteer";
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
import { PNG } from "pngjs";
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", "..");
const examplesDir = path.join(repoRoot, "examples");
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
const goldensDir = path.join(__dirname, "goldens");
const actualDir = path.join(__dirname, "actual");
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
const harnessUrl = pathToFileURL(path.join(__dirname, "harness.html")).toString();
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
const WIDTH = 256;
const HEIGHT = 240;
const BYTES_PER_PIXEL = 4; // RGBA
const PIXEL_BYTES = WIDTH * HEIGHT * BYTES_PER_PIXEL;
const FRAMES_TO_RUN = 180; // ~3 seconds at 60 fps
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
const SCREENSHOT_FRAME = 180;
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
const updateGoldens =
process.env.UPDATE_GOLDENS === "1" ||
process.env.UPDATE_GOLDENS === "true" ||
process.argv.includes("--update-goldens");
// ── PNG helpers ────────────────────────────────────────────────
// Decode a PNG file to a raw RGBA Buffer of length PIXEL_BYTES.
// Rejects if the file doesn't exist or has the wrong dimensions.
async function decodeGolden(filePath) {
const bytes = await fs.readFile(filePath);
const png = PNG.sync.read(bytes);
if (png.width !== WIDTH || png.height !== HEIGHT) {
throw new Error(
`golden ${filePath} has wrong dimensions ${png.width}x${png.height}, expected ${WIDTH}x${HEIGHT}`,
);
}
// `png.data` is already RGBA in top-left-first row-major order.
return png.data;
}
// Encode a raw RGBA Buffer to a PNG file.
async function writePng(filePath, rgba) {
if (rgba.length !== PIXEL_BYTES) {
throw new Error(
`writePng: expected ${PIXEL_BYTES} bytes, got ${rgba.length}`,
);
}
const png = new PNG({ width: WIDTH, height: HEIGHT });
rgba.copy(png.data);
const buf = PNG.sync.write(png);
await fs.writeFile(filePath, buf);
}
// Build a diff PNG: mismatching pixels in bright red, matching
// pixels in dim grayscale so you can still see the sprite silhouettes
// for context. First differing pixel is also returned for logs.
function buildDiff(expected, actual) {
const out = Buffer.alloc(PIXEL_BYTES);
let mismatched = 0;
let firstDiff = null;
for (let i = 0; i < PIXEL_BYTES; i += 4) {
const eR = expected[i];
const eG = expected[i + 1];
const eB = expected[i + 2];
const aR = actual[i];
const aG = actual[i + 1];
const aB = actual[i + 2];
const same = eR === aR && eG === aG && eB === aB;
if (same) {
// Dim grayscale of the expected pixel — 25% brightness,
// preserves the silhouette without competing with red.
const gray = Math.round((eR * 0.299 + eG * 0.587 + eB * 0.114) * 0.25);
out[i] = gray;
out[i + 1] = gray;
out[i + 2] = gray;
out[i + 3] = 0xff;
} else {
mismatched++;
if (firstDiff === null) {
const px = (i / 4) | 0;
firstDiff = {
x: px % WIDTH,
y: (px / WIDTH) | 0,
expected: [eR, eG, eB],
actual: [aR, aG, aB],
};
}
out[i] = 0xff;
out[i + 1] = 0x00;
out[i + 2] = 0x00;
out[i + 3] = 0xff;
}
}
return { mismatched, firstDiff, rgba: out };
}
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
// ── File helpers ───────────────────────────────────────────────
// True if `filePath` exists and is readable. Used to decide
// whether a golden needs to be created (missing) vs diffed.
async function exists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
// Write an audio golden file. The format is a single line:
//
// <hex-hash> <sample-count>
//
// chosen to be tiny (under 30 bytes per ROM), trivially diffable
// by git, and human-readable. The sample count is a sanity check
// — it catches the rare case where two runs produce differing
// sample counts but the same hash (practically zero probability
// with FNV-1a, but cheap insurance).
async function writeAudioGolden(filePath, audio) {
await fs.writeFile(filePath, `${audio.hash} ${audio.samples}\n`);
}
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
// ── ROM discovery ──────────────────────────────────────────────
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
async function listRoms() {
const entries = await fs.readdir(examplesDir);
return entries
.filter((f) => f.endsWith(".nes"))
.sort()
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
.map((f) => ({
name: f.replace(/\.nes$/, ""),
file: path.join(examplesDir, f),
}));
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
}
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
// ── Harness driver ─────────────────────────────────────────────
async function runRomInHarness(page, rom) {
const romBytes = await fs.readFile(rom.file);
const romB64 = romBytes.toString("base64");
let bootError = null;
try {
await page.evaluate((b64) => window.nesHarness.loadRomBase64(b64), romB64);
} catch (err) {
bootError = String(err);
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
return { bootError, rgba: null, audio: null };
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
}
try {
// Use runFrames — a single round-trip is much faster than
// 180 separate `frame()` calls across puppeteer's RPC.
await page.evaluate(
(n) => window.nesHarness.runFrames(n),
FRAMES_TO_RUN,
);
} catch (err) {
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
return { bootError: String(err), rgba: null, audio: null };
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
}
// Frame count here is a no-op marker kept for readability.
void SCREENSHOT_FRAME;
const pixelsB64 = await page.evaluate(() => window.nesHarness.rawPixelsBase64());
const rgba = Buffer.from(pixelsB64, "base64");
if (rgba.length !== PIXEL_BYTES) {
return {
bootError: `harness returned ${rgba.length} pixel bytes, expected ${PIXEL_BYTES}`,
rgba: null,
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
audio: null,
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
};
}
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
// Pull the audio hash (tiny — just a hex string + sample count).
// The WAV bytes themselves are only fetched on diff failure to
// keep the happy-path fast.
const audio = await page.evaluate(() => window.nesHarness.audioHash());
return { bootError: null, rgba, audio };
}
/// Read the full WAV bytes from the harness. Only called when an
/// audio diff fails — we don't want to pay the round-trip cost on
/// every ROM.
async function fetchAudioWav(page) {
const wavB64 = await page.evaluate(() => window.nesHarness.audioWavBase64());
return Buffer.from(wavB64, "base64");
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
}
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
// ── Main ───────────────────────────────────────────────────────
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
async function main() {
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
await fs.mkdir(goldensDir, { recursive: true });
// Wipe and recreate `actual/` so each run starts clean. This
// directory is gitignored, so it only exists to give the CI job
// something to upload when diffs fail.
await fs.rm(actualDir, { recursive: true, force: true });
await fs.mkdir(actualDir, { recursive: true });
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
const roms = await listRoms();
if (roms.length === 0) {
console.error("no .nes files found in examples/ — build them first");
process.exit(1);
}
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--disable-setuid-sandbox", "--allow-file-access-from-files"],
});
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
/** @type {Array<{name: string, status: string, reason: string | null}>} */
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
const results = [];
let failures = 0;
try {
for (const rom of roms) {
const page = await browser.newPage();
const consoleErrors = [];
page.on("pageerror", (err) => consoleErrors.push(String(err)));
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
await page.goto(harnessUrl, { waitUntil: "load" });
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
await page.waitForFunction(
"window.nesHarness && document.getElementById('info').textContent === 'ready'",
);
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
const { bootError, rgba, audio } = await runRomInHarness(page, rom);
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
if (bootError || !rgba) {
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
await page.close();
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
failures++;
const reason = `boot error: ${bootError ?? "no pixels"}`;
results.push({ name: rom.name, status: "FAIL", reason });
console.log(`FAIL ${rom.name.padEnd(28)} ${reason}`);
for (const e of consoleErrors) console.log(" console:", e);
continue;
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
}
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
const goldenPngPath = path.join(goldensDir, `${rom.name}.png`);
const goldenAudioPath = path.join(goldensDir, `${rom.name}.audio.hash`);
const pngExists = await exists(goldenPngPath);
const audioExists = await exists(goldenAudioPath);
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
// ── Update mode ──────────────────────────────────────
if (updateGoldens) {
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
await writePng(goldenPngPath, rgba);
await writeAudioGolden(goldenAudioPath, audio);
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
results.push({ name: rom.name, status: "UPDATED", reason: null });
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
console.log(
`UPD ${rom.name.padEnd(28)} wrote png + audio (hash=${audio.hash}, n=${audio.samples})`,
);
await page.close();
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
continue;
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
}
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
// ── Missing goldens ─────────────────────────────────
// We treat either missing PNG or missing audio hash as a
// failure — both are part of the committed contract. The
// user fixes both in one shot with UPDATE_GOLDENS=1.
if (!pngExists || !audioExists) {
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
failures++;
await writePng(path.join(actualDir, `${rom.name}.png`), rgba);
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
const wavBytes = await fetchAudioWav(page);
await fs.writeFile(path.join(actualDir, `${rom.name}.wav`), wavBytes);
const missing = [];
if (!pngExists) missing.push("png");
if (!audioExists) missing.push("audio");
const reason = `missing golden(s): ${missing.join(", ")} — run with UPDATE_GOLDENS=1 to create`;
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
results.push({ name: rom.name, status: "MISSING", reason });
console.log(`MISS ${rom.name.padEnd(28)} ${reason}`);
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
await page.close();
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
continue;
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
}
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
// ── PNG byte-for-byte diff ──────────────────────────
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
let golden;
try {
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
golden = await decodeGolden(goldenPngPath);
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
} catch (err) {
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
await page.close();
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
failures++;
const reason = `failed to decode golden: ${err.message}`;
results.push({ name: rom.name, status: "FAIL", reason });
console.log(`FAIL ${rom.name.padEnd(28)} ${reason}`);
continue;
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
}
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
const pixelsMatch = rgba.equals(golden);
// ── Audio hash diff ─────────────────────────────────
const expectedAudio = (await fs.readFile(goldenAudioPath, "utf8")).trim();
const actualAudioLine = `${audio.hash} ${audio.samples}`;
const audioMatch = expectedAudio === actualAudioLine;
if (pixelsMatch && audioMatch) {
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
results.push({ name: rom.name, status: "OK", reason: null });
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
console.log(`OK ${rom.name.padEnd(28)} exact video + audio match`);
await page.close();
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
continue;
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
}
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
// Something mismatched — collect details for both diffs
// and write the actual artifacts.
const reasons = [];
const actualPngPath = path.join(actualDir, `${rom.name}.png`);
const actualWavPath = path.join(actualDir, `${rom.name}.wav`);
if (!pixelsMatch) {
const { mismatched, firstDiff, rgba: diffRgba } = buildDiff(golden, rgba);
const diffPath = path.join(actualDir, `${rom.name}.diff.png`);
await writePng(actualPngPath, rgba);
await writePng(diffPath, diffRgba);
reasons.push(
`${mismatched}/${WIDTH * HEIGHT} px differ; first at ` +
`(${firstDiff.x},${firstDiff.y}) expected [${firstDiff.expected.join(",")}] ` +
`got [${firstDiff.actual.join(",")}]`,
);
}
if (!audioMatch) {
const wavBytes = await fetchAudioWav(page);
await fs.writeFile(actualWavPath, wavBytes);
reasons.push(`audio hash ${expectedAudio} -> ${actualAudioLine}`);
}
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
failures++;
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
const reason = reasons.join("; ");
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
results.push({ name: rom.name, status: "DIFF", reason });
console.log(`DIFF ${rom.name.padEnd(28)} ${reason}`);
tests/emulator: record audio goldens alongside screenshots Adds an audio capture pipeline to the jsnes e2e harness that mirrors the existing PNG screenshot path. Every ROM now produces both a golden PNG (video) and a golden `<name>.audio.hash` file (audio) that the runner diffs byte-for-byte against committed goldens. Pipeline: - `harness.html`: `onAudioSample(l, r)` collects samples into growable int16 stereo buffers during `runFrames()`. Two new API methods: `audioHash()` returns an FNV-1a hash of the full buffer plus sample count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file so the runner can write `actual/<name>.wav` on failure. - `run_examples.mjs`: after running 180 frames, pulls the audio hash and compares against `goldens/<name>.audio.hash` (16-byte text file with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and writes `actual/<name>.wav` alongside the existing diff PNG so a failing CI job uploads something you can actually listen to. On `UPDATE_GOLDENS=1`, writes both goldens together. - `audio_demo.ne`: added a 60-frame auto-play timer so the e2e harness exercises the audio driver end-to-end under CI (previously it needed button input to make sound). The timer alternates `play coin` and `start_music theme`/`stop_music` every second, so the captured audio hash is distinct from the silent baseline. Golden hashes: - 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because they never touch the APU — deliberately committed so any future change that introduces spurious audio writes trips the diff. - `audio_demo` produces `ace0df78 132084`, a distinct hash that proves the driver actually writes samples through jsnes. Two video goldens (`function_chain.png`, `logic_ops.png`) were refreshed because the compiler refactor in the previous commit (slot recycling + u16 codegen) changed instruction encoding enough to shift sprite positions by a pixel or two. Visually identical under a diff review. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
if (!pixelsMatch) {
console.log(` actual png: ${path.relative(repoRoot, actualPngPath)}`);
console.log(` diff png: ${path.relative(repoRoot, path.join(actualDir, `${rom.name}.diff.png`))}`);
}
if (!audioMatch) {
console.log(` actual wav: ${path.relative(repoRoot, actualWavPath)}`);
}
await page.close();
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
}
} finally {
await browser.close();
}
const reportPath = path.join(__dirname, "report.json");
tests/emulator: byte-exact golden-image diffs The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/<name>.png the run's raw output tests/emulator/actual/<name>.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 21:30:18 +00:00
await fs.writeFile(
reportPath,
JSON.stringify({ generatedAt: new Date().toISOString(), updateGoldens, results }, null, 2),
);
console.log("");
console.log(`report written to ${path.relative(repoRoot, reportPath)}`);
if (updateGoldens) {
console.log(`${results.length} goldens updated`);
console.log("review the changes with `git diff tests/emulator/goldens/` before committing");
} else {
console.log(`${results.length - failures}/${results.length} ROMs match their goldens`);
if (failures > 0) {
console.log("rerun with UPDATE_GOLDENS=1 if the new output is intentional");
}
}
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
if (failures > 0) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});