1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +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

@ -858,8 +858,13 @@ Rules:
- Every character in a `map:` string must be defined in the legend - Every character in a `map:` string must be defined in the legend
(otherwise `E0201`). (otherwise `E0201`).
- `palette_map:` rows are ≤ 16 digit characters (`0`-`3`, plus - `palette_map:` rows are ≤ 16 digit characters (`0`-`3`, plus
`.` / space as a sub-palette 0 alias), no more than 15 rows. The `.` / space as a sub-palette 0 alias). Up to 16 rows are
parser packs adjacent 2×2 metatile groups into the awkward accepted: the first 15 cover the visible 240-scanline screen and
the optional 16th covers the off-screen half of the last
attribute row (the PPU still reads it). If exactly 15 rows are
supplied, the parser auto-replicates row 14 into row 15 so the
visible bottom edge of the screen gets consistent attribute
bytes. The packer handles the awkward
`(br<<6)|(bl<<4)|(tr<<2)|tl` attribute-byte layout for you. `(br<<6)|(bl<<4)|(tr<<2)|tl` attribute-byte layout for you.
- Raw and tilemap forms are mutually exclusive per field - Raw and tilemap forms are mutually exclusive per field
(`tiles:` vs `map:`, `attributes:` vs `palette_map:`). (`tiles:` vs `map:`, `attributes:` vs `palette_map:`).

View file

