Rewrites every example with non-trivial asset declarations to use
the pleasant QoL syntax introduced in the previous commit. Every
example still compiles to a byte-identical ROM (verified by a
temp-path diff before committing), so the committed `.nes` files
and the 23 emulator goldens are unchanged.
* platformer.ne — the centerpiece end-to-end demo:
- `palette Main` goes to grouped form with a shared
`universal: 0x22` (sky blue), one shared colour per
sub-palette, and named NES colours throughout; the
long-standing `$3F10` mirror-trap warning is now handled
by the parser and the manual pitfall comment is gone.
- `sprite Tileset` is 15 tiles of ASCII pixel art instead
of 240 bytes of inline hex.
- `background Level` uses a `legend { '.': 15, '#': 9, ... }`
block plus `map:` strings for the 32×30 nametable, and
`palette_map:` rows for the attribute table. The map
reads top-down like the rendered screen.
- SFX latch-once `pitch: 0x30` scalars + `envelope:` alias.
- `music Theme` uses note names + `tempo: 10` default.
* audio_demo.ne — scalar sfx pitches, `envelope:` alias, and a
note-name `C4, E4, G4, ...` music track.
* palette_and_background.ne — grouped CoolBlues / WarmReds
palettes with `universal: black` + named colours, plus
`legend` + `map:` tilemaps for the two backgrounds.
* sprites_and_palettes.ne — Arrow and Heart sprites rewritten
as `pixels:` ASCII art.
Along the way, two small parser extensions support the rewrites:
- `parse_pixel_art` now accepts `a/b/c` as aliases for `#/%/@`,
matching the vocabulary every NES editor (and our own
gen_platformer_tiles.rs generator) uses.
- `palette_map_to_attrs` allows up to 16 metatile rows (the
full attribute-table coverage, including the off-screen
bottom half) and auto-replicates row 14 → row 15 when only
15 rows are supplied so the visible bottom of the screen
gets consistent sub-palette assignments by default. The old
15-row cap couldn't match a hand-packed `0xAA` attribute
table for the last row; the platformer required this to
stay byte-identical.
`scripts/gen_platformer_tiles.rs` is updated to emit the new
syntax directly (pixel-art `pixels:` block + `legend`/`map:`/
`palette_map:` for the background), so regenerating the
platformer tiles stays a one-liner.
474 lib tests + 64 integration tests pass (3 new parser tests
for `palette_map:` 15/16/17 rows and the `abc` alias). All 23
emulator goldens still match pixel- and sample-for-sample.
https://claude.ai/code/session_01PzaSFj3VahDzxEYTKCESkz
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
The compiler is deterministic: rebuilding any example produces
a byte-identical ROM, verified across all 22 examples and all
four mappers (NROM, MMC1, UxROM, MMC3). That means the .nes
files are reproducible artefacts and can live next to their
sources without drift.
Benefits:
- Users can clone the repo and open any example in an emulator
without installing a Rust toolchain or running the compiler.
- The emulator harness can trust examples/*.nes directly, so its
CI job no longer needs a compiler build or a "compile all
examples" loop — it just boots jsnes against the committed
ROMs and diffs each against its golden.
- ROM diffs in PRs are now meaningful: "this compiler change
flipped 17 bytes in hello_sprite.nes" is visible review
signal, not hidden behind the emulator golden.
Guard rails so the ROMs don't drift from their sources:
- .gitignore no longer excludes *.nes.
- The `examples` CI job rebuilds every .ne into /tmp and fails
loudly (with a GitHub error annotation pointing at the exact
cargo command to rerun) if any committed ROM differs.
- scripts/pre-commit does the same check locally.
- CLAUDE.md now states that editing a .ne file requires
rebuilding its .nes in the same commit, so future agents
won't miss the invariant.
Total footprint: 22 ROMs, 624 KB (avg 28 KB each — most are
NROM 24 KB; two banked examples are larger).
https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
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