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: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
........
|
|
|
|
|
|
........
|
|
|
|
|
|
........
|
|
|
|
|
|
........
|
|
|
|
|
|
........
|
|
|
|
|
|
........
|
2026-04-20 13:59:14 +00:00
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
// ── HUD glyphs (sprite-only; palette sp0: a=red, c=white) ──
|
|
|
|
|
|
// Each digit is a 4-wide outline centred in an 8×8 cell and
|
|
|
|
|
|
// drawn in white ('c') so it reads crisply over the sky
|
|
|
|
|
|
// backdrop at the top of the playfield. The compiler treats
|
|
|
|
|
|
// '.' as transparent for sprites, so the sky shows through.
|
|
|
|
|
|
Tile {
|
|
|
|
|
|
name: "Digit 0",
|
|
|
|
|
|
art: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
Tile {
|
|
|
|
|
|
name: "Digit 1",
|
|
|
|
|
|
art: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
...cc...
|
|
|
|
|
|
..ccc...
|
|
|
|
|
|
...cc...
|
|
|
|
|
|
...cc...
|
|
|
|
|
|
...cc...
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
Tile {
|
|
|
|
|
|
name: "Digit 2",
|
|
|
|
|
|
art: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
.....c..
|
|
|
|
|
|
....cc..
|
|
|
|
|
|
...cc...
|
|
|
|
|
|
..cc....
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
Tile {
|
|
|
|
|
|
name: "Digit 3",
|
|
|
|
|
|
art: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
.....c..
|
|
|
|
|
|
...ccc..
|
|
|
|
|
|
.....c..
|
|
|
|
|
|
.....c..
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
Tile {
|
|
|
|
|
|
name: "Digit 4",
|
|
|
|
|
|
art: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
.....c..
|
|
|
|
|
|
.....c..
|
|
|
|
|
|
.....c..
|
|
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
Tile {
|
|
|
|
|
|
name: "Digit 5",
|
|
|
|
|
|
art: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
..c.....
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
.....c..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
Tile {
|
|
|
|
|
|
name: "Digit 6",
|
|
|
|
|
|
art: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
..c.....
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
Tile {
|
|
|
|
|
|
name: "Digit 7",
|
|
|
|
|
|
art: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
.....c..
|
|
|
|
|
|
....c...
|
|
|
|
|
|
...c....
|
|
|
|
|
|
..c.....
|
|
|
|
|
|
..c.....
|
|
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
Tile {
|
|
|
|
|
|
name: "Digit 8",
|
|
|
|
|
|
art: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
Tile {
|
|
|
|
|
|
name: "Digit 9",
|
|
|
|
|
|
art: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
..c..c..
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
.....c..
|
|
|
|
|
|
.....c..
|
|
|
|
|
|
..cccc..
|
|
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
// Small red heart for the lives readout. Uses 'a' (red) so the
|
|
|
|
|
|
// shape pops against the sky and matches the cap/brick red.
|
|
|
|
|
|
Tile {
|
|
|
|
|
|
name: "Heart",
|
|
|
|
|
|
art: "\
|
|
|
|
|
|
........
|
|
|
|
|
|
.aa..aa.
|
|
|
|
|
|
aaaaaaaa
|
|
|
|
|
|
aaaaaaaa
|
|
|
|
|
|
.aaaaaa.
|
|
|
|
|
|
..aaaa..
|
|
|
|
|
|
...aa...
|
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
|
|
|
|
........",
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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
|
|
|
|
}
|