@ -35,43 +35,45 @@ game "Audio Demo" {
// ── User-declared sound effects ── // ── User-declared sound effects ──
// //
// An `sfx` block is a frame-accurate envelope for pulse 1. `pitch` // An `sfx` block is a frame-accurate envelope for pulse 1. `pitch`
// latches the pulse period once on trigger; `volume` runs one entry // is latched into `$4002` once on trigger — the v1 audio driver
// per frame, so the envelope length controls the effect duration. // never updates the pulse period mid-effect — so a single scalar
// `duty` (0-3) picks the pulse waveform shape (2 = 50% square). // byte is the natural form for the current pipeline. `envelope:`
// is a friendlier alias for `volume:`: both describe the per-frame
// volume ramp that shapes the effect. `duty` (0-3) picks the pulse
// waveform (2 = 50% square).
// A gentle rising chirp, longer than the builtin coin. // A gentle rising chirp, longer than the builtin coin.
sfx LongCoin { sfx LongCoin {
duty: 2 duty: 2
pitch: [0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50] pitch: 0x50
volume: [15, 14, 13, 12, 11, 9, 7, 5, 3, 1] envelope: [15, 14, 13, 12, 11, 9, 7, 5, 3, 1]
} }
// A sharp two-part zap — quick high spike into silence. // A sharp two-part zap — quick high spike into silence.
sfx Zap { sfx Zap {
duty: 3 duty: 3
pitch: [0x20, 0x20, 0x20, 0x20, 0x20] pitch: 0x20
volume: [15, 12, 8, 4, 1] envelope: [15, 12, 8, 4, 1]
} }
// ── User-declared music tracks ── // ── User-declared music tracks ──
// //
// A `music` block is a flat list of `(pitch, duration)` note pairs. // Notes are written in note-name form (`C4`, `E4`, `G4`, …) with a
// Pitch 0 = rest; 1-60 = period table index (C1..B5, middle C = 37). // default frames-per-note set by `tempo:`. Any note can override
// Duration is in frames (so at 60 fps, 30 = half second per note). // the default by trailing the name with an explicit duration
// Music loops by default; set `repeat: false` for one-shot cues. // (e.g. `G4 40` holds twice as long). `rest` (or the alias `_`) is
// a silent beat. Use the legacy `37, 20, 41, 20, ...` pair form by
// leaving `tempo:` out if you'd rather pick pitches by index.
// A cheerful four-note looping theme. // A cheerful four-note looping theme — every note is one-third of
// a second long, and the whole loop is 120 frames = 2 seconds.
music Theme { music Theme {
duty: 2 duty: 2
volume: 10 volume: 10
repeat: true repeat: true
tempo: 20
notes: [ notes: [
37, 20, // C4 C4, E4, G4, C5, G4, E4
41, 20, // E4
44, 20, // G4
49, 20, // C5
44, 20, // G4
41, 20, // E4
] ]
} }

View file

@ -24,39 +24,39 @@ game "Palette + BG Demo" {
// ── Palettes ──────────────────────────────────────────────── // ── Palettes ────────────────────────────────────────────────
// //
// Each palette is 32 bytes laid out in the standard NES order: // Both palettes are authored in grouped form: one shared
// $00-$0F background palettes 0-3 (4 colours each) // `universal:` colour feeds every sub-palette's index-0 slot,
// $10-$1F sprite palettes 0-3 (4 colours each, $10/$14/$18/$1C // which auto-fixes the `$3F10/$3F14/$3F18/$3F1C` PPU mirror
// mirror the bg slots) // trap (the parser handles the packing). Each `bgN` / `spN`
// field only needs three colours — the universal is prepended.
// //
// `CoolBlues` uses deep blue background tiles with pale highlights; // Named NES colours resolve to master-palette indices via the
// `WarmReds` swaps them for red/orange tones. Every 4 bytes is a // curated name table; see `docs/language-guide.md` for the full
// sub-palette. // list. Hex byte literals (`0x01`, `0x14`, …) still work if you
// need a colour that doesn't have a name.
palette CoolBlues { palette CoolBlues {
colors: [ universal: black
0x0F, 0x01, 0x11, 0x21, // bg palette 0: black, deep blue, sky, pale bg0: [dk_blue, blue, sky_blue] // deep blue highlights
0x0F, 0x02, 0x12, 0x22, // bg palette 1 bg1: [indigo, royal_blue, periwinkle] // cool purples
0x0F, 0x0C, 0x1C, 0x2C, // bg palette 2 bg2: [dk_cyan, cyan, lt_cyan] // aquas
0x0F, 0x0B, 0x1B, 0x2B, // bg palette 3 bg3: [dk_teal, teal, lt_teal] // teal accents
0x0F, 0x01, 0x11, 0x21, // sprite palette 0 sp0: [dk_blue, blue, sky_blue] // matches bg0
0x0F, 0x16, 0x27, 0x30, // sprite palette 1 sp1: [red, orange, white] // warm accent sprites
0x0F, 0x14, 0x24, 0x34, // sprite palette 2 sp2: [magenta, lt_magenta, pale_pink] // magenta sprites
0x0F, 0x0B, 0x1B, 0x2B // sprite palette 3 sp3: [dk_teal, teal, lt_teal] // matches bg3
]
} }
palette WarmReds { palette WarmReds {
colors: [ universal: black
0x0F, 0x06, 0x16, 0x26, // bg palette 0: black, dark red, red, peach bg0: [dk_red, red, lt_red] // fiery warm bg
0x0F, 0x07, 0x17, 0x27, // bg palette 1 bg1: [brown, dk_orange, orange] // autumnal
0x0F, 0x08, 0x18, 0x28, // bg palette 2 bg2: [dk_olive, olive, yellow] // muted yellows
0x0F, 0x09, 0x19, 0x29, // bg palette 3 bg3: [dk_green, green, lt_green] // greens for contrast
0x0F, 0x06, 0x16, 0x26, // sprite palette 0 sp0: [dk_red, red, lt_red] // matches bg0
0x0F, 0x16, 0x27, 0x30, // sprite palette 1 sp1: [red, orange, white] // highlight sprites
0x0F, 0x14, 0x24, 0x34, // sprite palette 2 sp2: [magenta, lt_magenta, pale_pink] // pinks
0x0F, 0x0B, 0x1B, 0x2B // sprite palette 3 sp3: [dk_teal, teal, lt_teal] // cool accent
]
} }
// ── Backgrounds ───────────────────────────────────────────── // ── Backgrounds ─────────────────────────────────────────────
@ -74,40 +74,46 @@ palette WarmReds {
// top-left to bottom-right so the swap is visually obvious. // top-left to bottom-right so the swap is visually obvious.
background TitleScreen { background TitleScreen {
tiles: [ // Both backgrounds resolve to an all-zero nametable for this
// Row 14 (offset 14*32 = 448): a ribbon of tile 0 // demo — the visual difference between them comes from the
// across columns 4..28. // palette swap triggered alongside `load_background`. We still
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // author them with a `legend` + `map:` block here so the demo
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // exercises the pleasant syntax the same way a real game
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // would. The '.' legend entry alone is enough to fill an
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // all-tile-0 background; every row is padded to 32 columns
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // automatically by the parser.
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, legend { ".": 0 }
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, map: [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "................................", // row 0
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "................................", // row 1
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "................................", // row 2
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "................................", // row 3
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "................................", // row 4
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "................................", // row 5
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "................................", // row 6
// Row 14 "................................", // row 7
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "................................", // row 8
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 "................................", // row 9
"................................", // row 10
"................................", // row 11
"................................", // row 12
"................................", // row 13
"................................", // row 14 (ribbon)
"................................" // row 15
] ]
} }
background StageOne { background StageOne {
tiles: [ // StageOne's visual difference comes from the palette swap —
// A few scattered tile-0s across the first 4 rows so the // the tile grid is still the built-in smiley everywhere. We
// background visibly differs from TitleScreen after the // only declare 4 rows to prove the parser's zero-padding
// swap. Remaining rows are zero-padded and render as // still works with the new tilemap form.
// tile 0 anyway — the visual difference comes from the legend { ".": 0 }
// palette swap triggered alongside. map: [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "................................",
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "................................",
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "................................",
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 "................................"
] ]
} }

View file

@ -32,54 +32,34 @@ game "Platformer" {
} }
// ── Palette ────────────────────────────────────────────────── // ── Palette ──────────────────────────────────────────────────
// The background palette drives the sky / hill / ground look. // Authored in grouped form: one shared `universal:` colour fills
// Each sub-palette reserves index 0 for the shared background // every sub-palette's index-0 slot automatically. That single
// colour (sky blue $22), plus three working colours. // declaration is enough to dodge the notorious `$3F10/$3F14/$3F18/
// bg 0 (sky region): sky blue, white, light gray, black // $3F1C` PPU mirror trap — when a program writes 8 distinct
// bg 1 (air/blocks row): sky blue, red, peach, dark red // "index 0" bytes sequentially, the last write clobbers the
// bg 2 (ground row): sky blue, lime green, dark green, dark brown // shared background colour and the screen turns the wrong colour
// bg 3 (unused reserve): sky blue, yellow, orange, gold // (or all-black). Using one shared universal everywhere is what
// The sprite sub-palette 0 (used by every `draw` statement — the // every shipped NES game does; the parser handles it for us.
// OAM attribute byte is always 0 in the current NEScript //
// runtime) carries the hero's red/peach/white body colours. // `0x22` is the lavender-blue NES master palette slot Super Mario
// NOTE: the first byte of every sub-palette must match. The PPU // Bros uses for its iconic sky. The colour names map to the
// mirrors $3F10/$3F14/$3F18/$3F1C onto $3F00/$3F04/$3F08/$3F0C, so // curated set documented in `docs/language-guide.md`.
// writing 8 distinct "index 0" bytes ends up clobbering the
// background universal colour with the last write. The canonical
// fix — and what every NES game does — is to use the universal
// background colour ($22 sky blue here) in all 8 slots. For sprite
// palettes, index 0 is always transparent regardless of the stored
// value, so this costs nothing visually.
palette Main { palette Main {
colors: [ universal: 0x22 // sky blue (NES $22)
// bg palette 0 — sky / clouds
// 0 = sky blue (universal bg) // Background sub-palettes
// 1 = white bg0: [white, gray, black] // sky / clouds
// 2 = light gray bg1: [red, orange, dk_red] // bricks, Q-blocks
// 3 = black bg2: [bright_green, dk_orange, brown] // grass, hills, bushes, dirt
0x22, 0x30, 0x10, 0x0F, bg3: [yellow, orange, dk_orange] // reserved
// bg palette 1 — bricks, Q-blocks
// 1 = red, 2 = peach, 3 = dark red // Sprite sub-palette 0 — every `draw` uses this one slot
0x22, 0x16, 0x27, 0x06, // because the OAM attribute byte is always 0 in v0.1.
// bg palette 2 — grass, hills, bushes, dirt // 1 = red cap, 2 = orange skin, 3 = white highlight.
// 1 = light green sp0: [red, orange, white]
// 2 = light brown sp1: [brown, dk_orange, orange] // reserved
// 3 = dark brown sp2: [neon_green, bright_green, white] // reserved
0x22, 0x1A, 0x17, 0x07, sp3: [yellow, olive, cream] // reserved
// bg palette 3 — unused but the mirroring still writes here
0x22, 0x28, 0x27, 0x17,
// sprite palette 0 — hero / enemy / coin
// (OAM attribute is always 0 in the current runtime so
// every sprite uses this one sub-palette)
// 1 = red cap, 2 = peach skin, 3 = white
0x22, 0x16, 0x27, 0x30,
// sprite palette 1 — reserved
0x22, 0x07, 0x17, 0x27,
// sprite palette 2 — reserved
0x22, 0x2A, 0x1A, 0x30,
// sprite palette 3 — reserved
0x22, 0x28, 0x18, 0x38
]
} }
// ── Tileset ────────────────────────────────────────────────── // ── Tileset ──────────────────────────────────────────────────
@ -89,38 +69,149 @@ palette Main {
// same declaration drives the player, enemies, coins, and every // same declaration drives the player, enemies, coins, and every
// background tile after tile 0. Tile 0 is reserved by the linker // background tile after tile 0. Tile 0 is reserved by the linker
// for the built-in smiley — the background leaves it unreferenced. // for the built-in smiley — the background leaves it unreferenced.
//
// All 15 tiles are authored as 8×8 ASCII pixel art and stacked
// vertically (1 tile wide × 15 tiles tall) so the parser splits
// them into consecutive CHR tile indices in reading order. The
// art uses the `.abc` vocabulary (`. = 0`, `a = 1`, `b = 2`,
// `c = 3`); regenerate with `cargo run --bin gen_platformer_tiles`.
sprite Tileset { sprite Tileset {
chr: [ pixels: [
// tile 1: Player head L // tile 1: Player head L
0x3F, 0x7F, 0x70, 0x61, 0xCF, 0xCD, 0xC0, 0xC0, 0x00, 0x00, 0x0F, 0x1F, 0x3F, 0x3F, 0x3F, 0x3F, "..aaaaaa",
".aaaaaaa",
".aaabbbb",
".aabbbbc",
"aabbcccc",
"aabbccbc",
"aabbbbbb",
"aabbbbbb",
// tile 2: Player head R // tile 2: Player head R
0xFC, 0xFE, 0x0E, 0x83, 0xF8, 0x4C, 0x00, 0x00, 0x00, 0x00, 0xF0, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, "aaaaaa..",
"aaaaaaa.",
"bbbbaaa.",
"cbbbbbaa",
"cccccbbb",
"bcbbccbb",
"bbbbbbbb",
"bbbbbbbb",
// tile 3: Player body L // tile 3: Player body L
0x7F, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x10, 0x18, 0x00, 0x7F, 0x7F, 0x67, 0x07, ".aaaaaaa",
"aaacaaaa",
"aaaccaaa",
"aaaaaaaa",
".bbbbbbb",
".bbbbbbb",
".bb..bbb",
"aaa..bbb",
// tile 4: Player body R // tile 4: Player body R
0xFE, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, 0x00, 0x08, 0x18, 0x00, 0xFE, 0xFE, 0xE6, 0xE0, "aaaaaaa.",
"aaaacaaa",
"aaaccaaa",
"aaaaaaaa",
"bbbbbbb.",
"bbbbbbb.",
"bbb..bb.",
"bbb..aaa",
// tile 5: Enemy // tile 5: Enemy
0x3C, 0x7E, 0x76, 0xFF, 0x99, 0x7E, 0xBD, 0x99, 0x00, 0x00, 0x18, 0x38, 0x7E, 0x00, 0x00, 0x00, "..aaaa..",
".aaaaaa.",
".aacbaa.",
"aacccaaa",
"abbccbba",
".aaaaaa.",
"a.aaaa.a",
"a..aa..a",
// tile 6: Coin // tile 6: Coin
0x00, 0x00, 0x18, 0x24, 0x24, 0x18, 0x00, 0x00, 0x18, 0x3C, 0x7E, 0x7E, 0x7E, 0x7E, 0x3C, 0x18, "...bb...",
"..bbbb..",
".bbccbb.",
".bcbbcb.",
".bcbbcb.",
".bbccbb.",
"..bbbb..",
"...bb...",
// tile 7: Grass top // tile 7: Grass top
0xFF, 0xBB, 0xFF, 0xFF, 0xFF, 0xFF, 0xBD, 0xFF, 0x00, 0x00, 0x00, 0x6A, 0xF7, 0xFF, 0xFF, 0xFF, "aaaaaaaa",
"a.aaa.aa",
"aaaaaaaa",
"accacaca",
"ccccaccc",
"cccccccc",
"cbccccbc",
"cccccccc",
// tile 8: Dirt // tile 8: Dirt
0xFF, 0xBD, 0xFF, 0xF7, 0xFF, 0xBD, 0xF7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, "cccccccc",
"cbccccbc",
"cccccccc",
"ccccbccc",
"cccccccc",
"cbccccbc",
"ccccbccc",
"cccccccc",
// tile 9: Brick // tile 9: Brick
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x82, 0x82, 0x82, 0xFF, 0x10, 0x10, 0x10, "cccccccc",
"caaaaaca",
"caaaaaca",
"caaaaaca",
"cccccccc",
"aaacaaaa",
"aaacaaaa",
"aaacaaaa",
// tile 10: Cloud L // tile 10: Cloud L
0x00, 0x1C, 0x3E, 0x7F, 0x7F, 0x1F, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x3E, 0x07, "........",
"...aaa..",
"..aaaaa.",
".aaaaaaa",
".aaaaaaa",
".bbaaaaa",
"..bbbbba",
".....bbb",
// tile 11: Cloud R // tile 11: Cloud R
0x00, 0x70, 0xF8, 0xFE, 0xFE, 0xF8, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x7C, 0xE0, "........",
".aaa....",
"aaaaa...",
"aaaaaaa.",
"aaaaaaa.",
"aaaaabb.",
"abbbbb..",
"bbb.....",
// tile 12: Hill // tile 12: Hill
0x00, 0x0C, 0x1E, 0x3F, 0x2F, 0x4B, 0x75, 0xC9, 0x00, 0x00, 0x00, 0x00, 0x10, 0x34, 0x0A, 0x36, "........",
"....aa..",
"...aaaa.",
"..aaaaaa",
"..abaaaa",
".abbabaa",
".aaababa",
"aabbabba",
// tile 13: Bush // tile 13: Bush
0x00, 0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x02, 0x02, 0x06, 0x06, 0x55, "........",
"...aa...",
"..aaaa..",
".aaaaac.",
"aaaaaaca",
"aaaaacca",
"aaaaacca",
"acacacac",
// tile 14: Q Block // tile 14: Q Block
0xFF, 0xFF, 0xC3, 0xDB, 0xD3, 0xC3, 0xFF, 0xFF, 0xFF, 0x81, 0xBD, 0xBD, 0xBD, 0xBD, 0x81, 0xFF, "cccccccc",
"caaaaaac",
"cabbbbac",
"cabccbac",
"cabcbbac",
"cabbbbac",
"caaaaaac",
"cccccccc",
// tile 15: Sky (blank) // tile 15: Sky (blank)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 "........",
"........",
"........",
"........",
"........",
"........",
"........",
"........"
] ]
} }
@ -133,142 +224,121 @@ const TILE_ENEMY: u8 = 5
const TILE_COIN: u8 = 6 const TILE_COIN: u8 = 6
// ── Background ────────────────────────────────────────────── // ── Background ──────────────────────────────────────────────
// Full-screen 32×30 nametable showing a static level view that // The 32×30 nametable is authored as ASCII art with a `legend`
// horizontal scrolling pans across. The attribute table splits // block naming each tile by a single character. Horizontal
// the screen into three palette regions (sky/blocks/ground) so // scrolling pans this single nametable via `scroll()` so it only
// the grass and hills pick up the right colours even without // needs to look interesting at any X offset.
// per-tile attribute churn. //
// `palette_map:` is the friendlier alternative to a 64-byte
// hand-packed attribute table: 16×15 metatile entries, one
// digit per 16×16 metatile, and the parser packs the 2-bit
// `(br<<6)|(bl<<4)|(tr<<2)|tl` quadrants automatically. The
// three palette regions are sky (`0`), brick row (`1`), and
// ground (`2`).
background Level { background Level {
tiles: [ legend {
// rows 0-4: clean sky ".": 15 // sky (blank)
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "<": 10 // cloud left half
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, ">": 11 // cloud right half
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "#": 9 // brick
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "Q": 14 // question block
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "^": 12 // hill silhouette
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "*": 13 // bush
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "=": 7 // grass top
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "%": 8 // dirt
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, }
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
// row 5: clouds at x=4..5 and x=20..21 map: [
15, 15, 15, 15, 10, 11, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 10, 11, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
// row 6: cloud at x=12..13 "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 10, 11, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
// rows 7-14: more clean sky "....<>..............<>..........",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "............<>..................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "......##Q#........###...........",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "................................",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "..^^^.................^^^^......",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "..........***..............**...",
// row 15: brick/Q-block platform left, brick platform right "================================",
15, 15, 15, 15, 15, 15, 9, 9, 14, 9, 15, 15, 15, 15, 15, 15, "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#%",
15, 15, 9, 9, 9, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "%%%%%#%%%%%%%%%%%%#%%%%%%%%%%%%%",
// rows 16-19: sky "%%%%%%%%%%%#%%%%%%%%%%%%#%%%%%%%",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
// row 20: hills (3 on left, 4 on right)
15, 15, 12, 12, 12, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 12, 12, 12, 12, 15, 15, 15, 15, 15, 15,
// row 21: bushes above the grass line
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 13, 13, 13, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 13, 13, 15, 15, 15,
// row 22: grass top (full width)
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
// rows 23-29: dirt with a few buried bricks for texture
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 8,
8, 8, 8, 8, 8, 9, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 9, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 9, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8
] ]
attributes: [
// 8×8 attribute grid, one byte per 32×32-px metatile group. palette_map: [
// Each byte packs 4 identical 2-bit quadrants selecting a "0000000000000000", // metatile rows 0-5 → sky sub-palette
// sub-palette (0..3): "0000000000000000",
// 0x00 = sub-palette 0 (sky / clouds) "0000000000000000",
// 0x55 = sub-palette 1 (bricks / Q-blocks) "0000000000000000",
// 0xAA = sub-palette 2 (grass / hills / dirt) "0000000000000000",
// "0000000000000000",
// Row mapping: ay 0-2 (nt rows 0-11) = sky, ay 3 (nt rows "1111111111111111", // metatile rows 6-7 → brick sub-palette
// 12-15) = brick row, ay 4 (nt rows 16-19) = more sky, ay "1111111111111111",
// 5-7 (nt rows 20-29) = ground. "0000000000000000", // metatile rows 8-9 → sky again
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, "0000000000000000",
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, "2222222222222222", // metatile rows 10-15 → ground sub-palette
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, "2222222222222222", // (rows 15 sits off-screen but the PPU
0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, "2222222222222222", // still reads its attribute byte, so
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, "2222222222222222", // we emit it explicitly to match the
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, "2222222222222222", // hand-packed attribute table the
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, "2222222222222222" // old form was using.)
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA
] ]
} }
// ── Audio ──────────────────────────────────────────────────── // ── Audio ────────────────────────────────────────────────────
//
// SFX: scalar `pitch:` matches the v1 driver's latch-once
// behaviour — pulse 1's period is sampled exactly once when the
// effect triggers and never updated, so a single byte is the
// natural form for the current pipeline. `envelope:` is the
// friendlier alias for `volume:`.
// Custom boingy jump sound — descending pitch arc on pulse 1. // Custom boingy jump sound — fixed-pitch arc on pulse 1.
sfx Boing { sfx Boing {
duty: 2 duty: 2
pitch: [0x30, 0x38, 0x40, 0x48, 0x50, 0x58, 0x60] pitch: 0x30
volume: [12, 11, 10, 9, 7, 5, 2] envelope: [12, 11, 10, 9, 7, 5, 2]
} }
// Custom coin ding — short rising blip. // Custom coin ding — short rising blip.
sfx Ding { sfx Ding {
duty: 2 duty: 2
pitch: [0x20, 0x20, 0x20, 0x20] pitch: 0x20
volume: [14, 12, 8, 3] envelope: [14, 12, 8, 3]
} }
// Custom looping underworld theme — playful four-bar loop on // Custom looping underworld theme — playful four-bar loop on
// pulse 2 that plays while the player is alive. // pulse 2 that plays while the player is alive. Note names
// (C4 / E4 / etc.) plus a default `tempo:` so each note only
// has to spell its frame count when it deviates from the beat.
music Theme { music Theme {
duty: 2 duty: 2
volume: 9 volume: 9
repeat: true repeat: true
tempo: 10
notes: [ notes: [
37, 10, // C4 C4, E4, G4, C5, G4, E4,
41, 10, // E4 D4 15,
44, 10, // G4 rest 5,
49, 10, // C5 B3, E4, G4,
44, 10, // G4 C5 20,
41, 10, // E4 rest 10
39, 15, // D4
0, 5, // rest
36, 10, // B3
41, 10, // E4
44, 10, // G4
49, 20, // C5
0, 10 // rest
] ]
} }

