1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-10 01:37:45 +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
This commit is contained in:
Claude 2026-04-13 18:04:21 +00:00
parent 48832ccb13
commit ad951a835f
No known key found for this signature in database
8 changed files with 676 additions and 353 deletions

View file

@ -822,18 +822,24 @@ impl Parser {
for (ry, row) in rows.iter().enumerate() {
let mut line = Vec::with_capacity(width);
for (rx, ch) in row.chars().enumerate() {
// Three vocabularies map to the same 0-3 index so
// artists can use whichever feels natural:
// `. # % @` — shade-intensity glyphs (dense = hi)
// `0 1 2 3` — literal palette-index digits
// `. a b c` — letter form used by most NES tools
let val = match ch {
'.' | ' ' | '0' => 0u8,
'#' | '1' => 1,
'%' | '2' => 2,
'@' | '3' => 3,
'#' | '1' | 'a' | 'A' => 1,
'%' | '2' | 'b' | 'B' => 2,
'@' | '3' | 'c' | 'C' => 3,
other => {
return Err(Diagnostic::error(
ErrorCode::E0201,
format!(
"sprite '{sprite_name}' pixel at ({rx}, {ry}) has \
invalid character '{other}'; use '.'/' '/'0' for \
index 0, '#'/'1' for 1, '%'/'2' for 2, '@'/'3' for 3"
index 0, '#'/'1'/'a' for 1, '%'/'2'/'b' for 2, \
'@'/'3'/'c' for 3"
),
key_span,
));
@ -2947,18 +2953,21 @@ fn tilemap_to_bytes(
/// The attribute layout is notoriously awkward: each attribute byte
/// covers a 32×32-pixel region (four 16×16 metatiles) packed as
/// `BR BL TR TL` — top-left in the low bits, bottom-right in the high
/// bits. A 32×30-pixel nametable has a 16×15 metatile grid but the
/// attribute table is a fixed 8×8 = 64 bytes, so the last half-row
/// of metatiles (rows 14-15, which render below the visible area) is
/// always zero-padded.
/// bits. The attribute table is a fixed 8×8 = 64 bytes covering 16
/// metatile rows, even though only the top 15 (the visible 240
/// scanlines) render on screen. Programs may declare up to 16 rows
/// so the off-screen half picks up sensible attribute bytes; if
/// exactly 15 are given, the parser auto-replicates row 14 down
/// into row 15 so the last attribute byte stays consistent with
/// what's visible.
fn palette_map_to_attrs(bg_name: &str, rows: &[String], span: Span) -> Result<Vec<u8>, Diagnostic> {
// 16 metatile cols × up to 15 metatile rows.
if rows.len() > 15 {
if rows.len() > 16 {
return Err(Diagnostic::error(
ErrorCode::E0201,
format!(
"background '{bg_name}' palette_map has {} rows; maximum is 15 \
(one entry per 16×16 metatile row)",
"background '{bg_name}' palette_map has {} rows; maximum is 16 \
(15 visible metatile rows + 1 off-screen row for the bottom \
half of the last attribute byte)",
rows.len()
),
span,
@ -3002,6 +3011,14 @@ fn palette_map_to_attrs(bg_name: &str, rows: &[String], span: Span) -> Result<Ve
grid[ry][rx] = idx;
}
}
// If the user gave exactly 15 rows, replicate row 14 into row 15
// so the last attribute byte's bottom-half picks up the same
// sub-palette as the visible bottom of the screen. Users who
// want explicit control over the off-screen row can supply all
// 16 rows.
if rows.len() == 15 {
grid[15] = grid[14];
}
// Pack into the 8×8 attribute table. Each attribute byte covers
// a 2×2 block of metatiles:
// bits 0-1 = top-left (grid[ay*2 ][ax*2 ])

View file

@ -1780,6 +1780,54 @@ fn parse_sprite_pixel_art_rejects_non_multiple_of_8() {
assert!(diags.contains(&crate::errors::ErrorCode::E0201));
}
#[test]
fn parse_sprite_pixel_art_accepts_abc_alias() {
// `.abc` is the vocabulary every NES tool uses for 2-bit pixel
// art; the parser should accept it interchangeably with `.#%@`
// and `.0123`. Two sprites written in the two forms must
// encode to bit-identical CHR bytes.
let letters = r#"
game "T" { mapper: NROM }
sprite A {
pixels: [
"........",
"...aa...",
"..abba..",
".abbccb.",
".abbccb.",
"..abba..",
"...aa...",
"........"
]
}
on frame { wait_frame }
start Main
"#;
let glyphs = r#"
game "T" { mapper: NROM }
sprite A {
pixels: [
"........",
"...##...",
"..#%%#..",
".#%%@@%.",
".#%%@@%.",
"..#%%#..",
"...##...",
"........"
]
}
on frame { wait_frame }
start Main
"#;
let p1 = parse_ok(letters);
let p2 = parse_ok(glyphs);
match (&p1.sprites[0].chr_source, &p2.sprites[0].chr_source) {
(AssetSource::Inline(a), AssetSource::Inline(b)) => assert_eq!(a, b),
_ => panic!("expected inline CHR on both sprites"),
}
}
#[test]
fn parse_sprite_pixel_art_rejects_ragged_rows() {
let src = r#"
@ -2083,6 +2131,110 @@ fn parse_background_palette_map_packs_attributes() {
assert_eq!(attrs[1], 0x55);
}
#[test]
fn parse_background_palette_map_15_rows_replicates_off_screen_row() {
// When a program only declares 15 rows (the visible metatile
// grid), the parser should replicate the bottom row into the
// off-screen 16th row so the last attribute byte's bottom half
// picks up the same sub-palette as the visible bottom.
let src = r#"
game "T" { mapper: NROM }
background Stage {
legend { ".": 0 }
map: ["................................"]
palette_map: [
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222",
"2222222222222222"
]
}
on frame { wait_frame }
start Main
"#;
let prog = parse_ok(src);
let attrs = &prog.backgrounds[0].attributes;
// Every byte covers 4 metatiles at sub-palette 2, so each
// byte should be 0b10_10_10_10 = 0xAA. Crucially, the last
// attribute row (bytes 56-63) must also be 0xAA — if the
// auto-replicate didn't happen, bytes 56-63 would be 0x0A
// (top-half 2, bottom-half 0) since the 16th metatile row
// would default to sub-palette 0.
for b in attrs {
assert_eq!(*b, 0xAA, "every attribute byte should be 2s");
}
}
#[test]
fn parse_background_palette_map_16_rows_accepted() {
// The parser accepts an explicit 16th row for programs that
// want full control over the off-screen attribute byte.
let src = r#"
game "T" { mapper: NROM }
background Stage {
legend { ".": 0 }
map: ["................................"]
palette_map: [
"1111111111111111", "1111111111111111",
"1111111111111111", "1111111111111111",
"1111111111111111", "1111111111111111",
"1111111111111111", "1111111111111111",
"1111111111111111", "1111111111111111",
"1111111111111111", "1111111111111111",
"1111111111111111", "1111111111111111",
"1111111111111111",
"2222222222222222"
]
}
on frame { wait_frame }
start Main
"#;
let prog = parse_ok(src);
let attrs = &prog.backgrounds[0].attributes;
// The last attribute byte's top half covers metatile row 14
// (sub-palette 1) and bottom half covers metatile row 15
// (sub-palette 2). Byte = (1) | (1 << 2) | (2 << 4) | (2 << 6)
// = 0x01 | 0x04 | 0x20 | 0x80 = 0xA5.
assert_eq!(attrs[63], 0xA5);
}
#[test]
fn parse_background_palette_map_rejects_17_rows() {
let src = r#"
game "T" { mapper: NROM }
background Bad {
legend { ".": 0 }
map: ["."]
palette_map: [
"0000000000000000", "0000000000000000",
"0000000000000000", "0000000000000000",
"0000000000000000", "0000000000000000",
"0000000000000000", "0000000000000000",
"0000000000000000", "0000000000000000",
"0000000000000000", "0000000000000000",
"0000000000000000", "0000000000000000",
"0000000000000000", "0000000000000000",
"0000000000000000"
]
}
on frame { wait_frame }
start Main
"#;
let diags = parse_err(src);
assert!(diags.contains(&crate::errors::ErrorCode::E0201));
}
#[test]
fn parse_background_raw_tiles_and_attributes_still_work() {
// Backward compat: legacy inline byte arrays should keep parsing