From ad951a835f72ee050e7a6e16fff07296437485a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 18:04:21 +0000 Subject: [PATCH] examples: adopt the friendly asset syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/language-guide.md | 9 +- examples/audio_demo.ne | 38 +-- examples/palette_and_background.ne | 120 ++++---- examples/platformer.ne | 424 +++++++++++++++++------------ examples/sprites_and_palettes.ne | 35 ++- scripts/gen_platformer_tiles.rs | 210 ++++++++------ src/parser/mod.rs | 41 ++- src/parser/tests.rs | 152 +++++++++++ 8 files changed, 676 insertions(+), 353 deletions(-) diff --git a/docs/language-guide.md b/docs/language-guide.md index 1195f80..20038ca 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -858,8 +858,13 @@ Rules: - Every character in a `map:` string must be defined in the legend (otherwise `E0201`). - `palette_map:` rows are ≤ 16 digit characters (`0`-`3`, plus - `.` / space as a sub-palette 0 alias), no more than 15 rows. The - parser packs adjacent 2×2 metatile groups into the awkward + `.` / space as a sub-palette 0 alias). Up to 16 rows are + 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. - Raw and tilemap forms are mutually exclusive per field (`tiles:` vs `map:`, `attributes:` vs `palette_map:`). diff --git a/examples/audio_demo.ne b/examples/audio_demo.ne index b1a6d72..e59204a 100644 --- a/examples/audio_demo.ne +++ b/examples/audio_demo.ne @@ -35,43 +35,45 @@ game "Audio Demo" { // ── User-declared sound effects ── // // An `sfx` block is a frame-accurate envelope for pulse 1. `pitch` -// latches the pulse period once on trigger; `volume` runs one entry -// per frame, so the envelope length controls the effect duration. -// `duty` (0-3) picks the pulse waveform shape (2 = 50% square). +// is latched into `$4002` once on trigger — the v1 audio driver +// never updates the pulse period mid-effect — so a single scalar +// 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. sfx LongCoin { duty: 2 - pitch: [0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50] - volume: [15, 14, 13, 12, 11, 9, 7, 5, 3, 1] + pitch: 0x50 + envelope: [15, 14, 13, 12, 11, 9, 7, 5, 3, 1] } // A sharp two-part zap — quick high spike into silence. sfx Zap { duty: 3 - pitch: [0x20, 0x20, 0x20, 0x20, 0x20] - volume: [15, 12, 8, 4, 1] + pitch: 0x20 + envelope: [15, 12, 8, 4, 1] } // ── User-declared music tracks ── // -// A `music` block is a flat list of `(pitch, duration)` note pairs. -// Pitch 0 = rest; 1-60 = period table index (C1..B5, middle C = 37). -// Duration is in frames (so at 60 fps, 30 = half second per note). -// Music loops by default; set `repeat: false` for one-shot cues. +// Notes are written in note-name form (`C4`, `E4`, `G4`, …) with a +// default frames-per-note set by `tempo:`. Any note can override +// the default by trailing the name with an explicit duration +// (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 { duty: 2 volume: 10 repeat: true + tempo: 20 notes: [ - 37, 20, // C4 - 41, 20, // E4 - 44, 20, // G4 - 49, 20, // C5 - 44, 20, // G4 - 41, 20, // E4 + C4, E4, G4, C5, G4, E4 ] } diff --git a/examples/palette_and_background.ne b/examples/palette_and_background.ne index a1d43bf..ea36362 100644 --- a/examples/palette_and_background.ne +++ b/examples/palette_and_background.ne @@ -24,39 +24,39 @@ game "Palette + BG Demo" { // ── Palettes ──────────────────────────────────────────────── // -// Each palette is 32 bytes laid out in the standard NES order: -// $00-$0F background palettes 0-3 (4 colours each) -// $10-$1F sprite palettes 0-3 (4 colours each, $10/$14/$18/$1C -// mirror the bg slots) +// Both palettes are authored in grouped form: one shared +// `universal:` colour feeds every sub-palette's index-0 slot, +// which auto-fixes the `$3F10/$3F14/$3F18/$3F1C` PPU mirror +// 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; -// `WarmReds` swaps them for red/orange tones. Every 4 bytes is a -// sub-palette. +// Named NES colours resolve to master-palette indices via the +// curated name table; see `docs/language-guide.md` for the full +// list. Hex byte literals (`0x01`, `0x14`, …) still work if you +// need a colour that doesn't have a name. palette CoolBlues { - colors: [ - 0x0F, 0x01, 0x11, 0x21, // bg palette 0: black, deep blue, sky, pale - 0x0F, 0x02, 0x12, 0x22, // bg palette 1 - 0x0F, 0x0C, 0x1C, 0x2C, // bg palette 2 - 0x0F, 0x0B, 0x1B, 0x2B, // bg palette 3 - 0x0F, 0x01, 0x11, 0x21, // sprite palette 0 - 0x0F, 0x16, 0x27, 0x30, // sprite palette 1 - 0x0F, 0x14, 0x24, 0x34, // sprite palette 2 - 0x0F, 0x0B, 0x1B, 0x2B // sprite palette 3 - ] + universal: black + bg0: [dk_blue, blue, sky_blue] // deep blue highlights + bg1: [indigo, royal_blue, periwinkle] // cool purples + bg2: [dk_cyan, cyan, lt_cyan] // aquas + bg3: [dk_teal, teal, lt_teal] // teal accents + sp0: [dk_blue, blue, sky_blue] // matches bg0 + sp1: [red, orange, white] // warm accent sprites + sp2: [magenta, lt_magenta, pale_pink] // magenta sprites + sp3: [dk_teal, teal, lt_teal] // matches bg3 } palette WarmReds { - colors: [ - 0x0F, 0x06, 0x16, 0x26, // bg palette 0: black, dark red, red, peach - 0x0F, 0x07, 0x17, 0x27, // bg palette 1 - 0x0F, 0x08, 0x18, 0x28, // bg palette 2 - 0x0F, 0x09, 0x19, 0x29, // bg palette 3 - 0x0F, 0x06, 0x16, 0x26, // sprite palette 0 - 0x0F, 0x16, 0x27, 0x30, // sprite palette 1 - 0x0F, 0x14, 0x24, 0x34, // sprite palette 2 - 0x0F, 0x0B, 0x1B, 0x2B // sprite palette 3 - ] + universal: black + bg0: [dk_red, red, lt_red] // fiery warm bg + bg1: [brown, dk_orange, orange] // autumnal + bg2: [dk_olive, olive, yellow] // muted yellows + bg3: [dk_green, green, lt_green] // greens for contrast + sp0: [dk_red, red, lt_red] // matches bg0 + sp1: [red, orange, white] // highlight sprites + sp2: [magenta, lt_magenta, pale_pink] // pinks + sp3: [dk_teal, teal, lt_teal] // cool accent } // ── Backgrounds ───────────────────────────────────────────── @@ -74,40 +74,46 @@ palette WarmReds { // top-left to bottom-right so the swap is visually obvious. background TitleScreen { - tiles: [ - // Row 14 (offset 14*32 = 448): a ribbon of tile 0 - // across columns 4..28. - 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, - 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, - 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, - 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, - // Row 14 - 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 + // Both backgrounds resolve to an all-zero nametable for this + // demo — the visual difference between them comes from the + // palette swap triggered alongside `load_background`. We still + // author them with a `legend` + `map:` block here so the demo + // exercises the pleasant syntax the same way a real game + // would. The '.' legend entry alone is enough to fill an + // all-tile-0 background; every row is padded to 32 columns + // automatically by the parser. + legend { ".": 0 } + map: [ + "................................", // row 0 + "................................", // row 1 + "................................", // row 2 + "................................", // row 3 + "................................", // row 4 + "................................", // row 5 + "................................", // row 6 + "................................", // row 7 + "................................", // row 8 + "................................", // row 9 + "................................", // row 10 + "................................", // row 11 + "................................", // row 12 + "................................", // row 13 + "................................", // row 14 (ribbon) + "................................" // row 15 ] } background StageOne { - tiles: [ - // A few scattered tile-0s across the first 4 rows so the - // background visibly differs from TitleScreen after the - // swap. Remaining rows are zero-padded and render as - // tile 0 anyway — the visual difference comes from the - // palette swap triggered alongside. - 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 + // StageOne's visual difference comes from the palette swap — + // the tile grid is still the built-in smiley everywhere. We + // only declare 4 rows to prove the parser's zero-padding + // still works with the new tilemap form. + legend { ".": 0 } + map: [ + "................................", + "................................", + "................................", + "................................" ] } diff --git a/examples/platformer.ne b/examples/platformer.ne index 100f0c6..1277c83 100644 --- a/examples/platformer.ne +++ b/examples/platformer.ne @@ -32,54 +32,34 @@ game "Platformer" { } // ── Palette ────────────────────────────────────────────────── -// The background palette drives the sky / hill / ground look. -// Each sub-palette reserves index 0 for the shared background -// colour (sky blue $22), plus three working colours. -// bg 0 (sky region): sky blue, white, light gray, black -// bg 1 (air/blocks row): sky blue, red, peach, dark red -// bg 2 (ground row): sky blue, lime green, dark green, dark brown -// bg 3 (unused reserve): sky blue, yellow, orange, gold -// The sprite sub-palette 0 (used by every `draw` statement — the -// OAM attribute byte is always 0 in the current NEScript -// runtime) carries the hero's red/peach/white body colours. -// NOTE: the first byte of every sub-palette must match. The PPU -// mirrors $3F10/$3F14/$3F18/$3F1C onto $3F00/$3F04/$3F08/$3F0C, so -// 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. +// Authored in grouped form: one shared `universal:` colour fills +// every sub-palette's index-0 slot automatically. That single +// declaration is enough to dodge the notorious `$3F10/$3F14/$3F18/ +// $3F1C` PPU mirror trap — when a program writes 8 distinct +// "index 0" bytes sequentially, the last write clobbers the +// shared background colour and the screen turns the wrong colour +// (or all-black). Using one shared universal everywhere is what +// every shipped NES game does; the parser handles it for us. +// +// `0x22` is the lavender-blue NES master palette slot Super Mario +// Bros uses for its iconic sky. The colour names map to the +// curated set documented in `docs/language-guide.md`. palette Main { - colors: [ - // bg palette 0 — sky / clouds - // 0 = sky blue (universal bg) - // 1 = white - // 2 = light gray - // 3 = black - 0x22, 0x30, 0x10, 0x0F, - // bg palette 1 — bricks, Q-blocks - // 1 = red, 2 = peach, 3 = dark red - 0x22, 0x16, 0x27, 0x06, - // bg palette 2 — grass, hills, bushes, dirt - // 1 = light green - // 2 = light brown - // 3 = dark brown - 0x22, 0x1A, 0x17, 0x07, - // 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 - ] + universal: 0x22 // sky blue (NES $22) + + // Background sub-palettes + bg0: [white, gray, black] // sky / clouds + bg1: [red, orange, dk_red] // bricks, Q-blocks + bg2: [bright_green, dk_orange, brown] // grass, hills, bushes, dirt + bg3: [yellow, orange, dk_orange] // reserved + + // Sprite sub-palette 0 — every `draw` uses this one slot + // because the OAM attribute byte is always 0 in v0.1. + // 1 = red cap, 2 = orange skin, 3 = white highlight. + sp0: [red, orange, white] + sp1: [brown, dk_orange, orange] // reserved + sp2: [neon_green, bright_green, white] // reserved + sp3: [yellow, olive, cream] // reserved } // ── Tileset ────────────────────────────────────────────────── @@ -89,38 +69,149 @@ palette Main { // same declaration drives the player, enemies, coins, and every // background tile after tile 0. Tile 0 is reserved by the linker // 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 { - chr: [ + pixels: [ // 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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) - 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 // ── Background ────────────────────────────────────────────── -// Full-screen 32×30 nametable showing a static level view that -// horizontal scrolling pans across. The attribute table splits -// the screen into three palette regions (sky/blocks/ground) so -// the grass and hills pick up the right colours even without -// per-tile attribute churn. +// The 32×30 nametable is authored as ASCII art with a `legend` +// block naming each tile by a single character. Horizontal +// scrolling pans this single nametable via `scroll()` so it only +// needs to look interesting at any X offset. +// +// `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 { - tiles: [ - // rows 0-4: 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, - // row 5: clouds at x=4..5 and x=20..21 - 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, - 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, - 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, - 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, - 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, - 15, 15, 15, 15, 15, 15, 15, 15, 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 + legend { + ".": 15 // sky (blank) + "<": 10 // cloud left half + ">": 11 // cloud right half + "#": 9 // brick + "Q": 14 // question block + "^": 12 // hill silhouette + "*": 13 // bush + "=": 7 // grass top + "%": 8 // dirt + } + + map: [ + "................................", + "................................", + "................................", + "................................", + "................................", + "....<>..............<>..........", + "............<>..................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "......##Q#........###...........", + "................................", + "................................", + "................................", + "................................", + "..^^^.................^^^^......", + "..........***..............**...", + "================================", + "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#%", + "%%%%%#%%%%%%%%%%%%#%%%%%%%%%%%%%", + "%%%%%%%%%%%#%%%%%%%%%%%%#%%%%%%%", + "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", + "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", + "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", + "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%" ] - attributes: [ - // 8×8 attribute grid, one byte per 32×32-px metatile group. - // Each byte packs 4 identical 2-bit quadrants selecting a - // sub-palette (0..3): - // 0x00 = sub-palette 0 (sky / clouds) - // 0x55 = sub-palette 1 (bricks / Q-blocks) - // 0xAA = sub-palette 2 (grass / hills / dirt) - // - // Row mapping: ay 0-2 (nt rows 0-11) = sky, ay 3 (nt rows - // 12-15) = brick row, ay 4 (nt rows 16-19) = more sky, ay - // 5-7 (nt rows 20-29) = ground. - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, - 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, - 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA + + palette_map: [ + "0000000000000000", // metatile rows 0-5 → sky sub-palette + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "1111111111111111", // metatile rows 6-7 → brick sub-palette + "1111111111111111", + "0000000000000000", // metatile rows 8-9 → sky again + "0000000000000000", + "2222222222222222", // metatile rows 10-15 → ground sub-palette + "2222222222222222", // (rows 15 sits off-screen but the PPU + "2222222222222222", // still reads its attribute byte, so + "2222222222222222", // we emit it explicitly to match the + "2222222222222222", // hand-packed attribute table the + "2222222222222222" // old form was using.) ] } // ── 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 { duty: 2 - pitch: [0x30, 0x38, 0x40, 0x48, 0x50, 0x58, 0x60] - volume: [12, 11, 10, 9, 7, 5, 2] + pitch: 0x30 + envelope: [12, 11, 10, 9, 7, 5, 2] } // Custom coin ding — short rising blip. sfx Ding { duty: 2 - pitch: [0x20, 0x20, 0x20, 0x20] - volume: [14, 12, 8, 3] + pitch: 0x20 + envelope: [14, 12, 8, 3] } // 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 { duty: 2 volume: 9 repeat: true + tempo: 10 notes: [ - 37, 10, // C4 - 41, 10, // E4 - 44, 10, // G4 - 49, 10, // C5 - 44, 10, // G4 - 41, 10, // E4 - 39, 15, // D4 - 0, 5, // rest - 36, 10, // B3 - 41, 10, // E4 - 44, 10, // G4 - 49, 20, // C5 - 0, 10 // rest + C4, E4, G4, C5, G4, E4, + D4 15, + rest 5, + B3, E4, G4, + C5 20, + rest 10 ] } diff --git a/examples/sprites_and_palettes.ne b/examples/sprites_and_palettes.ne index c15e6b4..54d66b1 100644 --- a/examples/sprites_and_palettes.ne +++ b/examples/sprites_and_palettes.ne @@ -9,17 +9,38 @@ game "Asset Demo" { mapper: NROM } -// Define a sprite with inline CHR tile data (16 bytes = one 8x8 tile) -// This is a simple arrow pointing right +// Define a sprite with ASCII pixel art. Each character maps to a +// 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 { - chr: [0x18, 0x1C, 0xFE, 0xFF, 0xFF, 0xFE, 0x1C, 0x18, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + pixels: [ + "...##...", + "...###..", + "#######.", + "########", + "########", + "#######.", + "...###..", + "...##..." + ] } -// Define a sprite with a heart shape +// Heart — a full-colour heart in palette-index 3 (the brightest +// shade, `@`). sprite Heart { - chr: [0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00, - 0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00] + pixels: [ + ".@@..@@.", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + ".@@@@@@.", + "..@@@@..", + "...@@...", + "........" + ] } var px: u8 = 128 diff --git a/scripts/gen_platformer_tiles.rs b/scripts/gen_platformer_tiles.rs index 04ba26b..9df6e02 100644 --- a/scripts/gen_platformer_tiles.rs +++ b/scripts/gen_platformer_tiles.rs @@ -3,11 +3,11 @@ //! //! Run with `cargo run --bin gen_platformer_tiles`. The output is //! intended to be pasted into `platformer.ne` under the -//! `sprite Tileset { chr: [...] }` and -//! `background Level { tiles: [...] }` blocks. Keeping the source of -//! truth here (instead of hand-maintained hex in the `.ne` file) -//! makes the tile art editable as ASCII and ensures the CHR bytes -//! and the nametable stay in sync with named tile indices. +//! `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. //! //! Tiles are defined as 8×8 ASCII art where each character selects //! one of 4 sub-palette slots: @@ -15,8 +15,10 @@ //! 'a' = colour 1 //! 'b' = colour 2 //! '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. struct Tile { @@ -242,34 +244,32 @@ const BUSH: u8 = 13; const QBLOCK: u8 = 14; const SKY: u8 = 15; -/// Encode one 8×8 tile into its 16-byte NES CHR representation -/// (two 8-byte bitplanes: low bit then high bit). -fn tile_to_chr(art: &str) -> [u8; 16] { +/// 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) { let rows: Vec<&str> = art.lines().filter(|l| !l.is_empty()).collect(); - assert_eq!(rows.len(), 8, "expected 8 rows, got {}", rows.len()); - let mut chr = [0u8; 16]; + assert_eq!( + rows.len(), + 8, + "tile '{name}': expected 8 rows, got {}", + rows.len() + ); for (y, row) in rows.iter().enumerate() { - assert_eq!(row.len(), 8, "expected 8 cols in row {y}: {row:?}"); - let (mut plane0, mut plane1) = (0u8, 0u8); - for (x, ch) in row.chars().enumerate() { - let idx: u8 = match ch { - '.' => 0, - 'a' => 1, - 'b' => 2, - 'c' => 3, - other => panic!("invalid tile char {other:?}"), - }; - if idx & 1 != 0 { - plane0 |= 0x80 >> x; - } - if idx & 2 != 0 { - plane1 |= 0x80 >> x; - } + 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}'" + ); } - chr[y] = plane0; - chr[y + 8] = plane1; } - chr } /// Build a 32×30 nametable for a static Mario-ish level vista. @@ -367,62 +367,112 @@ fn build_attributes() -> [u8; 64] { 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() { - // ── CHR ── - let mut chr_all: Vec = Vec::new(); - println!("// ── CHR tiles (paste into sprite Tileset {{ chr: [...] }}) ──"); + // ── 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: ["); for (i, tile) in TILES.iter().enumerate() { + validate_tile_art(tile.name, tile.art); let tile_idx = i + 1; - let chr = tile_to_chr(tile.art); - chr_all.extend_from_slice(&chr); - println!(" // tile {tile_idx}: {}", tile.name); - print!("{}", emit_hex_block(&chr, 16, " ")); + println!(" // tile {tile_idx}: {}", tile.name); + for row in tile.art.lines().filter(|l| !l.is_empty()) { + println!(" \"{row}\","); + } } - println!( - "// total tiles: {}, total CHR bytes: {}\n", - TILES.len(), - chr_all.len() - ); + println!(" ]\n"); - // ── 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(); assert_eq!(nt.len(), 960); - println!("// ── Nametable (paste into background Level {{ tiles: [...] }}) ──"); - print!("{}", emit_byte_block(&nt, 16, " ")); + 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"); - // ── 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(); - assert_eq!(attr.len(), 64); - println!("\n// ── Attributes (paste into background Level {{ attributes: [...] }}) ──"); - print!("{}", emit_byte_block(&attr, 16, " ")); + 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}"), + } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index e4869c6..5c7da13 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -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, 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 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