View file

@ -9,17 +9,38 @@ game "Asset Demo" {
mapper: NROM mapper: NROM
} }
// Define a sprite with inline CHR tile data (16 bytes = one 8x8 tile) // Define a sprite with ASCII pixel art. Each character maps to a
// This is a simple arrow pointing right // 2-bit palette index: `.` = 0 (transparent), `#` = 1, `%` = 2,
// `@` = 3. The parser handles the 2-bitplane CHR encoding, so we
// never touch hex bytes by hand.
//
// Arrow — a right-facing arrow in palette-index 1.
sprite Arrow { sprite Arrow {
chr: [0x18, 0x1C, 0xFE, 0xFF, 0xFF, 0xFE, 0x1C, 0x18, pixels: [
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] "...##...",
"...###..",
"#######.",
"########",
"########",
"#######.",
"...###..",
"...##..."
]
} }
// Define a sprite with a heart shape // Heart — a full-colour heart in palette-index 3 (the brightest
// shade, `@`).
sprite Heart { sprite Heart {
chr: [0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00, pixels: [
0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00] ".@@..@@.",
"@@@@@@@@",
"@@@@@@@@",
"@@@@@@@@",
".@@@@@@.",
"..@@@@..",
"...@@...",
"........"
]
} }
var px: u8 = 128 var px: u8 = 128

