1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00
nescript/tests/emulator/record_gif.mjs

136 lines
5.6 KiB
JavaScript
Raw Normal View History

platformer: end-to-end side-scroller demo + three runtime bug fixes Adds `examples/platformer.ne`, a full side-scrolling game that exercises nearly every subsystem of the compiler in one program: custom CHR tileset, 32×30 background nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, moving enemies, coin pickups, user-declared SFX + music, and a Title → Playing state machine with autopilot so the headless jsnes harness captures real gameplay at frame 180. Tile art + nametable are generated by `scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`). Building this out uncovered three independent runtime bugs that together made the example render as black-on-black smileys. All three are fixed in this commit: 1. **`gen_init` enabled sprite rendering before the linker's initial palette/background load runs.** The PPU's v-register auto-increments on every `$2007` write *during active rendering*, so the palette load (32 B) and nametable load (1024 B) were scrambled past the first ~72 bytes — every existing program with a `background Level { ... }` block was silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0` at the end of `gen_init` and emit a new `gen_enable_rendering` call *after* all initial VRAM writes complete. 2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio driver's period-table lookup reused `$02/$03` as a temporary indirect pointer with a comment claiming the slots were free because the tick doesn't call mul/div. But `$03` is also `ZP_CURRENT_STATE` used by the state dispatch loop, so every music note silently overwrote the state index with the high byte of `__period_table` (`0xC5` in the platformer ROM), wedging the state machine forever. Fix: `gen_nmi` now PHAs `$02/$03` on entry and PLA-restores them on exit, and the audio tick JSR moves inside that save/restore window (it used to be spliced by the linker *before* the register saves, so even A/X/Y were technically being trashed pre-save). Only `audio_demo`'s audio hash shifts (its note timings move a few cycles); every other golden is unchanged. 3. **Sub-palette mirroring footgun.** Writing a 32-byte palette blob sequentially causes the sprite sub-palettes' "index 0" slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware mirroring. The example's palette sets all eight first bytes to `$22` (sky blue) for this reason; `docs/future-work.md` picks up a TODO to warn on inconsistent first-byte values in the analyzer. Also: - `docs/platformer.gif` — 6-second recording of the example running in jsnes, generated by the new `tests/emulator/record_gif.mjs` puppeteer helper (encodes via `gifenc`, committed as a dev-dependency under `tests/emulator/package.json`). - README / examples/README tables and the 497-test count are updated to cover the new example. https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 13:04:26 +00:00
// Record a GIF of a .nes ROM running in jsnes.
//
// Usage:
// node record_gif.mjs <rom-name> [frames] [stride] [output.gif] [warmup]
platformer: end-to-end side-scroller demo + three runtime bug fixes Adds `examples/platformer.ne`, a full side-scrolling game that exercises nearly every subsystem of the compiler in one program: custom CHR tileset, 32×30 background nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, moving enemies, coin pickups, user-declared SFX + music, and a Title → Playing state machine with autopilot so the headless jsnes harness captures real gameplay at frame 180. Tile art + nametable are generated by `scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`). Building this out uncovered three independent runtime bugs that together made the example render as black-on-black smileys. All three are fixed in this commit: 1. **`gen_init` enabled sprite rendering before the linker's initial palette/background load runs.** The PPU's v-register auto-increments on every `$2007` write *during active rendering*, so the palette load (32 B) and nametable load (1024 B) were scrambled past the first ~72 bytes — every existing program with a `background Level { ... }` block was silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0` at the end of `gen_init` and emit a new `gen_enable_rendering` call *after* all initial VRAM writes complete. 2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio driver's period-table lookup reused `$02/$03` as a temporary indirect pointer with a comment claiming the slots were free because the tick doesn't call mul/div. But `$03` is also `ZP_CURRENT_STATE` used by the state dispatch loop, so every music note silently overwrote the state index with the high byte of `__period_table` (`0xC5` in the platformer ROM), wedging the state machine forever. Fix: `gen_nmi` now PHAs `$02/$03` on entry and PLA-restores them on exit, and the audio tick JSR moves inside that save/restore window (it used to be spliced by the linker *before* the register saves, so even A/X/Y were technically being trashed pre-save). Only `audio_demo`'s audio hash shifts (its note timings move a few cycles); every other golden is unchanged. 3. **Sub-palette mirroring footgun.** Writing a 32-byte palette blob sequentially causes the sprite sub-palettes' "index 0" slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware mirroring. The example's palette sets all eight first bytes to `$22` (sky blue) for this reason; `docs/future-work.md` picks up a TODO to warn on inconsistent first-byte values in the analyzer. Also: - `docs/platformer.gif` — 6-second recording of the example running in jsnes, generated by the new `tests/emulator/record_gif.mjs` puppeteer helper (encodes via `gifenc`, committed as a dev-dependency under `tests/emulator/package.json`). - README / examples/README tables and the 497-test count are updated to cover the new example. https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 13:04:26 +00:00
//
// Examples:
platformer: end-to-end side-scroller demo + three runtime bug fixes Adds `examples/platformer.ne`, a full side-scrolling game that exercises nearly every subsystem of the compiler in one program: custom CHR tileset, 32×30 background nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, moving enemies, coin pickups, user-declared SFX + music, and a Title → Playing state machine with autopilot so the headless jsnes harness captures real gameplay at frame 180. Tile art + nametable are generated by `scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`). Building this out uncovered three independent runtime bugs that together made the example render as black-on-black smileys. All three are fixed in this commit: 1. **`gen_init` enabled sprite rendering before the linker's initial palette/background load runs.** The PPU's v-register auto-increments on every `$2007` write *during active rendering*, so the palette load (32 B) and nametable load (1024 B) were scrambled past the first ~72 bytes — every existing program with a `background Level { ... }` block was silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0` at the end of `gen_init` and emit a new `gen_enable_rendering` call *after* all initial VRAM writes complete. 2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio driver's period-table lookup reused `$02/$03` as a temporary indirect pointer with a comment claiming the slots were free because the tick doesn't call mul/div. But `$03` is also `ZP_CURRENT_STATE` used by the state dispatch loop, so every music note silently overwrote the state index with the high byte of `__period_table` (`0xC5` in the platformer ROM), wedging the state machine forever. Fix: `gen_nmi` now PHAs `$02/$03` on entry and PLA-restores them on exit, and the audio tick JSR moves inside that save/restore window (it used to be spliced by the linker *before* the register saves, so even A/X/Y were technically being trashed pre-save). Only `audio_demo`'s audio hash shifts (its note timings move a few cycles); every other golden is unchanged. 3. **Sub-palette mirroring footgun.** Writing a 32-byte palette blob sequentially causes the sprite sub-palettes' "index 0" slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware mirroring. The example's palette sets all eight first bytes to `$22` (sky blue) for this reason; `docs/future-work.md` picks up a TODO to warn on inconsistent first-byte values in the analyzer. Also: - `docs/platformer.gif` — 6-second recording of the example running in jsnes, generated by the new `tests/emulator/record_gif.mjs` puppeteer helper (encodes via `gifenc`, committed as a dev-dependency under `tests/emulator/package.json`). - README / examples/README tables and the 497-test count are updated to cover the new example. https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 13:04:26 +00:00
// node record_gif.mjs platformer 360 2 docs/platformer.gif
// node record_gif.mjs war 360 2 docs/war.gif 4
// node record_gif.mjs pong 360 2 docs/pong.gif 4
platformer: end-to-end side-scroller demo + three runtime bug fixes Adds `examples/platformer.ne`, a full side-scrolling game that exercises nearly every subsystem of the compiler in one program: custom CHR tileset, 32×30 background nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, moving enemies, coin pickups, user-declared SFX + music, and a Title → Playing state machine with autopilot so the headless jsnes harness captures real gameplay at frame 180. Tile art + nametable are generated by `scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`). Building this out uncovered three independent runtime bugs that together made the example render as black-on-black smileys. All three are fixed in this commit: 1. **`gen_init` enabled sprite rendering before the linker's initial palette/background load runs.** The PPU's v-register auto-increments on every `$2007` write *during active rendering*, so the palette load (32 B) and nametable load (1024 B) were scrambled past the first ~72 bytes — every existing program with a `background Level { ... }` block was silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0` at the end of `gen_init` and emit a new `gen_enable_rendering` call *after* all initial VRAM writes complete. 2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio driver's period-table lookup reused `$02/$03` as a temporary indirect pointer with a comment claiming the slots were free because the tick doesn't call mul/div. But `$03` is also `ZP_CURRENT_STATE` used by the state dispatch loop, so every music note silently overwrote the state index with the high byte of `__period_table` (`0xC5` in the platformer ROM), wedging the state machine forever. Fix: `gen_nmi` now PHAs `$02/$03` on entry and PLA-restores them on exit, and the audio tick JSR moves inside that save/restore window (it used to be spliced by the linker *before* the register saves, so even A/X/Y were technically being trashed pre-save). Only `audio_demo`'s audio hash shifts (its note timings move a few cycles); every other golden is unchanged. 3. **Sub-palette mirroring footgun.** Writing a 32-byte palette blob sequentially causes the sprite sub-palettes' "index 0" slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware mirroring. The example's palette sets all eight first bytes to `$22` (sky blue) for this reason; `docs/future-work.md` picks up a TODO to warn on inconsistent first-byte values in the analyzer. Also: - `docs/platformer.gif` — 6-second recording of the example running in jsnes, generated by the new `tests/emulator/record_gif.mjs` puppeteer helper (encodes via `gifenc`, committed as a dev-dependency under `tests/emulator/package.json`). - README / examples/README tables and the 497-test count are updated to cover the new example. https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 13:04:26 +00:00
//
// The recorder drives `harness.html` via puppeteer, collects one
// canvas frame every `stride` NES frames for `frames` total, and
// encodes the sequence as a paletted GIF via the `gifenc` library.
// At stride=2 we end up with a 30 fps GIF that maps 1:1 to every
// other NES frame (NES runs at ~60 fps), which is the right
// tradeoff between smoothness and file size for a README demo.
docs: regenerate platformer.gif and lock it as a CI invariant The optimizer fix in the previous commit changes the observable gameplay of `examples/platformer.ne` — pre-fix the player got spurious enemy-1 stomp bounces every time coin 2 drifted into its pickup window, so the README demo gif showed the player bouncing mid-air around emu frames 85-125 instead of walking through the coin at ground level. Regenerate `docs/platformer.gif` from the fixed compiler so the README matches reality. To stop this from drifting again, treat the gif the same way the repo already treats `examples/*.nes`: - `gifenc` + `jsnes` + the harness are deterministic, so a fresh recording byte-matches a valid commit. Verified across two back-to-back runs (identical md5). - `.github/workflows/ci.yml`'s `emulator` job now renders the gif into `/tmp/platformer.gif` and `cmp`s it against `docs/platformer.gif`, emitting a `::error` annotation pointing at the exact rerun command if the committed copy is stale. This piggybacks on the existing puppeteer + node setup, adding ~20s to the job. - `scripts/pre-commit` runs the same check locally, but only when `examples/platformer.{ne,nes}`, `tests/emulator/record_gif.mjs`, or `tests/emulator/harness.html` is staged, and only if `tests/emulator/node_modules` is already installed. Cold-start puppeteer is ~20s — too slow to pay on every commit, but cheap enough to pay when something gif-relevant changed. - The header of `tests/emulator/record_gif.mjs` and the project conventions section of `CLAUDE.md` both spell out the rerun command and the invariant, so the next agent doesn't have to re-derive any of this. https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
2026-04-13 18:04:17 +00:00
//
// `warmup` is the number of NES frames to advance before the first
// captured frame. The default of 30 skips past the reset stall and
// the platformer's auto-Title→Play handoff at frame 20; the war
// recording uses 4 instead because that demo opens on its menu and
// we want the title screen to be the gif's thumbnail.
//
// IMPORTANT: `docs/platformer.gif`, `docs/war.gif`, and
// `docs/pong.gif` are committed and embedded in the README. Any
// change to the compiler, the runtime, the harness, or the
// underlying `.ne` source that alters the gameplay you see in the
// first ~6 seconds of any of the three demos must be followed by
docs: regenerate platformer.gif and lock it as a CI invariant The optimizer fix in the previous commit changes the observable gameplay of `examples/platformer.ne` — pre-fix the player got spurious enemy-1 stomp bounces every time coin 2 drifted into its pickup window, so the README demo gif showed the player bouncing mid-air around emu frames 85-125 instead of walking through the coin at ground level. Regenerate `docs/platformer.gif` from the fixed compiler so the README matches reality. To stop this from drifting again, treat the gif the same way the repo already treats `examples/*.nes`: - `gifenc` + `jsnes` + the harness are deterministic, so a fresh recording byte-matches a valid commit. Verified across two back-to-back runs (identical md5). - `.github/workflows/ci.yml`'s `emulator` job now renders the gif into `/tmp/platformer.gif` and `cmp`s it against `docs/platformer.gif`, emitting a `::error` annotation pointing at the exact rerun command if the committed copy is stale. This piggybacks on the existing puppeteer + node setup, adding ~20s to the job. - `scripts/pre-commit` runs the same check locally, but only when `examples/platformer.{ne,nes}`, `tests/emulator/record_gif.mjs`, or `tests/emulator/harness.html` is staged, and only if `tests/emulator/node_modules` is already installed. Cold-start puppeteer is ~20s — too slow to pay on every commit, but cheap enough to pay when something gif-relevant changed. - The header of `tests/emulator/record_gif.mjs` and the project conventions section of `CLAUDE.md` both spell out the rerun command and the invariant, so the next agent doesn't have to re-derive any of this. https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
2026-04-13 18:04:17 +00:00
//
// node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif
// node tests/emulator/record_gif.mjs war 360 2 docs/war.gif 4
// node tests/emulator/record_gif.mjs pong 360 2 docs/pong.gif 4
docs: regenerate platformer.gif and lock it as a CI invariant The optimizer fix in the previous commit changes the observable gameplay of `examples/platformer.ne` — pre-fix the player got spurious enemy-1 stomp bounces every time coin 2 drifted into its pickup window, so the README demo gif showed the player bouncing mid-air around emu frames 85-125 instead of walking through the coin at ground level. Regenerate `docs/platformer.gif` from the fixed compiler so the README matches reality. To stop this from drifting again, treat the gif the same way the repo already treats `examples/*.nes`: - `gifenc` + `jsnes` + the harness are deterministic, so a fresh recording byte-matches a valid commit. Verified across two back-to-back runs (identical md5). - `.github/workflows/ci.yml`'s `emulator` job now renders the gif into `/tmp/platformer.gif` and `cmp`s it against `docs/platformer.gif`, emitting a `::error` annotation pointing at the exact rerun command if the committed copy is stale. This piggybacks on the existing puppeteer + node setup, adding ~20s to the job. - `scripts/pre-commit` runs the same check locally, but only when `examples/platformer.{ne,nes}`, `tests/emulator/record_gif.mjs`, or `tests/emulator/harness.html` is staged, and only if `tests/emulator/node_modules` is already installed. Cold-start puppeteer is ~20s — too slow to pay on every commit, but cheap enough to pay when something gif-relevant changed. - The header of `tests/emulator/record_gif.mjs` and the project conventions section of `CLAUDE.md` both spell out the rerun command and the invariant, so the next agent doesn't have to re-derive any of this. https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
2026-04-13 18:04:17 +00:00
//
// committed alongside the source change. The CI `emulator` job
// regenerates both gifs and fails if the committed copies are stale —
docs: regenerate platformer.gif and lock it as a CI invariant The optimizer fix in the previous commit changes the observable gameplay of `examples/platformer.ne` — pre-fix the player got spurious enemy-1 stomp bounces every time coin 2 drifted into its pickup window, so the README demo gif showed the player bouncing mid-air around emu frames 85-125 instead of walking through the coin at ground level. Regenerate `docs/platformer.gif` from the fixed compiler so the README matches reality. To stop this from drifting again, treat the gif the same way the repo already treats `examples/*.nes`: - `gifenc` + `jsnes` + the harness are deterministic, so a fresh recording byte-matches a valid commit. Verified across two back-to-back runs (identical md5). - `.github/workflows/ci.yml`'s `emulator` job now renders the gif into `/tmp/platformer.gif` and `cmp`s it against `docs/platformer.gif`, emitting a `::error` annotation pointing at the exact rerun command if the committed copy is stale. This piggybacks on the existing puppeteer + node setup, adding ~20s to the job. - `scripts/pre-commit` runs the same check locally, but only when `examples/platformer.{ne,nes}`, `tests/emulator/record_gif.mjs`, or `tests/emulator/harness.html` is staged, and only if `tests/emulator/node_modules` is already installed. Cold-start puppeteer is ~20s — too slow to pay on every commit, but cheap enough to pay when something gif-relevant changed. - The header of `tests/emulator/record_gif.mjs` and the project conventions section of `CLAUDE.md` both spell out the rerun command and the invariant, so the next agent doesn't have to re-derive any of this. https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
2026-04-13 18:04:17 +00:00
// gifenc + jsnes are deterministic, so the freshly-rendered bytes
// byte-match a valid commit. See `.github/workflows/ci.yml`.
platformer: end-to-end side-scroller demo + three runtime bug fixes Adds `examples/platformer.ne`, a full side-scrolling game that exercises nearly every subsystem of the compiler in one program: custom CHR tileset, 32×30 background nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, moving enemies, coin pickups, user-declared SFX + music, and a Title → Playing state machine with autopilot so the headless jsnes harness captures real gameplay at frame 180. Tile art + nametable are generated by `scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`). Building this out uncovered three independent runtime bugs that together made the example render as black-on-black smileys. All three are fixed in this commit: 1. **`gen_init` enabled sprite rendering before the linker's initial palette/background load runs.** The PPU's v-register auto-increments on every `$2007` write *during active rendering*, so the palette load (32 B) and nametable load (1024 B) were scrambled past the first ~72 bytes — every existing program with a `background Level { ... }` block was silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0` at the end of `gen_init` and emit a new `gen_enable_rendering` call *after* all initial VRAM writes complete. 2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio driver's period-table lookup reused `$02/$03` as a temporary indirect pointer with a comment claiming the slots were free because the tick doesn't call mul/div. But `$03` is also `ZP_CURRENT_STATE` used by the state dispatch loop, so every music note silently overwrote the state index with the high byte of `__period_table` (`0xC5` in the platformer ROM), wedging the state machine forever. Fix: `gen_nmi` now PHAs `$02/$03` on entry and PLA-restores them on exit, and the audio tick JSR moves inside that save/restore window (it used to be spliced by the linker *before* the register saves, so even A/X/Y were technically being trashed pre-save). Only `audio_demo`'s audio hash shifts (its note timings move a few cycles); every other golden is unchanged. 3. **Sub-palette mirroring footgun.** Writing a 32-byte palette blob sequentially causes the sprite sub-palettes' "index 0" slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware mirroring. The example's palette sets all eight first bytes to `$22` (sky blue) for this reason; `docs/future-work.md` picks up a TODO to warn on inconsistent first-byte values in the analyzer. Also: - `docs/platformer.gif` — 6-second recording of the example running in jsnes, generated by the new `tests/emulator/record_gif.mjs` puppeteer helper (encodes via `gifenc`, committed as a dev-dependency under `tests/emulator/package.json`). - README / examples/README tables and the 497-test count are updated to cover the new example. https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 13:04:26 +00:00
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import puppeteer from "puppeteer";
import gifenc from "gifenc";
const { GIFEncoder, quantize, applyPalette } = gifenc;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", "..");
const harnessUrl = pathToFileURL(path.join(__dirname, "harness.html")).toString();
const WIDTH = 256;
const HEIGHT = 240;
const romName = process.argv[2] ?? "platformer";
const totalFrames = parseInt(process.argv[3] ?? "360", 10);
const stride = parseInt(process.argv[4] ?? "2", 10); // captured every Nth NES frame
const outputPath = path.resolve(repoRoot, process.argv[5] ?? `docs/${romName}.gif`);
const romPath = path.join(repoRoot, "examples", `${romName}.nes`);
const romBytes = await fs.readFile(romPath);
const romB64 = romBytes.toString("base64");
const browser = await puppeteer.launch({
headless: "new",
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--allow-file-access-from-files",
],
});
const page = await browser.newPage();
page.on("pageerror", (e) => console.log("[pageerror]", e.message));
await page.goto(harnessUrl, { waitUntil: "load" });
await page.waitForFunction(
"window.nesHarness && document.getElementById('info').textContent === 'ready'",
);
await page.evaluate((b) => window.nesHarness.loadRomBase64(b), romB64);
// Warm-up: skip past the reset stall and (optionally) any title
// screen so the first captured frame shows what we want as the
// gif's thumbnail. 30 frames at 60 fps covers ~0.5 s which is
// enough for the platformer example's Title → Playing auto-
// transition at frame 20. The war recording overrides this with
// `4` (positional arg below) so the title menu is the first frame.
// Positional arg wins; `WARMUP=…` env var is honoured for ad-hoc
// experimentation.
const warmupFrames = parseInt(
process.argv[6] ?? process.env.WARMUP ?? "30",
10,
);
platformer: end-to-end side-scroller demo + three runtime bug fixes Adds `examples/platformer.ne`, a full side-scrolling game that exercises nearly every subsystem of the compiler in one program: custom CHR tileset, 32×30 background nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, moving enemies, coin pickups, user-declared SFX + music, and a Title → Playing state machine with autopilot so the headless jsnes harness captures real gameplay at frame 180. Tile art + nametable are generated by `scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`). Building this out uncovered three independent runtime bugs that together made the example render as black-on-black smileys. All three are fixed in this commit: 1. **`gen_init` enabled sprite rendering before the linker's initial palette/background load runs.** The PPU's v-register auto-increments on every `$2007` write *during active rendering*, so the palette load (32 B) and nametable load (1024 B) were scrambled past the first ~72 bytes — every existing program with a `background Level { ... }` block was silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0` at the end of `gen_init` and emit a new `gen_enable_rendering` call *after* all initial VRAM writes complete. 2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio driver's period-table lookup reused `$02/$03` as a temporary indirect pointer with a comment claiming the slots were free because the tick doesn't call mul/div. But `$03` is also `ZP_CURRENT_STATE` used by the state dispatch loop, so every music note silently overwrote the state index with the high byte of `__period_table` (`0xC5` in the platformer ROM), wedging the state machine forever. Fix: `gen_nmi` now PHAs `$02/$03` on entry and PLA-restores them on exit, and the audio tick JSR moves inside that save/restore window (it used to be spliced by the linker *before* the register saves, so even A/X/Y were technically being trashed pre-save). Only `audio_demo`'s audio hash shifts (its note timings move a few cycles); every other golden is unchanged. 3. **Sub-palette mirroring footgun.** Writing a 32-byte palette blob sequentially causes the sprite sub-palettes' "index 0" slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware mirroring. The example's palette sets all eight first bytes to `$22` (sky blue) for this reason; `docs/future-work.md` picks up a TODO to warn on inconsistent first-byte values in the analyzer. Also: - `docs/platformer.gif` — 6-second recording of the example running in jsnes, generated by the new `tests/emulator/record_gif.mjs` puppeteer helper (encodes via `gifenc`, committed as a dev-dependency under `tests/emulator/package.json`). - README / examples/README tables and the 497-test count are updated to cover the new example. https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 13:04:26 +00:00
await page.evaluate((n) => window.nesHarness.runFrames(n), warmupFrames);
console.log(
`recording ${romName}.nes: ${totalFrames} frames, stride ${stride}, ` +
`~${Math.round((totalFrames / 60) * 100) / 100}s of gameplay`,
);
const frames = [];
for (let i = 0; i < totalFrames; i += stride) {
await page.evaluate((n) => window.nesHarness.runFrames(n), stride);
const pixelsB64 = await page.evaluate(() => window.nesHarness.rawPixelsBase64());
const rgba = Buffer.from(pixelsB64, "base64");
// gifenc wants a Uint8Array or Uint8ClampedArray of RGBA pixels.
frames.push(new Uint8Array(rgba));
if (i % 20 === 0) process.stdout.write(".");
}
process.stdout.write("\n");
await browser.close();
// Encode. We quantize a representative middle frame to build a
// shared palette — this avoids the dithering / palette-drift
// artifacts you get with per-frame palettes and keeps the file
// size down. The NES only renders out of a fixed master palette
// anyway, so a single shared palette is the right answer.
console.log(`encoding ${frames.length} frames as GIF → ${path.relative(repoRoot, outputPath)}`);
const paletteSource = frames[Math.floor(frames.length / 2)];
const palette = quantize(paletteSource, 256, { format: "rgba4444" });
const gif = GIFEncoder();
for (let i = 0; i < frames.length; i++) {
const indexed = applyPalette(frames[i], palette, "rgba4444");
// delay is in milliseconds. stride NES frames at ~60 fps =
// stride * 16.67 ms per captured frame.
gif.writeFrame(indexed, WIDTH, HEIGHT, {
palette,
delay: Math.round((stride * 1000) / 60),
transparent: false,
});
}
gif.finish();
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from(gif.bytes()));
console.log(`wrote ${outputPath} (${(gif.bytes().length / 1024).toFixed(1)} KB)`);