1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00
nescript/scripts/gen_platformer_tiles.rs

479 lines
13 KiB
Rust
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
//! One-shot generator for the CHR tiles and nametable used by
//! `examples/platformer.ne`.
//!
//! Run with `cargo run --bin gen_platformer_tiles`. The output is
//! intended to be pasted into `platformer.ne` under the
examples: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
//! `sprite Tileset { pixels: [...] }` and
//! `background Level { legend { ... } map: [...] palette_map: [...] }`
//! blocks. Keeping the source of truth here (instead of
//! hand-maintained ASCII in the `.ne` file) ensures the tile art,
//! the nametable, and the named tile indices all stay in sync.
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
//!
//! Tiles are defined as 8×8 ASCII art where each character selects
//! one of 4 sub-palette slots:
//! '.' = colour 0 (sky / transparent for sprites)
//! 'a' = colour 1
//! 'b' = colour 2
//! 'c' = colour 3
examples: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
//!
//! Both the generator *and* the `NEScript` parser accept `.abc` as
//! aliases for `.#%@`, so the output is ready to paste directly
//! into a `pixels:` block without translation.
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
/// A single 8×8 tile description: name + ASCII art.
struct Tile {
name: &'static str,
art: &'static str,
}
/// All tiles referenced by the platformer example. Order matters —
/// tile 0 is the default smiley reserved by the linker, so entry 0
/// here lands at CHR index 1, entry 1 at index 2, and so on.
///
/// Colour conventions (must line up with the palette + attribute
/// layout in `examples/platformer.ne`):
/// sprite sub-palette 0 (player / enemy / coin):
/// a = red/cap, b = peach/skin, c = white/highlight
/// bg sub-palette 0 (sky row): a = white, b = light gray, c = black
/// bg sub-palette 1 (block row): a = red, b = peach, c = dark red
/// bg sub-palette 2 (ground row): a = light green, b = light brown,
/// c = dark brown
const TILES: &[Tile] = &[
Tile {
name: "Player head L",
art: "\
..aaaaaa
.aaaaaaa
.aaabbbb
.aabbbbc
aabbcccc
aabbccbc
aabbbbbb
aabbbbbb",
},
Tile {
name: "Player head R",
art: "\
aaaaaa..
aaaaaaa.
bbbbaaa.
cbbbbbaa
cccccbbb
bcbbccbb
bbbbbbbb
bbbbbbbb",
},
Tile {
name: "Player body L",
art: "\
.aaaaaaa
aaacaaaa
aaaccaaa
aaaaaaaa
.bbbbbbb
.bbbbbbb
.bb..bbb
aaa..bbb",
},
Tile {
name: "Player body R",
art: "\
aaaaaaa.
aaaacaaa
aaaccaaa
aaaaaaaa
bbbbbbb.
bbbbbbb.
bbb..bb.
bbb..aaa",
},
Tile {
name: "Enemy",
art: "\
..aaaa..
.aaaaaa.
.aacbaa.
aacccaaa
abbccbba
.aaaaaa.
a.aaaa.a
a..aa..a",
},
Tile {
name: "Coin",
art: "\
...bb...
..bbbb..
.bbccbb.
.bcbbcb.
.bcbbcb.
.bbccbb.
..bbbb..
...bb...",
},
// Grass top (sub-palette 2: a=green, c=dark brown).
Tile {
name: "Grass top",
art: "\
aaaaaaaa
a.aaa.aa
aaaaaaaa
accacaca
ccccaccc
cccccccc
cbccccbc
cccccccc",
},
// Dirt (sub-palette 2: c=dark brown bulk, b=light brown speckles).
Tile {
name: "Dirt",
art: "\
cccccccc
cbccccbc
cccccccc
ccccbccc
cccccccc
cbccccbc
ccccbccc
cccccccc",
},
// Brick (sub-palette 1: a=red, c=dark red mortar).
Tile {
name: "Brick",
art: "\
cccccccc
caaaaaca
caaaaaca
caaaaaca
cccccccc
aaacaaaa
aaacaaaa
aaacaaaa",
},
// Cloud left (sub-palette 0: a=white, b=light gray shade).
Tile {
name: "Cloud L",
art: "\
........
...aaa..
..aaaaa.
.aaaaaaa
.aaaaaaa
.bbaaaaa
..bbbbba
.....bbb",
},
// Cloud right (sub-palette 0).
Tile {
name: "Cloud R",
art: "\
........
.aaa....
aaaaa...
aaaaaaa.
aaaaaaa.
aaaaabb.
abbbbb..
bbb.....",
},
// Hill (sub-palette 2: a=green, b=light brown shade).
Tile {
name: "Hill",
art: "\
........
....aa..
...aaaa.
..aaaaaa
..abaaaa
.abbabaa
.aaababa
aabbabba",
},
// Bush (sub-palette 2: a=green, c=dark brown outline).
Tile {
name: "Bush",
art: "\
........
...aa...
..aaaa..
.aaaaac.
aaaaaaca
aaaaacca
aaaaacca
acacacac",
},
// Q Block (sub-palette 1: a=red frame, b=peach face, c=dark red).
Tile {
name: "Q Block",
art: "\
cccccccc
caaaaaac
cabbbbac
cabccbac
cabcbbac
cabbbbac
caaaaaac
cccccccc",
},
// Sky (all transparent — renders as the universal bg colour).
Tile {
name: "Sky (blank)",
art: "\
........
........
........
........
........
........
........
........",
},
];
// ── Named CHR tile indices used by the nametable layout below ──
// (Player/enemy/coin tile indices are referenced by name only in
// the .ne file's `draw` statements, not by the nametable here, so
// the nametable-only constants live here.)
const GRASS: u8 = 7;
const DIRT: u8 = 8;
const BRICK: u8 = 9;
const CLOUD_L: u8 = 10;
const CLOUD_R: u8 = 11;
const HILL: u8 = 12;
const BUSH: u8 = 13;
const QBLOCK: u8 = 14;
const SKY: u8 = 15;
examples: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
/// Validate that a tile's ASCII art is well-formed (8 rows × 8 cols,
/// only the legal `.abc` characters). Pixel→CHR encoding now happens
/// inside the `NEScript` parser when it sees the `pixels:` block, so
/// this generator's job is just to make sure the strings we paste
/// are syntactically valid.
fn validate_tile_art(name: &str, art: &str) {
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
let rows: Vec<&str> = art.lines().filter(|l| !l.is_empty()).collect();
examples: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
assert_eq!(
rows.len(),
8,
"tile '{name}': expected 8 rows, got {}",
rows.len()
);
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
for (y, row) in rows.iter().enumerate() {
examples: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
assert_eq!(
row.len(),
8,
"tile '{name}' row {y}: expected 8 cols, got {row:?}"
);
for ch in row.chars() {
assert!(
matches!(ch, '.' | 'a' | 'b' | 'c'),
"tile '{name}' row {y}: invalid character '{ch}'"
);
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
}
}
}
/// Build a 32×30 nametable for a static Mario-ish level vista.
/// Horizontal scrolling pans this single nametable via `scroll()`,
/// so it only needs to look interesting at any X offset.
fn build_nametable() -> [u8; 960] {
let mut nt = [SKY; 960];
let set = |nt: &mut [u8; 960], y: usize, x: usize, t: u8| {
nt[y * 32 + x] = t;
};
// Row 5 & 6: two clouds at different X positions for parallax feel.
for &cx in &[4usize, 20] {
set(&mut nt, 5, cx, CLOUD_L);
set(&mut nt, 5, cx + 1, CLOUD_R);
}
set(&mut nt, 6, 12, CLOUD_L);
set(&mut nt, 6, 13, CLOUD_R);
// Row 15: a suspended brick platform with a Q-block in the middle.
for x in 6..=9 {
let t = if x == 8 { QBLOCK } else { BRICK };
set(&mut nt, 15, x, t);
}
for x in 18..=20 {
set(&mut nt, 15, x, BRICK);
}
// Row 20: hills sitting behind the grass line.
for x in 2..=4 {
set(&mut nt, 20, x, HILL);
}
for x in 22..=25 {
set(&mut nt, 20, x, HILL);
}
// Row 21: bushes on top of the grass.
for x in 10..=12 {
set(&mut nt, 21, x, BUSH);
}
for x in 27..=28 {
set(&mut nt, 21, x, BUSH);
}
// Row 22: grass top — a full horizontal line.
for x in 0..32 {
set(&mut nt, 22, x, GRASS);
}
// Row 23-29: dirt rows with a few buried bricks for texture.
for y in 23..30 {
for x in 0..32 {
set(&mut nt, y, x, DIRT);
}
}
for &(y, x) in &[(24usize, 5usize), (25, 11), (24, 18), (25, 24), (23, 30)] {
set(&mut nt, y, x, BRICK);
}
nt
}
/// Build a 64-byte attribute table that pairs every 2×2 metatile
/// with a background sub-palette.
///
/// The attribute table covers a 16×15 grid of 16×16 metatiles. Each
/// byte encodes 4 quadrants (top-left, top-right, bottom-left,
/// bottom-right) of a 32×32-pixel "super-metatile" with 2 bits
/// apiece (palette index 0..3). For this demo we simply pick a
/// sub-palette per screen row region:
/// - top region (sky): sub-palette 0
/// - mid region (hills/blocks): sub-palette 1
/// - ground region (grass/dirt): sub-palette 2
fn build_attributes() -> [u8; 64] {
let mut attr = [0u8; 64];
// The attribute table is 8×8 bytes. Row `ay` covers nametable
// rows 4*ay..4*ay+3. Palette layout:
// rows 0-11 (ay 0-2): sub-palette 0 (sky/clouds: whites)
// rows 12-15 (ay 3): sub-palette 1 (brick/Q-block row)
// rows 16-19 (ay 4): sub-palette 0 (more sky above the hills)
// rows 20-29 (ay 5-7): sub-palette 2 (grass/hills/bushes/dirt)
for ay in 0..8 {
let pal: u8 = match ay {
0..=2 => 0,
3 => 1,
4 => 0,
_ => 2, // 5, 6, 7
};
let quad = pal & 0b11;
let byte = quad | (quad << 2) | (quad << 4) | (quad << 6);
for ax in 0..8 {
attr[ay * 8 + ax] = byte;
}
}
attr
}
fn main() {
examples: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
// ── CHR tiles as pixel-art strings ──────────────────────────
//
// `NEScript`'s `sprite Name { pixels: [...] }` accepts one string
// per pixel row; the parser splits the grid into 8×8 tiles in
// row-major reading order, so emitting each 8-row tile
// sequentially (stacked vertically, 1 tile wide × 15 tiles tall)
// produces CHR tile indices 1..15 in the same order as the
// TILES array.
println!("// ── CHR tiles (paste into sprite Tileset {{ pixels: [...] }}) ──");
println!(" pixels: [");
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
for (i, tile) in TILES.iter().enumerate() {
examples: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
validate_tile_art(tile.name, tile.art);
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
let tile_idx = i + 1;
examples: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
println!(" // tile {tile_idx}: {}", tile.name);
for row in tile.art.lines().filter(|l| !l.is_empty()) {
println!(" \"{row}\",");
}
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: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
println!(" ]\n");
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: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
// ── Background tilemap via legend + map form ────────────────
//
// Each distinct nametable tile gets one easy-to-read legend
// character. `.` is the most common (sky), so use it for the
// default fill to keep the `map:` strings tidy. The compiler
// validates every character — any typo in the rows below is
// caught by the parser rather than by a render bug at runtime.
println!("// ── Nametable (paste into background Level {{ legend/map/palette_map }}) ──");
println!(" legend {{");
println!(" \".\": 15 // sky (blank)");
println!(" \"<\": 10 // cloud left half");
println!(" \">\": 11 // cloud right half");
println!(" \"#\": 9 // brick");
println!(" \"Q\": 14 // question block");
println!(" \"^\": 12 // hill silhouette");
println!(" \"*\": 13 // bush");
println!(" \"=\": 7 // grass top");
println!(" \"%\": 8 // dirt");
println!(" }}");
println!();
println!(" map: [");
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
let nt = build_nametable();
assert_eq!(nt.len(), 960);
examples: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
let legend = legend_char_for;
for row in 0..30 {
let mut s = String::from(" \"");
for col in 0..32 {
s.push(legend(nt[row * 32 + col]));
}
s.push_str("\",");
println!("{s}");
}
println!(" ]\n");
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: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
// ── Palette map (auto-packs the 64-byte attribute table) ────
//
// `palette_map:` is a 16-wide grid of sub-palette digits (one
// per 16×16 metatile). The parser packs pairs of rows into the
// 8×8 attribute table automatically, so we emit the metatile
// grid directly and skip the awkward `(br<<6)|(bl<<4)|(tr<<2)|tl`
// bit-packing the old raw-bytes form demanded.
//
// The attribute table covers 16 metatile rows (8 attr rows ×
// 2 each); the last row sits below the visible 240-scanline
// screen but the PPU still reads it, so we emit all 16 rows to
// match whatever the original hand-packed attribute byte was.
println!("// ── Attributes (paste into background Level {{ palette_map: [...] }}) ──");
println!(" palette_map: [");
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
let attr = build_attributes();
examples: adopt the friendly asset syntax 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
2026-04-13 18:04:21 +00:00
for my in 0..16 {
let mut s = String::from(" \"");
for mx in 0..16 {
// Recover the sub-palette index for metatile (mx, my)
// from the packed attribute table. Each byte covers a
// 2×2 metatile block at attr[(my/2)*8 + mx/2], with
// quadrants laid out as BR BL TR TL in the high bits.
let byte = attr[(my / 2) * 8 + mx / 2];
let quadrant = match (mx % 2, my % 2) {
(0, 0) => byte & 0b11,
(1, 0) => (byte >> 2) & 0b11,
(0, 1) => (byte >> 4) & 0b11,
_ => (byte >> 6) & 0b11,
};
s.push(char::from(b'0' + quadrant));
}
s.push_str("\",");
println!("{s}");
}
println!(" ]");
}
/// Map a CHR tile index back to its legend character in the
/// generated `map:` block. Must stay in sync with the `legend { ... }`
/// block printed by `main()`.
fn legend_char_for(tile: u8) -> char {
match tile {
15 => '.',
10 => '<',
11 => '>',
9 => '#',
14 => 'Q',
12 => '^',
13 => '*',
7 => '=',
8 => '%',
other => panic!("no legend char for tile {other}"),
}
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
}