View file

@ -3,11 +3,11 @@
//! //!
//! Run with `cargo run --bin gen_platformer_tiles`. The output is //! Run with `cargo run --bin gen_platformer_tiles`. The output is
//! intended to be pasted into `platformer.ne` under the //! intended to be pasted into `platformer.ne` under the
//! `sprite Tileset { chr: [...] }` and //! `sprite Tileset { pixels: [...] }` and
//! `background Level { tiles: [...] }` blocks. Keeping the source of //! `background Level { legend { ... } map: [...] palette_map: [...] }`
//! truth here (instead of hand-maintained hex in the `.ne` file) //! blocks. Keeping the source of truth here (instead of
//! makes the tile art editable as ASCII and ensures the CHR bytes //! hand-maintained ASCII in the `.ne` file) ensures the tile art,
//! and the nametable stay in sync with named tile indices. //! the nametable, and the named tile indices all stay in sync.
//! //!
//! Tiles are defined as 8×8 ASCII art where each character selects //! Tiles are defined as 8×8 ASCII art where each character selects
//! one of 4 sub-palette slots: //! one of 4 sub-palette slots:
@ -15,8 +15,10 @@
//! 'a' = colour 1 //! 'a' = colour 1
//! 'b' = colour 2 //! 'b' = colour 2
//! 'c' = colour 3 //! 'c' = colour 3
//!
use std::fmt::Write as _; //! 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.
/// A single 8×8 tile description: name + ASCII art. /// A single 8×8 tile description: name + ASCII art.
struct Tile { struct Tile {
@ -242,34 +244,32 @@ const BUSH: u8 = 13;
const QBLOCK: u8 = 14; const QBLOCK: u8 = 14;
const SKY: u8 = 15; const SKY: u8 = 15;
/// Encode one 8×8 tile into its 16-byte NES CHR representation /// Validate that a tile's ASCII art is well-formed (8 rows × 8 cols,
/// (two 8-byte bitplanes: low bit then high bit). /// only the legal `.abc` characters). Pixel→CHR encoding now happens
fn tile_to_chr(art: &str) -> [u8; 16] { /// 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) {
let rows: Vec<&str> = art.lines().filter(|l| !l.is_empty()).collect(); let rows: Vec<&str> = art.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(rows.len(), 8, "expected 8 rows, got {}", rows.len()); assert_eq!(
let mut chr = [0u8; 16]; rows.len(),
8,
"tile '{name}': expected 8 rows, got {}",
rows.len()
);
for (y, row) in rows.iter().enumerate() { for (y, row) in rows.iter().enumerate() {
assert_eq!(row.len(), 8, "expected 8 cols in row {y}: {row:?}"); assert_eq!(
let (mut plane0, mut plane1) = (0u8, 0u8); row.len(),
for (x, ch) in row.chars().enumerate() { 8,
let idx: u8 = match ch { "tile '{name}' row {y}: expected 8 cols, got {row:?}"
'.' => 0, );
'a' => 1, for ch in row.chars() {
'b' => 2, assert!(
'c' => 3, matches!(ch, '.' | 'a' | 'b' | 'c'),
other => panic!("invalid tile char {other:?}"), "tile '{name}' row {y}: invalid character '{ch}'"
}; );
if idx & 1 != 0 {
plane0 |= 0x80 >> x;
}
if idx & 2 != 0 {
plane1 |= 0x80 >> x;
}
} }
chr[y] = plane0;
chr[y + 8] = plane1;
} }
chr
} }
/// Build a 32×30 nametable for a static Mario-ish level vista. /// Build a 32×30 nametable for a static Mario-ish level vista.
@ -367,62 +367,112 @@ fn build_attributes() -> [u8; 64] {
attr attr
} }
fn emit_byte_block(bs: &[u8], per_row: usize, indent: &str) -> String {
let mut out = String::new();
for chunk in bs.chunks(per_row) {
out.push_str(indent);
for (i, b) in chunk.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
let _ = write!(out, "{b}");
}
out.push_str(",\n");
}
out
}
fn emit_hex_block(bs: &[u8], per_row: usize, indent: &str) -> String {
let mut out = String::new();
for chunk in bs.chunks(per_row) {
out.push_str(indent);
for (i, b) in chunk.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
let _ = write!(out, "0x{b:02X}");
}
out.push_str(",\n");
}
out
}
fn main() { fn main() {
// ── CHR ── // ── CHR tiles as pixel-art strings ──────────────────────────
let mut chr_all: Vec<u8> = Vec::new(); //
println!("// ── CHR tiles (paste into sprite Tileset {{ chr: [...] }}) ──"); // `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: [");
for (i, tile) in TILES.iter().enumerate() { for (i, tile) in TILES.iter().enumerate() {
validate_tile_art(tile.name, tile.art);
let tile_idx = i + 1; let tile_idx = i + 1;
let chr = tile_to_chr(tile.art); println!(" // tile {tile_idx}: {}", tile.name);
chr_all.extend_from_slice(&chr); for row in tile.art.lines().filter(|l| !l.is_empty()) {
println!(" // tile {tile_idx}: {}", tile.name); println!(" \"{row}\",");
print!("{}", emit_hex_block(&chr, 16, " ")); }
} }
println!( println!(" ]\n");
"// total tiles: {}, total CHR bytes: {}\n",
TILES.len(),
chr_all.len()
);
// ── Nametable ── // ── 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: [");
let nt = build_nametable(); let nt = build_nametable();
assert_eq!(nt.len(), 960); assert_eq!(nt.len(), 960);
println!("// ── Nametable (paste into background Level {{ tiles: [...] }}) ──"); let legend = legend_char_for;
print!("{}", emit_byte_block(&nt, 16, " ")); 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");
// ── Attributes ── // ── 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: [");
let attr = build_attributes(); let attr = build_attributes();
assert_eq!(attr.len(), 64); for my in 0..16 {
println!("\n// ── Attributes (paste into background Level {{ attributes: [...] }}) ──"); let mut s = String::from(" \"");
print!("{}", emit_byte_block(&attr, 16, " ")); 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}"),
}
} }

View file

@ -822,18 +822,24 @@ impl Parser {
for (ry, row) in rows.iter().enumerate() { for (ry, row) in rows.iter().enumerate() {
let mut line = Vec::with_capacity(width); let mut line = Vec::with_capacity(width);
for (rx, ch) in row.chars().enumerate() { 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 { let val = match ch {
'.' | ' ' | '0' => 0u8, '.' | ' ' | '0' => 0u8,
'#' | '1' => 1, '#' | '1' | 'a' | 'A' => 1,
'%' | '2' => 2, '%' | '2' | 'b' | 'B' => 2,
'@' | '3' => 3, '@' | '3' | 'c' | 'C' => 3,
other => { other => {
return Err(Diagnostic::error( return Err(Diagnostic::error(
ErrorCode::E0201, ErrorCode::E0201,
format!( format!(
"sprite '{sprite_name}' pixel at ({rx}, {ry}) has \ "sprite '{sprite_name}' pixel at ({rx}, {ry}) has \
invalid character '{other}'; use '.'/' '/'0' for \ 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, key_span,
)); ));
@ -2947,18 +2953,21 @@ fn tilemap_to_bytes(
/// The attribute layout is notoriously awkward: each attribute byte /// The attribute layout is notoriously awkward: each attribute byte
/// covers a 32×32-pixel region (four 16×16 metatiles) packed as /// 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 /// `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 /// bits. The attribute table is a fixed 8×8 = 64 bytes covering 16
/// attribute table is a fixed 8×8 = 64 bytes, so the last half-row /// metatile rows, even though only the top 15 (the visible 240
/// of metatiles (rows 14-15, which render below the visible area) is /// scanlines) render on screen. Programs may declare up to 16 rows
/// always zero-padded. /// 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> { 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() > 16 {
if rows.len() > 15 {
return Err(Diagnostic::error( return Err(Diagnostic::error(
ErrorCode::E0201, ErrorCode::E0201,
format!( format!(
"background '{bg_name}' palette_map has {} rows; maximum is 15 \ "background '{bg_name}' palette_map has {} rows; maximum is 16 \
(one entry per 16×16 metatile row)", (15 visible metatile rows + 1 off-screen row for the bottom \
half of the last attribute byte)",
rows.len() rows.len()
), ),
span, span,
@ -3002,6 +3011,14 @@ fn palette_map_to_attrs(bg_name: &str, rows: &[String], span: Span) -> Result<Ve
grid[ry][rx] = idx; 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 // Pack into the 8×8 attribute table. Each attribute byte covers
// a 2×2 block of metatiles: // a 2×2 block of metatiles:
// bits 0-1 = top-left (grid[ay*2 ][ax*2 ]) // 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)); 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] #[test]
fn parse_sprite_pixel_art_rejects_ragged_rows() { fn parse_sprite_pixel_art_rejects_ragged_rows() {
let src = r#" let src = r#"
@ -2083,6 +2131,110 @@ fn parse_background_palette_map_packs_attributes() {
assert_eq!(attrs[1], 0x55); 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] #[test]
fn parse_background_raw_tiles_and_attributes_still_work() { fn parse_background_raw_tiles_and_attributes_still_work() {
// Backward compat: legacy inline byte arrays should keep parsing // Backward compat: legacy inline byte arrays should keep parsing