diff --git a/README.md b/README.md index 869e281..1efb5f3 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ start Main | [`sprites_and_palettes.ne`](examples/sprites_and_palettes.ne) | Inline CHR data, scroll, type casting | | [`mmc1_banked.ne`](examples/mmc1_banked.ne) | MMC1 mapper, bank declarations, multiply | | [`palette_and_background.ne`](examples/palette_and_background.ne) | Palette and background declarations, reset-time load, vblank-safe `set_palette` / `load_background` swaps | +| [`friendly_assets.ne`](examples/friendly_assets.ne) | **Pleasant asset syntax** — named NES colours, grouped `bg0..sp3` palettes with `universal:`, ASCII pixel-art sprites, `legend { } + map:` tilemaps, `palette_map:` attribute grids, scalar sfx `pitch:`, note-name music with `tempo:` | | [`structs_enums_for.ne`](examples/structs_enums_for.ne) | Structs, enums, `for` loops, struct literals | | [`inline_asm_demo.ne`](examples/inline_asm_demo.ne) | Inline asm with `{var}` substitution, `poke`/`peek` | | [`audio_demo.ne`](examples/audio_demo.ne) | Audio subsystem: user `sfx`/`music` blocks, builtin effects, `play`/`start_music`/`stop_music` | diff --git a/docs/language-guide.md b/docs/language-guide.md index 59c665d..20038ca 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -675,6 +675,12 @@ update_physics(player_x, player_y) ### Sprite Declarations +Sprites can be authored in two ways. Pick whichever maps best to how +your art starts out. + +**Raw CHR bytes.** Supply 16 bytes of 2-bitplane CHR per tile — the +form every NES toolchain consumes: + ``` sprite Player { chr: @chr("assets/player.png") @@ -683,41 +689,116 @@ sprite Player { sprite Coin { chr: @binary("assets/coin.bin") } -``` -### Palette Declarations - -``` -palette MainPalette { - colors: [0x0F, 0x01, 0x11, 0x21, - 0x0F, 0x06, 0x16, 0x26, - 0x0F, 0x09, 0x19, 0x29, - 0x0F, 0x0B, 0x1B, 0x2B, - 0x0F, 0x01, 0x11, 0x21, - 0x0F, 0x16, 0x27, 0x30, - 0x0F, 0x14, 0x24, 0x34, - 0x0F, 0x0B, 0x1B, 0x2B] +sprite Heart { + chr: [0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00, + 0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00] } ``` -Each byte is an NES master-palette index (`$00-$3F`). The 32 bytes -map directly to PPU palette RAM `$3F00-$3F1F` in the canonical NES -layout: +**ASCII pixel art.** One string per 8-pixel row, one character per +pixel. Far easier to hand-author, and the compiler does the 2-bitplane +encoding for you: -| Offset | Contents | -|-----------------|-------------------------------------| -| `$00`-`$03` | Background sub-palette 0 (4 colours) | -| `$04`-`$07` | Background sub-palette 1 | -| `$08`-`$0B` | Background sub-palette 2 | -| `$0C`-`$0F` | Background sub-palette 3 | -| `$10`-`$13` | Sprite sub-palette 0 | -| `$14`-`$17` | Sprite sub-palette 1 | -| `$18`-`$1B` | Sprite sub-palette 2 | -| `$1C`-`$1F` | Sprite sub-palette 3 | +``` +sprite Arrow { + pixels: [ + "...##...", + "...###..", + "########", + "########", + "########", + "########", + "...###..", + "...##..." + ] +} +``` -The first byte of each sub-palette is typically `$0F` (black); it -serves as the shared background colour. Lists shorter than 32 bytes -are zero-padded; lists longer than 32 are a compile error. +Characters map to 2-bit palette indices: + +| Char(s) | Index | Meaning | +|-------------|-------|--------------------------| +| `.` ` ` `0` | 0 | transparent / background | +| `#` `1` | 1 | sub-palette colour 1 | +| `%` `2` | 2 | sub-palette colour 2 | +| `@` `3` | 3 | sub-palette colour 3 | + +Both dimensions must be multiples of 8. Multi-tile sprites (16×8, +8×16, 16×16, …) are split into 8×8 tiles in row-major reading order +so consecutive tile indices match what your eye reads. + +### Palette Declarations + +Palettes can be authored in two styles. Both produce the same 32-byte +PPU palette blob (background + sprite, in the canonical +`$3F00-$3F1F` layout) — pick whichever reads best. + +**Flat form.** The raw 32-byte list, matching how PPU palette RAM is +laid out. Every entry can be a byte literal *or* a named NES colour: + +``` +palette MainPalette { + colors: [ + black, dk_blue, blue, sky_blue, // bg sub-palette 0 + black, dk_red, red, peach, // bg sub-palette 1 + black, dk_green, green, mint, // bg sub-palette 2 + black, dk_gray, lt_gray, white, // bg sub-palette 3 + black, dk_blue, blue, sky_blue, // sp sub-palette 0 + black, dk_red, red, peach, // sp sub-palette 1 + black, dk_green, green, mint, // sp sub-palette 2 + black, dk_gray, lt_gray, white // sp sub-palette 3 + ] +} +``` + +**Grouped form.** Declare each sub-palette by name and supply a shared +`universal:` colour. The compiler auto-fills every sub-palette's +first byte with the universal, which fixes the notorious +`$3F10 / $3F14 / $3F18 / $3F1C` mirror trap: when a program writes +all 32 bytes sequentially, the last four "sprite sub-palette 0" +bytes would otherwise overwrite the shared background colour. + +``` +palette Sunset { + universal: black + bg0: [dk_blue, blue, sky_blue] + bg1: [dk_red, red, peach] + bg2: [dk_olive, olive, cream] + bg3: [dk_gray, lt_gray, white] + sp0: [dk_blue, blue, sky_blue] + sp1: [dk_red, red, peach] + sp2: [dk_green, green, mint] + sp3: [dk_gray, lt_gray, white] +} +``` + +Each `bgN` / `spN` field takes 3 colours (the universal is +prepended); giving 4 colours instead overrides the universal for +that slot only. Omitted slots default to `[universal, 0, 0, 0]`. + +**Named colours.** Friendlier than hex bytes, and the names are the +same ones you'd find on a NES palette poster. Names are +case-insensitive, and `dark_red` / `dk_red` / `dark-red` are all +synonyms. + +| Group | Names | +|------------|-----------------------------------------------------------------| +| Grayscale | `black`, `dk_gray`, `gray`, `lt_gray`, `white`, `off_white` | +| Blues | `dk_blue`, `blue`, `sky_blue`, `pale_blue`, `indigo`, `royal_blue`, `periwinkle`, `ice_blue` | +| Purples | `dk_purple`, `purple` (`violet`), `lavender`, `pale_purple`, `dk_magenta`, `magenta`, `pink`, `pale_pink` | +| Pinks | `maroon`, `rose`, `hot_pink`, `pale_rose` | +| Reds | `dk_red`, `red`, `lt_red`, `peach` | +| Oranges | `brown`, `dk_orange`, `orange`, `tan` | +| Yellows | `dk_olive`, `olive`, `yellow`, `cream` | +| Greens | `dk_green`, `green`, `lime`, `pale_green`, `forest`, `bright_green`, `neon_green`, `mint` | +| Teals | `dk_teal`, `teal`, `aqua`, `pale_teal` | +| Cyans | `dk_cyan`, `cyan`, `lt_cyan`, `pale_cyan` | + +`black` maps to `$0F`, the canonical "one true black" slot the +hardware guarantees to render as `(0, 0, 0)` on every TV. If a +colour name you want isn't listed, reach for a hex byte literal — +the palette helper resolves every NES master-palette index `$00-$3F`. The *first* `palette` declared in a program is loaded into VRAM at reset time, before rendering is enabled, so the title screen boots @@ -727,6 +808,12 @@ which queues the write for the next vblank. ### Background Declarations +Like palettes and sprites, backgrounds can be authored two ways. + +**Raw byte form.** A flat `tiles:` list (up to 960 bytes, row-major) +and an optional `attributes:` list (up to 64 bytes). Best if you've +already generated the nametable with an external tool. + ``` background TitleScreen { tiles: [0x00, 0x01, 0x01, 0x00, /* ... up to 960 bytes ... */] @@ -734,15 +821,53 @@ background TitleScreen { } ``` -A background is a 32×30 nametable. `tiles` is the nametable itself -— 960 bytes of CHR tile indices, one per 8×8 cell, in row-major -order (left-to-right, top-to-bottom). `attributes` is the 8×8 -attribute table (64 bytes) that controls which sub-palette each -16×16 metatile uses. +**Tilemap form.** A `legend { }` block names single characters, a +`map:` list-of-strings paints the nametable one row at a time, and +an optional `palette_map:` grid of digit characters packs the 64-byte +attribute table automatically: -Both lists are zero-padded up to their fixed sizes; `attributes` -may be omitted entirely, in which case every cell uses background -sub-palette 0. +``` +background StageOne { + legend { + ".": 0 // empty / sky + "#": 1 // brick + "X": 2 // coin + } + map: [ + "................................", + "................................", + "......##........##..............", + "....##..##....##..##............", + "..##......##.##.....##..........", + "##..........###.......##........" + ] + palette_map: [ + "0000000000000000", // 16 cells wide; one entry per 16×16 metatile + "0000000000000000", + "0000111111110000", + "0000111111110000", + "2222222222222222" + // ... up to 15 rows total + ] +} +``` + +Rules: +- `map:` strings must be ≤ 32 characters; shorter rows are + right-padded with tile 0. No more than 30 rows. +- 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). 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:`). The *first* `background` declared is loaded into nametable 0 at reset time and background rendering is enabled automatically. @@ -779,45 +904,86 @@ Each statement looks up the name in the program's user declarations first, then ### SFX Declarations -An `sfx` block is a frame-accurate envelope for pulse 1. `pitch` latches the pulse period on trigger; `volume` runs one entry per frame, so the envelope length controls the effect duration. +An `sfx` block is a frame-accurate envelope for pulse 1. The v1 +audio driver latches the pulse period *once* on trigger (it never +updates `$4002/$4003` mid-effect), so a scalar pitch is the natural +way to write one. `volume` / `envelope` runs one byte per frame, so +the envelope length controls the effect duration: ``` sfx Pickup { - duty: 2 // 0-3, 2 = 50% square (default) - pitch: [0x50, 0x50, 0x50, 0x50, 0x50] // period for each frame - volume: [15, 12, 9, 6, 3] // 0-15, one per frame + duty: 2 // 0-3, 2 = 50% square (default) + pitch: 0x50 // latched period byte + envelope: [15, 12, 9, 6, 3] // 0-15, one entry per frame } ``` +Both spellings are interchangeable: + +- `pitch: 0x50` — single byte, latched once on trigger. +- `pitch: [0x50, 0x50, ...]` — per-frame array, still accepted for + backwards compatibility; the analyzer requires its length to + match `volume`. +- `envelope: [...]` and `volume: [...]` — aliases for the same + field. Use whichever reads better in context. + Rules: -- `pitch` and `volume` must have the same length (the frame count). -- `volume` values are 0-15 (4-bit pulse volume). +- `envelope` / `volume` values are 0-15 (4-bit pulse volume). - `duty` is 0-3 and defaults to 2. - Maximum 120 frames (2 seconds at 60 fps). ### Music Declarations -A `music` block is a flat list of `(pitch, duration)` note pairs played on pulse 2. Pitch 0 is a rest; pitches 1-60 are indices into the builtin 60-note period table (C1 through B5, with middle C at index 37). Duration is in frames (so at 60 fps, `30` is half a second). +A `music` block is a list of `(pitch, duration)` pairs played on +pulse 2. Two authoring styles are available; the parser picks +between them based on whether `tempo:` is set. + +**Note-name form** — set `tempo:` to the default frames-per-note and +write each note as a name (C4, Eb4, Fs4, …, rest) with an optional +per-note duration override: ``` music Theme { - duty: 2 // 0-3 (default 2) - volume: 10 // 0-15 (default 10) - repeat: true // loop when track ends (default true) + duty: 2 // 0-3 (default 2) + volume: 10 // 0-15 (default 10) + repeat: true // loop when track ends (default true) + tempo: 20 // default frames per note + notes: [ + C4, E4, G4, C5, // each note lasts 20 frames + G4 40, // held twice as long + rest 10, // short rest + E4, C4 + ] +} +``` + +**Raw-pair form** — leave `tempo:` unset and write a flat list of +`pitch, duration, pitch, duration, ...` integer pairs: + +``` +music Theme { + duty: 2 + volume: 10 notes: [ 37, 20, // C4 for 20 frames 41, 20, // E4 44, 20, // G4 49, 20, // C5 - 0, 10, // rest for 10 frames + 0, 10 // rest for 10 frames ] } ``` +Note names cover C1..B5 (60 entries in the builtin period table, +middle C at index 37). Accidentals use `s` for sharp and `b` for +flat (e.g. `Cs4` = C#4 = `Db4`) because `#` / `♭` aren't valid +identifier characters. `rest` (or the alias `_`) is pitch 0. + Rules: -- `notes` must contain an even number of bytes (pitch + duration pairs). +- Raw-pair form must contain an even number of entries. - Pitches are 0 (rest) or 1-60 (period table index). - Duration must be ≥ 1 frame. +- `tempo` must be ≥ 1 frame (only present in note-name form). - Maximum 256 notes per track. ### Builtin Names diff --git a/examples/README.md b/examples/README.md index 9237dee..27adc2a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,6 +27,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `sprites_and_palettes.ne` | sprites, scroll, cast | Inline CHR data, PPU scroll writes, type casting | | `mmc1_banked.ne` | MMC1, banks, multiply | Banked mapper with software multiply | | `palette_and_background.ne` | palette, background, set_palette, load_background | Reset-time initial load plus vblank-safe runtime swaps | +| `friendly_assets.ne` | named colours, grouped palette, pixel art, tilemap+legend, palette_map, scalar sfx pitch, note-name music | Exercises every "friendlier" asset syntax at once — the `palette` uses `bg0..sp3` + a shared `universal:`, the sprite is authored as ASCII pixel art, the background uses a `legend { ... } + map:` tilemap with a `palette_map:` for attributes, the sfx uses a scalar `pitch:` + `envelope:` alias, and the music uses note names (`C4, E4 40, rest 10`) with a `tempo:` default. | | `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, enemies, coin pickups, user-declared SFX + music, and a Title → Playing state machine with an autopilot so the headless harness still hits interesting gameplay at frame 180. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. | ## Emulator Controls 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/friendly_assets.ne b/examples/friendly_assets.ne new file mode 100644 index 0000000..0455d9e --- /dev/null +++ b/examples/friendly_assets.ne @@ -0,0 +1,204 @@ +// Friendly Assets Demo — showcases NEScript's pleasant-to-author +// asset syntax. Every one of the "raw byte" art forms (palettes, +// CHR tiles, nametables, attribute tables, sfx envelopes, music +// note indices) has a friendlier alternative so you don't have to +// reach for a hex editor to make your game look and sound good. +// +// This demo uses every one of them at once: +// +// 1. Named NES colours (`black`, `sky_blue`, `dk_red`, …) inside +// a grouped `palette` declaration with per-slot fields and a +// shared `universal:` colour — auto-fixes the $3F10 mirror. +// 2. A `sprite` declared with ASCII pixel art rather than 16 +// bytes of 2-bitplane CHR. +// 3. A `background` laid out with a `legend` + `map:` tilemap +// and a `palette_map:` grid that auto-packs the 64-byte +// attribute table. +// 4. An `sfx` with a scalar `pitch:` (matching the v1 driver's +// latch-once behaviour) and the friendlier `envelope:` alias. +// 5. A `music` track written with note names (`C4, E4 40, rest 10`) +// plus a default `tempo:` so the common case stays concise. +// +// Build: cargo run --release -- build examples/friendly_assets.ne +// Output: examples/friendly_assets.nes + +game "Friendly Assets" { + mapper: NROM + mirroring: horizontal +} + +// ── Palette ──────────────────────────────────────────────── +// +// Grouped form: one `universal:` field feeds every sub-palette's +// shared index-0 byte (fixing the PPU mirror trap where the last +// four bytes of the 32-byte blob would otherwise clobber the +// background universal colour). Each `bgN` / `spN` field only +// needs three colours — `universal` supplies the fourth. +// +// Every colour here is a named constant; see docs/language-guide.md +// for the full list. Hex byte literals (`0x0F`, `0x21`, …) still +// work if you prefer them. + +palette Sunset { + universal: black // shared background colour + bg0: [dk_blue, blue, sky_blue] // horizon + bg1: [dk_red, red, peach] // clouds + bg2: [dk_olive, olive, cream] // ground highlights + bg3: [dk_gray, lt_gray, white] // rocks + sp0: [dk_blue, blue, sky_blue] // player body uses bg0 tones + sp1: [dk_red, red, peach] // enemies + sp2: [dk_green, green, mint] // pickups + sp3: [dk_gray, lt_gray, white] // UI / text +} + +// ── Sprite ───────────────────────────────────────────────── +// +// ASCII pixel art. Characters map to 2-bit palette indices: +// +// `.` or ` ` → 0 (transparent) +// `#` or `1` → 1 (darker shade) +// `%` or `2` → 2 (mid shade) +// `@` or `3` → 3 (highlight) +// +// 8x8 for a plain tile; multiples of 8 in either dimension for +// multi-tile sprites (emitted in row-major reading order). + +sprite Star { + pixels: [ + "...@@...", + "..@##@..", + ".@####@.", + "@######@", + ".@####@.", + "..@##@..", + "...@@...", + "........" + ] +} + +// ── Background ───────────────────────────────────────────── +// +// `legend { ... }` names each tile index with a single character; +// `map:` is the 32×30 nametable authored directly as one string +// per row. Short rows are right-padded with tile 0; fewer than 30 +// rows pads the bottom with tile 0 as well. +// +// `palette_map:` is a 16×15 grid of sub-palette digits (`0`-`3`) +// where each cell covers one 16×16 metatile. The parser packs it +// into the awkward 8×8 attribute table automatically — no more +// hand-computing `(br<<6)|(bl<<4)|(tr<<2)|tl` by eye. + +background Horizon { + legend { + ".": 0 // sky (built-in smiley tile as a stand-in) + "S": 0 // star placeholder — same tile + } + // Sparse map: every cell in the first three rows is tile 0. + // This is enough to exercise the tile + attribute pipeline + // without depending on sprite CHR we haven't declared. + map: [ + "................................", + "................................", + "................................" + ] + // Paint the top two metatile rows (rows 0-1) with sub-palette 1 + // so the sky uses the warm cloud colours. The next 13 rows use + // sub-palette 0 (cool blues). + palette_map: [ + "1111111111111111", + "1111111111111111", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ] +} + +// ── SFX ──────────────────────────────────────────────────── +// +// Scalar `pitch:` + `envelope:` alias. The v1 audio driver only +// reads the first `pitch` byte (it latches the pulse period on +// trigger and never updates it) so a per-frame array was always +// redundant — a single byte makes the intent obvious. + +sfx Chime { + duty: 2 + pitch: 0x40 // latched period byte + envelope: [15, 14, 13, 12, 10, 8, 6, 4, 2, 1] +} + +// ── Music ────────────────────────────────────────────────── +// +// `tempo:` sets the default frames-per-note; individual notes can +// override it by trailing the name with a frame count. Note names +// are C1..B5 with `Cs4`/`Db4` style accidentals (`#` and `♭` are +// not valid identifier characters so sharp/flat use a letter). + +music Waltz { + duty: 2 + volume: 10 + repeat: true + tempo: 20 + notes: [ + C4, E4, G4, C5, // rising C major chord + G4 40, // held chord tone + rest 10, // brief pause + E4, C4, // descent + B4, D5, Fs5, B5, // up a fifth with a sharp + A4 30, // landing note + rest 20 // bar break + ] +} + +// ── Game state ───────────────────────────────────────────── + +var px: u8 = 120 +var py: u8 = 112 +var tick: u8 = 0 +var music_on: bool = false + +on frame { + tick += 1 + + // Let the d-pad nudge the star around the screen. + if button.right { px += 1 } + if button.left { px -= 1 } + if button.down { py += 1 } + if button.up { py -= 1 } + + // A / B ping the sfx so the envelope is audible under Mesen. + if button.a { play Chime } + if button.b { play Chime } + + // Auto-start the waltz once on the first frame so the jsnes + // golden-capture run (which doesn't simulate input) still + // hits the music driver's start path. + if tick == 10 { + if music_on { + // Already playing — nothing to do. + } else { + start_music Waltz + music_on = true + } + } + + // Keep a baseline sfx chirp so the audio golden is non-silent + // even when the music is between notes. + if tick == 120 { + tick = 0 + play Chime + } + + draw Star at: (px, py) +} + +start Main diff --git a/examples/friendly_assets.nes b/examples/friendly_assets.nes new file mode 100644 index 0000000..33b199c Binary files /dev/null and b/examples/friendly_assets.nes differ 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/assets/audio.rs b/src/assets/audio.rs index 6eda238..5fda181 100644 --- a/src/assets/audio.rs +++ b/src/assets/audio.rs @@ -97,6 +97,82 @@ fn sanitize_label(name: &str) -> String { .collect() } +/// Resolve a note name like `C4` / `Cs4` / `Db4` / `rest` into the +/// period-table index understood by the runtime music driver. +/// +/// Returns: +/// - `Some(0)` for `rest` (silence) +/// - `Some(1..=60)` for a C1..B5 note +/// - `None` for anything else +/// +/// The period table used by `runtime::gen_period_table` is laid out +/// one octave per 12 entries starting at C1 = index 1, so: +/// +/// | Octave | First index | Last index | +/// |--------|-------------|------------| +/// | 1 | 1 (C1) | 12 (B1) | +/// | 2 | 13 (C2) | 24 (B2) | +/// | 3 | 25 (C3) | 36 (B3) | +/// | 4 | 37 (C4) | 48 (B4) | +/// | 5 | 49 (C5) | 60 (B5) | +/// +/// Middle C is `C4` = 37. +/// +/// Accidentals are written as `Cs4` (C sharp) or `Db4` (D flat); the +/// `#` and flat characters aren't valid `NEScript` identifiers, so +/// the two-letter prefix is the portable alternative. Names are +/// case-insensitive and equivalent enharmonic pairs +/// (e.g. `Cs4` / `Db4`) both resolve to the same index. +#[must_use] +pub fn note_name_to_index(name: &str) -> Option { + let lower = name.to_ascii_lowercase(); + if lower == "rest" || lower == "_" { + return Some(0); + } + // The shortest valid note name is 2 chars ("c1"), the longest is + // 3 chars ("cs5"). Anything else can't be a note. + let bytes = lower.as_bytes(); + if bytes.len() < 2 || bytes.len() > 3 { + return None; + } + // Last char must be the octave digit. + let octave = match bytes[bytes.len() - 1] { + b'1' => 0u8, + b'2' => 1, + b'3' => 2, + b'4' => 3, + b'5' => 4, + _ => return None, + }; + // Step index within the octave: C=0, C#=1, D=2, D#=3, E=4, + // F=5, F#=6, G=7, G#=8, A=9, A#=10, B=11. + let step: u8 = match (bytes[0], bytes.get(1).copied()) { + (b'c', Some(b's')) | (b'd', Some(b'b')) => 1, + (b'c', _) => 0, + (b'd', Some(b's')) | (b'e', Some(b'b')) => 3, + (b'd', _) => 2, + (b'e', _) => 4, + (b'f', Some(b's')) | (b'g', Some(b'b')) => 6, + (b'f', _) => 5, + (b'g', Some(b's')) | (b'a', Some(b'b')) => 8, + (b'g', _) => 7, + (b'a', Some(b's')) | (b'b', Some(b'b')) => 10, + (b'a', _) => 9, + (b'b', _) => 11, + _ => return None, + }; + // If byte[1] is present it must have been consumed as an + // accidental OR be the octave digit. Validate the accidental path + // actually consumed byte[1]. + if bytes.len() == 3 { + let acc = bytes[1]; + if acc != b's' && acc != b'b' { + return None; + } + } + Some(octave * 12 + step + 1) +} + /// Per-frame max (user-declared sfx length cap). /// /// The driver walks envelope bytes one per NMI — a 60-frame sfx @@ -765,6 +841,63 @@ mod tests { assert!(!is_builtin_music("also_made_up")); } + #[test] + fn note_name_middle_c_is_index_37() { + // The period-table's middle-C slot — every other note is + // anchored relative to this, so regressions here silently + // transpose every song in the program. + assert_eq!(note_name_to_index("C4"), Some(37)); + assert_eq!(note_name_to_index("c4"), Some(37)); + } + + #[test] + fn note_name_rest_maps_to_zero() { + assert_eq!(note_name_to_index("rest"), Some(0)); + assert_eq!(note_name_to_index("REST"), Some(0)); + assert_eq!(note_name_to_index("_"), Some(0)); + } + + #[test] + fn note_name_octave_range() { + assert_eq!(note_name_to_index("C1"), Some(1)); + assert_eq!(note_name_to_index("B1"), Some(12)); + assert_eq!(note_name_to_index("C2"), Some(13)); + assert_eq!(note_name_to_index("C5"), Some(49)); + assert_eq!(note_name_to_index("B5"), Some(60)); + } + + #[test] + fn note_name_enharmonic_equivalence() { + // C# == Db, D# == Eb, etc. The music driver only has one slot + // per pitch, so enharmonic spellings must collapse. + assert_eq!(note_name_to_index("Cs4"), note_name_to_index("Db4")); + assert_eq!(note_name_to_index("Ds4"), note_name_to_index("Eb4")); + assert_eq!(note_name_to_index("Fs4"), note_name_to_index("Gb4")); + assert_eq!(note_name_to_index("Gs4"), note_name_to_index("Ab4")); + assert_eq!(note_name_to_index("As4"), note_name_to_index("Bb4")); + } + + #[test] + fn note_name_sharp_flat_indices() { + // C# sits between C and D in the period table. + let c4 = note_name_to_index("C4").unwrap(); + let cs4 = note_name_to_index("Cs4").unwrap(); + let d4 = note_name_to_index("D4").unwrap(); + assert_eq!(cs4, c4 + 1); + assert_eq!(d4, c4 + 2); + } + + #[test] + fn note_name_invalid_names_return_none() { + assert_eq!(note_name_to_index(""), None); + assert_eq!(note_name_to_index("H4"), None); // H isn't a note + assert_eq!(note_name_to_index("C0"), None); // below period table + assert_eq!(note_name_to_index("C6"), None); // above period table + assert_eq!(note_name_to_index("C#4"), None); // `#` not allowed in idents + assert_eq!(note_name_to_index("Csx4"), None); // bogus accidental + assert_eq!(note_name_to_index("CoolName"), None); + } + #[test] fn walk_for_play_finds_nested_references() { // `play` inside an `if` inside a `while` should still be diff --git a/src/assets/mod.rs b/src/assets/mod.rs index ab9910d..9e89883 100644 --- a/src/assets/mod.rs +++ b/src/assets/mod.rs @@ -6,11 +6,11 @@ pub mod resolve; mod tests; pub use audio::{ - builtin_music, builtin_sfx, is_builtin_music, is_builtin_sfx, resolve_music, resolve_sfx, - MusicData, SfxData, + builtin_music, builtin_sfx, is_builtin_music, is_builtin_sfx, note_name_to_index, + resolve_music, resolve_sfx, MusicData, SfxData, }; pub use chr::png_to_chr; -pub use palette::{nearest_nes_color, NES_COLORS}; +pub use palette::{color_name_to_index, nearest_nes_color, NES_COLORS}; pub use resolve::{ resolve_backgrounds, resolve_palettes, resolve_sprites, BackgroundData, PaletteData, }; diff --git a/src/assets/palette.rs b/src/assets/palette.rs index b421ffc..e49464c 100644 --- a/src/assets/palette.rs +++ b/src/assets/palette.rs @@ -86,3 +86,170 @@ pub fn nearest_nes_color(r: u8, g: u8, b: u8) -> u8 { } best_idx } + +/// Resolve a human-readable color name to its NES master palette index +/// (`$00-$3F`). Returns `None` for unknown names. +/// +/// The name list is a curated subset of the 64-entry master palette, +/// chosen for how distinct each colour is in practice (rows 3 and +/// above often produce near-duplicates so we skip most of them). Names +/// are case-insensitive; underscores and hyphens are interchangeable. +/// +/// Every NES programmer eventually memorizes that `$0F` is "the one +/// true black" — the one hardware palette index guaranteed to render +/// as `(0,0,0)` on every TV — so `black` maps to `$0F` rather than +/// `$1D`/`$2E`/`$3E`/`$3F` (which are also black but are commonly used +/// as "emphasis blanking" slots in advanced code). +#[must_use] +pub fn color_name_to_index(name: &str) -> Option { + // Normalize: lowercase + collapse `-` to `_`. + let normalized: String = name + .chars() + .map(|c| { + if c == '-' { + '_' + } else { + c.to_ascii_lowercase() + } + }) + .collect(); + Some(match normalized.as_str() { + // ── Grayscale ── + // $0F is the canonical "true black" slot — preferred over + // $1D/$2E/$3E/$3F which are mirrors used for emphasis blanking. + "black" => 0x0F, + "dk_gray" | "dark_gray" | "darkgray" => 0x00, + "gray" | "grey" | "mid_gray" => 0x10, + "lt_gray" | "light_gray" | "lightgray" => 0x3D, + "white" => 0x30, + "off_white" | "pale_white" => 0x20, + + // ── Blues ── + "dk_blue" | "dark_blue" | "navy" => 0x01, + "blue" => 0x11, + "lt_blue" | "light_blue" | "sky_blue" | "sky" => 0x21, + "pale_blue" => 0x31, + "indigo" => 0x02, + "royal_blue" | "royal" => 0x12, + "periwinkle" => 0x22, + "ice_blue" | "ice" => 0x32, + + // ── Purples / magentas ── + "dk_purple" | "dark_purple" => 0x03, + "purple" | "violet" => 0x13, + "lt_purple" | "light_purple" | "lavender" => 0x23, + "pale_purple" => 0x33, + "dk_magenta" | "dark_magenta" => 0x04, + "magenta" => 0x14, + "lt_magenta" | "light_magenta" | "pink" => 0x24, + "pale_pink" => 0x34, + + // ── Pinks / roses ── + "dk_rose" | "maroon" => 0x05, + "rose" => 0x15, + "hot_pink" => 0x25, + "pale_rose" => 0x35, + + // ── Reds ── + "dk_red" | "dark_red" => 0x06, + "red" => 0x16, + "lt_red" | "light_red" => 0x26, + "peach" => 0x36, + + // ── Oranges / browns ── + "brown" => 0x07, + "dk_orange" | "dark_orange" => 0x17, + "orange" => 0x27, + "tan" => 0x37, + + // ── Yellows ── + "dk_olive" | "dark_olive" => 0x08, + "olive" => 0x18, + "yellow" => 0x28, + "lt_yellow" | "light_yellow" | "cream" => 0x38, + + // ── Greens ── + "dk_green" | "dark_green" => 0x09, + "green" => 0x19, + "lt_green" | "light_green" | "lime" => 0x29, + "pale_green" => 0x39, + "forest" | "forest_green" => 0x0A, + "bright_green" => 0x1A, + "neon_green" => 0x2A, + "mint" => 0x3A, + + // ── Teals / cyans ── + "dk_teal" | "dark_teal" => 0x0B, + "teal" => 0x1B, + "lt_teal" | "light_teal" | "aqua" => 0x2B, + "pale_teal" | "pale_aqua" => 0x3B, + "dk_cyan" | "dark_cyan" => 0x0C, + "cyan" => 0x1C, + "lt_cyan" | "light_cyan" => 0x2C, + "pale_cyan" => 0x3C, + + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn black_is_true_black() { + // $0F is the one-true-black slot. Every NES programmer relies + // on this being the default bg colour, so it must stay pinned. + assert_eq!(color_name_to_index("black"), Some(0x0F)); + } + + #[test] + fn names_are_case_insensitive() { + assert_eq!(color_name_to_index("RED"), Some(0x16)); + assert_eq!(color_name_to_index("Red"), Some(0x16)); + assert_eq!(color_name_to_index("red"), Some(0x16)); + } + + #[test] + fn aliases_resolve_identically() { + // Grey / gray and light_gray / lt_gray should all map to the + // same hardware slot so users don't have to remember which + // spelling we picked. + assert_eq!(color_name_to_index("gray"), color_name_to_index("grey")); + assert_eq!( + color_name_to_index("lt_gray"), + color_name_to_index("light_gray") + ); + assert_eq!( + color_name_to_index("dk_blue"), + color_name_to_index("dark_blue") + ); + } + + #[test] + fn hyphen_separator_also_works() { + // `dark-red` and `dark_red` should mean the same thing so a + // user copying CSS-style names doesn't get surprising errors. + assert_eq!( + color_name_to_index("dark-red"), + color_name_to_index("dark_red") + ); + } + + #[test] + fn unknown_name_returns_none() { + assert_eq!(color_name_to_index("mauve"), None); + assert_eq!(color_name_to_index(""), None); + } + + #[test] + fn every_returned_index_is_in_master_palette_range() { + for name in [ + "black", "white", "red", "green", "blue", "yellow", "orange", "purple", "cyan", "teal", + "brown", "olive", "tan", "mint", "peach", "indigo", + ] { + let idx = color_name_to_index(name).expect(name); + assert!(idx <= 0x3F, "{name} -> {idx:#04x} out of range"); + } + } +} diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 8fca3db..5c7da13 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -635,21 +635,84 @@ impl Parser { // ── Sprite / Palette / Background declarations ── + /// Sprite declarations accept one of two shapes: + /// + /// **Raw CHR bytes** — the original form, matching how CHR is + /// stored on the cart: + /// ```text + /// sprite Heart { + /// chr: [0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00, + /// 0x66, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00] + /// } + /// // or from an external file: + /// sprite Player { chr: @chr("assets/player.png") } + /// ``` + /// + /// **Pixel-art strings** — each string is one row of pixels, each + /// character is one pixel's 2-bit palette index. Way easier to + /// hand-author than hex: + /// ```text + /// sprite Arrow { + /// pixels: [ + /// "...##...", + /// "...###..", + /// "########", + /// "########", + /// "########", + /// "########", + /// "...###..", + /// "...##..." + /// ] + /// } + /// ``` + /// + /// Characters map to palette indices as follows: + /// + /// | Char(s) | Index | Meaning | + /// |----------|-------|-------------------------------| + /// | `.` ` ` | 0 | transparent / background | + /// | `#` `1` | 1 | sub-palette colour 1 | + /// | `%` `2` | 2 | sub-palette colour 2 | + /// | `@` `3` | 3 | sub-palette colour 3 | + /// + /// Rows must all be the same length, and both dimensions must be + /// multiples of 8 (the NES tile size). Multi-tile sprites (16×8, + /// 8×16, 16×16, …) are split into 8×8 tiles in row-major reading + /// order so consecutive tile indices line up with what `draw` + /// expects. fn parse_sprite_decl(&mut self) -> Result { let start = self.current_span(); self.expect(&TokenKind::KwSprite)?; let (name, _) = self.expect_ident()?; self.expect(&TokenKind::LBrace)?; - let mut chr_source = None; + let mut chr_source: Option = None; while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { - let (key, _) = self.expect_ident()?; + let (key, key_span) = self.expect_ident()?; self.expect(&TokenKind::Colon)?; match key.as_str() { "chr" => { + if chr_source.is_some() { + return Err(Diagnostic::error( + ErrorCode::E0201, + "sprite 'chr' and 'pixels' are mutually exclusive", + key_span, + )); + } chr_source = Some(self.parse_asset_source()?); } + "pixels" => { + if chr_source.is_some() { + return Err(Diagnostic::error( + ErrorCode::E0201, + "sprite 'pixels' and 'chr' are mutually exclusive", + key_span, + )); + } + let bytes = self.parse_pixel_art(&name, key_span)?; + chr_source = Some(AssetSource::Inline(bytes)); + } _ => { return Err(Diagnostic::error( ErrorCode::E0201, @@ -662,7 +725,11 @@ impl Parser { self.expect(&TokenKind::RBrace)?; let chr_source = chr_source.ok_or_else(|| { - Diagnostic::error(ErrorCode::E0201, "sprite requires 'chr' property", start) + Diagnostic::error( + ErrorCode::E0201, + "sprite requires 'chr' or 'pixels' property", + start, + ) })?; Ok(SpriteDecl { @@ -672,24 +739,274 @@ impl Parser { }) } + /// Parse a pixel-art block of the form + /// `[ "row0", "row1", ... ]` and lower it to CHR bytes. + /// + /// Each character is one pixel; see [`Self::parse_sprite_decl`] + /// for the full character → palette-index mapping. All rows must + /// be the same length and both dimensions must be multiples of 8. + /// Multi-tile sprites are split into 8×8 tiles in row-major order + /// so `tile_index, tile_index+1, ...` traverses the tiles in the + /// same order your eye reads them. + fn parse_pixel_art( + &mut self, + sprite_name: &str, + key_span: Span, + ) -> Result, Diagnostic> { + self.expect(&TokenKind::LBracket)?; + let mut rows: Vec = Vec::new(); + while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof { + match self.peek().clone() { + TokenKind::StringLiteral(s) => { + self.advance(); + rows.push(s); + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "expected pixel row string in sprite '{sprite_name}', found '{}'", + self.peek() + ), + self.current_span(), + )); + } + } + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBracket)?; + + if rows.is_empty() { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("sprite '{sprite_name}' 'pixels' list is empty"), + key_span, + )); + } + let width = rows[0].chars().count(); + for (i, row) in rows.iter().enumerate() { + let len = row.chars().count(); + if len != width { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "sprite '{sprite_name}' pixel row {i} has {len} cells but \ + row 0 has {width}; every row must be the same width" + ), + key_span, + )); + } + } + let height = rows.len(); + if width == 0 || !width.is_multiple_of(8) { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "sprite '{sprite_name}' width is {width}; must be a non-zero multiple of 8" + ), + key_span, + )); + } + if !height.is_multiple_of(8) { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("sprite '{sprite_name}' height is {height}; must be a multiple of 8"), + key_span, + )); + } + + // Convert rows to a 2-bit palette-index grid. + let mut grid: Vec> = Vec::with_capacity(height); + 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' | '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'/'a' for 1, '%'/'2'/'b' for 2, \ + '@'/'3'/'c' for 3" + ), + key_span, + )); + } + }; + line.push(val); + } + grid.push(line); + } + + // Encode into CHR tiles. Each 8×8 block becomes 16 bytes: the + // first 8 are bit 0 of each pixel (bitplane 0), the next 8 are + // bit 1 (bitplane 1). Tiles are emitted in row-major reading + // order so consecutive tile indices match what you'd expect. + let tiles_x = width / 8; + let tiles_y = height / 8; + let mut out = Vec::with_capacity(tiles_x * tiles_y * 16); + for ty in 0..tiles_y { + for tx in 0..tiles_x { + let mut plane0 = [0u8; 8]; + let mut plane1 = [0u8; 8]; + for row_in_tile in 0..8 { + let y = ty * 8 + row_in_tile; + let mut p0 = 0u8; + let mut p1 = 0u8; + for col_in_tile in 0..8 { + let x = tx * 8 + col_in_tile; + let v = grid[y][x]; + let shift = 7 - col_in_tile; + if v & 0b01 != 0 { + p0 |= 1 << shift; + } + if v & 0b10 != 0 { + p1 |= 1 << shift; + } + } + plane0[row_in_tile] = p0; + plane1[row_in_tile] = p1; + } + out.extend_from_slice(&plane0); + out.extend_from_slice(&plane1); + } + } + Ok(out) + } + // ── Palette / Background declarations ── /// `palette Name { colors: [c0, c1, ..., c31] }` — declares a /// 32-byte PPU palette. Colors shorter than 32 are zero-padded /// by the analyzer; colors longer than 32 are rejected. + /// Palette declarations accept one of two shapes. They cannot be mixed: + /// + /// **Flat form** — a single 32-byte list matching the PPU layout: + /// ```text + /// palette Main { + /// colors: [0x0F, 0x01, 0x11, 0x21, /* bg0..bg3, sp0..sp3 */ ...] + /// } + /// ``` + /// + /// **Grouped form** — a per-slot declaration with an optional shared + /// universal colour: + /// ```text + /// palette Main { + /// universal: black // optional, defaults to black ($0F) + /// bg0: [dk_blue, blue, sky_blue] // 3 colours — universal prepended + /// bg1: [dk_purple, purple, lavender] + /// bg2: [dk_red, red, peach] + /// bg3: [dk_green, green, mint] + /// sp0: [dk_blue, blue, sky_blue] + /// sp1: [dk_red, red, peach] + /// sp2: [dk_green, green, mint] + /// sp3: [dk_gray, lt_gray, white] + /// } + /// ``` + /// + /// Grouped form auto-fixes the `$3F10 / $3F14 / $3F18 / $3F1C` PPU + /// mirror issue — every sub-palette's index-0 byte is forced to the + /// same universal value, so sequential writes to + /// `$3F00-$3F1F` never accidentally clobber the shared background + /// colour. + /// + /// Any colour value (in either form) may be a raw byte literal + /// (`0x0F`) or a named NES colour (`black`, `dk_blue`, `sky_blue`, …). + /// See [`crate::assets::color_name_to_index`] for the full name list. fn parse_palette_decl(&mut self) -> Result { let start = self.current_span(); self.expect(&TokenKind::KwPalette)?; let (name, _) = self.expect_ident()?; self.expect(&TokenKind::LBrace)?; - let mut colors: Option> = None; + // Flat-form output. + let mut flat_colors: Option> = None; + // Grouped-form scratch: 8 sub-palette slots, each up to 4 + // colours. `None` means "user didn't declare this slot". + let mut slots: [Option>; 8] = Default::default(); + let mut universal: Option = None; + let mut grouped_first_key: Option = None; + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { let (key, key_span) = self.expect_ident()?; self.expect(&TokenKind::Colon)?; match key.as_str() { "colors" => { - colors = Some(self.parse_byte_array("colors")?); + if grouped_first_key.is_some() { + return Err(Diagnostic::error( + ErrorCode::E0201, + "palette cannot mix 'colors' with grouped sub-palette \ + fields (bg0..sp3 / universal); pick one form", + key_span, + )); + } + flat_colors = Some(self.parse_color_array("colors")?); + } + "universal" => { + if flat_colors.is_some() { + return Err(Diagnostic::error( + ErrorCode::E0201, + "palette cannot mix 'colors' with 'universal'; pick one form", + key_span, + )); + } + grouped_first_key.get_or_insert(key_span); + universal = Some(self.parse_color_value("universal")?); + } + slot_name @ ("bg0" | "bg1" | "bg2" | "bg3" | "sp0" | "sp1" | "sp2" | "sp3") => { + if flat_colors.is_some() { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "palette cannot mix 'colors' with '{slot_name}'; pick one form" + ), + key_span, + )); + } + grouped_first_key.get_or_insert(key_span); + let slot_idx = match slot_name { + "bg0" => 0, + "bg1" => 1, + "bg2" => 2, + "bg3" => 3, + "sp0" => 4, + "sp1" => 5, + "sp2" => 6, + "sp3" => 7, + _ => unreachable!(), + }; + if slots[slot_idx].is_some() { + return Err(Diagnostic::error( + ErrorCode::E0501, + format!("duplicate sub-palette '{slot_name}'"), + key_span, + )); + } + let entries = self.parse_color_array(slot_name)?; + if entries.len() > 4 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "sub-palette '{slot_name}' has {} colours; maximum is 4 \ + (3 + optional leading universal override)", + entries.len() + ), + key_span, + )); + } + slots[slot_idx] = Some(entries); } _ => { return Err(Diagnostic::error( @@ -705,13 +1022,41 @@ impl Parser { } self.expect(&TokenKind::RBrace)?; - let colors = colors.ok_or_else(|| { - Diagnostic::error( + let colors = if let Some(flat) = flat_colors { + flat + } else if grouped_first_key.is_some() { + // Assemble the 32-byte blob from the grouped slots. + // `$0F` is the canonical "one true black" universal that + // every NES cart uses when nothing else is specified. + let uni = universal.unwrap_or(0x0F); + let mut out = vec![0u8; 32]; + for (slot_idx, slot) in slots.iter().enumerate() { + let base = slot_idx * 4; + out[base] = uni; + if let Some(entries) = slot { + if entries.len() == 4 { + // Explicit override of the universal byte + // for this slot only. + out[base] = entries[0]; + out[base + 1] = entries[1]; + out[base + 2] = entries[2]; + out[base + 3] = entries[3]; + } else { + for (i, c) in entries.iter().enumerate() { + out[base + 1 + i] = *c; + } + } + } + } + out + } else { + return Err(Diagnostic::error( ErrorCode::E0201, - "palette requires 'colors' property", + "palette requires either 'colors' or at least one sub-palette field \ + (bg0..bg3 / sp0..sp3)", start, - ) - })?; + )); + }; Ok(PaletteDecl { name, @@ -720,28 +1065,235 @@ impl Parser { }) } - /// `background Name { tiles: [...], attributes: [...] }` — the - /// tiles array is the 32×30 nametable (up to 960 bytes); the - /// attributes array is the 8×8 attribute table (up to 64 bytes). - /// Both shorter and omitted arrays are zero-padded by the - /// analyzer. Longer arrays are rejected. + /// Parse a single NES colour value: either a `u8` integer literal or + /// an identifier resolved via + /// [`crate::assets::color_name_to_index`]. Used by palette + /// declarations so either raw hex bytes (`0x0F`) or friendly names + /// (`black`, `sky_blue`) can appear anywhere a colour is expected. + fn parse_color_value(&mut self, prop: &str) -> Result { + match self.peek().clone() { + TokenKind::IntLiteral(v) => { + self.advance(); + if v > 0xFF { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("'{prop}' colour value {v} doesn't fit in a u8"), + self.current_span(), + )); + } + Ok(v as u8) + } + TokenKind::Ident(name) => { + let span = self.current_span(); + self.advance(); + crate::assets::color_name_to_index(&name).ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + format!( + "unknown NES colour name '{name}' in '{prop}'; \ + use a byte literal (0x00-0x3F) or a name like \ + 'black' / 'blue' / 'sky_blue'" + ), + span, + ) + }) + } + _ => Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "expected colour value for '{prop}' (byte literal or name), found '{}'", + self.peek() + ), + self.current_span(), + )), + } + } + + /// Parse `[color, color, ...]` where each element is either a byte + /// literal or a named NES colour. This is the "friendly" version of + /// [`Self::parse_byte_array`] used everywhere a palette byte is + /// expected. + fn parse_color_array(&mut self, prop: &str) -> Result, Diagnostic> { + self.expect(&TokenKind::LBracket)?; + let mut out = Vec::new(); + while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof { + out.push(self.parse_color_value(prop)?); + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBracket)?; + Ok(out) + } + + /// Background declarations pick one of two authoring styles for + /// the 32×30 nametable: + /// + /// **Raw bytes** — a flat list matching the PPU nametable layout, + /// 960 tile indices in row-major order, optionally followed by a + /// 64-byte attribute table: + /// ```text + /// background StageOne { + /// tiles: [0, 1, 2, 3, ...] + /// attributes: [0xFF, 0x55, ...] + /// } + /// ``` + /// + /// **Tilemap + legend** — a legend mapping single characters to + /// CHR tile indices, followed by a `map:` list-of-strings where + /// each character is one cell of the nametable. Rows shorter than + /// 32 cells are right-padded with tile 0; extra rows past row 30 + /// are an error. Optional `palette_map:` is a 16×15 grid of + /// sub-palette indices `0`-`3`, one digit per 16×16 metatile, + /// auto-packed into the 64-byte attribute table (no more hand- + /// packing 2-bit pairs): + /// ```text + /// background StageOne { + /// legend { + /// '.': 0 // empty + /// '#': 1 // brick + /// 'X': 2 // coin + /// } + /// map: [ + /// "................................", + /// "................................", + /// "......##........##..............", + /// "....##..##....##..##............", + /// // ... up to 30 rows, 32 cells each + /// ] + /// palette_map: [ + /// "0000000011110000", // 16 metatile cols + /// "0000000011110000", + /// // ... up to 15 metatile rows + /// ] + /// } + /// ``` fn parse_background_decl(&mut self) -> Result { let start = self.current_span(); self.expect(&TokenKind::KwBackground)?; let (name, _) = self.expect_ident()?; self.expect(&TokenKind::LBrace)?; - let mut tiles: Option> = None; - let mut attributes: Option> = None; + // Raw-form scratch. + let mut tiles_raw: Option> = None; + let mut attributes_raw: Option> = None; + // Tilemap-form scratch. + let mut legend: Option> = None; + let mut legend_span: Option = None; + let mut map_rows: Option<(Vec, Span)> = None; + let mut palette_rows: Option<(Vec, Span)> = None; + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { + // `legend { ... }` uses a brace block rather than a + // `key: value` pair, so detect it specially. + if matches!(self.peek(), TokenKind::Ident(n) if n == "legend") + && self.peek_at_offset(1) == Some(&TokenKind::LBrace) + { + let span = self.current_span(); + self.advance(); // `legend` + self.advance(); // `{` + let mut map: std::collections::HashMap = std::collections::HashMap::new(); + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { + let key_span = self.current_span(); + let ch = match self.peek().clone() { + TokenKind::StringLiteral(s) => { + self.advance(); + let mut chars = s.chars(); + let c = chars.next().ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + "legend key must be a single character", + key_span, + ) + })?; + if chars.next().is_some() { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("legend key '{s}' has more than one character"), + key_span, + )); + } + c + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "expected string literal for legend key, found '{}'", + self.peek() + ), + key_span, + )); + } + }; + self.expect(&TokenKind::Colon)?; + let tile = self.parse_u8_literal("legend value")?; + if map.insert(ch, tile).is_some() { + return Err(Diagnostic::error( + ErrorCode::E0501, + format!("duplicate legend entry '{ch}'"), + key_span, + )); + } + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBrace)?; + if legend.is_some() { + return Err(Diagnostic::error( + ErrorCode::E0501, + "duplicate 'legend' block", + span, + )); + } + legend = Some(map); + legend_span = Some(span); + continue; + } + let (key, key_span) = self.expect_ident()?; self.expect(&TokenKind::Colon)?; match key.as_str() { "tiles" => { - tiles = Some(self.parse_byte_array("tiles")?); + if tiles_raw.is_some() || map_rows.is_some() { + return Err(Diagnostic::error( + ErrorCode::E0501, + "duplicate tile data in background declaration", + key_span, + )); + } + tiles_raw = Some(self.parse_byte_array("tiles")?); } "attributes" => { - attributes = Some(self.parse_byte_array("attributes")?); + if attributes_raw.is_some() || palette_rows.is_some() { + return Err(Diagnostic::error( + ErrorCode::E0501, + "duplicate attribute data in background declaration", + key_span, + )); + } + attributes_raw = Some(self.parse_byte_array("attributes")?); + } + "map" => { + if map_rows.is_some() || tiles_raw.is_some() { + return Err(Diagnostic::error( + ErrorCode::E0501, + "duplicate tile data in background declaration", + key_span, + )); + } + map_rows = Some((self.parse_string_array("map")?, key_span)); + } + "palette_map" => { + if palette_rows.is_some() || attributes_raw.is_some() { + return Err(Diagnostic::error( + ErrorCode::E0501, + "duplicate attribute data in background declaration", + key_span, + )); + } + palette_rows = Some((self.parse_string_array("palette_map")?, key_span)); } _ => { return Err(Diagnostic::error( @@ -757,36 +1309,107 @@ impl Parser { } self.expect(&TokenKind::RBrace)?; - let tiles = tiles.ok_or_else(|| { - Diagnostic::error( + // Resolve the tile source. + let tiles = if let Some(flat) = tiles_raw { + flat + } else if let Some((rows, span)) = map_rows { + let legend = legend.as_ref().ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + "background 'map' requires a 'legend { ... }' block", + legend_span.unwrap_or(span), + ) + })?; + tilemap_to_bytes(&name, &rows, legend, span)? + } else { + return Err(Diagnostic::error( ErrorCode::E0201, - "background requires 'tiles' property", + "background requires a 'tiles' array or a 'map' + 'legend'", start, - ) - })?; + )); + }; + + // Resolve the attribute source. + let attributes = if let Some(flat) = attributes_raw { + flat + } else if let Some((rows, span)) = palette_rows { + palette_map_to_attrs(&name, &rows, span)? + } else { + Vec::new() + }; Ok(BackgroundDecl { name, tiles, - attributes: attributes.unwrap_or_default(), + attributes, span: Span::new(start.file_id, start.start, self.current_span().end), }) } + /// Parse a `[string, string, ...]` list. Used by background + /// `map:` and `palette_map:` where each string is one row of the + /// grid and characters inside the string are cells. + fn parse_string_array(&mut self, prop: &str) -> Result, Diagnostic> { + self.expect(&TokenKind::LBracket)?; + let mut out = Vec::new(); + while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof { + match self.peek().clone() { + TokenKind::StringLiteral(s) => { + self.advance(); + out.push(s); + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("expected string row in '{prop}', found '{}'", self.peek()), + self.current_span(), + )); + } + } + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBracket)?; + Ok(out) + } + // ── SFX / Music declarations ── - /// `sfx Name { duty: N, pitch: [..], volume: [..] }`. Pitch and - /// volume arrays must be the same length — each index is one - /// frame of the envelope. Duty is optional, default 2 (50%). + /// `sfx Name { duty: N, pitch: ..., volume: [..] }`. + /// + /// The v1 audio driver latches the pulse-1 period **once** on + /// trigger, so there's no point giving a per-frame pitch array — + /// only `pitch[0]` is ever read. That's now reflected in the + /// syntax: + /// + /// - `pitch: 0x50` — a single byte, latched at trigger time (the + /// natural form for the current driver). + /// - `pitch: [0x50, 0x50, ...]` — still accepted for + /// backwards-compatibility with existing sources; the analyzer + /// requires the array length to match `volume`. + /// + /// `envelope:` is a friendlier alias for `volume:` — both mean the + /// same thing (the per-frame volume ramp that shapes the sound). fn parse_sfx_decl(&mut self) -> Result { + // Scalar pitches expand to a per-frame array once we know + // the envelope length, so we track both possibilities in + // this local enum while parsing. Declared here so clippy's + // `items_after_statements` stays happy. + enum PitchSrc { + Scalar(u8), + Array(Vec), + } + let start = self.current_span(); self.expect(&TokenKind::KwSfx)?; let (name, _) = self.expect_ident()?; self.expect(&TokenKind::LBrace)?; let mut duty: u8 = 2; - let mut pitch: Option> = None; + let mut pitch_src: Option = None; let mut volume: Option> = None; + let mut volume_key: &'static str = "volume"; while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { let (key, key_span) = self.expect_ident()?; @@ -803,15 +1426,36 @@ impl Parser { } } "pitch" => { - pitch = Some(self.parse_byte_array("pitch")?); + // Either a scalar (new form) or a [bytes] array + // (legacy form). Branch on the leading token. + if *self.peek() == TokenKind::LBracket { + pitch_src = Some(PitchSrc::Array(self.parse_byte_array("pitch")?)); + } else { + pitch_src = Some(PitchSrc::Scalar(self.parse_u8_literal("pitch")?)); + } } - "volume" => { - let vals = self.parse_byte_array("volume")?; + "volume" | "envelope" => { + if volume.is_some() { + return Err(Diagnostic::error( + ErrorCode::E0201, + "sfx 'volume' / 'envelope' are aliases — pick one", + key_span, + )); + } + // Remember which spelling the user chose so + // diagnostics below match their source. + let prop = if key.as_str() == "envelope" { + "envelope" + } else { + "volume" + }; + volume_key = prop; + let vals = self.parse_byte_array(prop)?; for v in &vals { if *v > 15 { return Err(Diagnostic::error( ErrorCode::E0201, - format!("sfx 'volume' entries must be 0-15, got {v}"), + format!("sfx '{prop}' entries must be 0-15, got {v}"), key_span, )); } @@ -832,33 +1476,53 @@ impl Parser { } self.expect(&TokenKind::RBrace)?; - let pitch = pitch.ok_or_else(|| { + let pitch_src = pitch_src.ok_or_else(|| { Diagnostic::error(ErrorCode::E0201, "sfx requires 'pitch' property", start) })?; let volume = volume.ok_or_else(|| { - Diagnostic::error(ErrorCode::E0201, "sfx requires 'volume' property", start) + Diagnostic::error( + ErrorCode::E0201, + format!("sfx requires '{volume_key}' property (or its alias)"), + start, + ) })?; - if pitch.is_empty() { + if volume.is_empty() { return Err(Diagnostic::error( ErrorCode::E0201, - "sfx 'pitch' array must have at least one frame", - start, - )); - } - if pitch.len() != volume.len() { - return Err(Diagnostic::error( - ErrorCode::E0201, - format!( - "sfx 'pitch' and 'volume' arrays must have the same length \ - (pitch has {}, volume has {})", - pitch.len(), - volume.len() - ), + format!("sfx '{volume_key}' array must have at least one frame"), start, )); } + // Normalize to the legacy per-frame pitch array the rest of + // the compiler already consumes. Scalar pitches just repeat. + let pitch = match pitch_src { + PitchSrc::Scalar(v) => vec![v; volume.len()], + PitchSrc::Array(v) => { + if v.is_empty() { + return Err(Diagnostic::error( + ErrorCode::E0201, + "sfx 'pitch' array must have at least one frame", + start, + )); + } + if v.len() != volume.len() { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "sfx 'pitch' and '{volume_key}' arrays must have the \ + same length (pitch has {}, {volume_key} has {})", + v.len(), + volume.len() + ), + start, + )); + } + v + } + }; + Ok(SfxDecl { name, duty, @@ -868,10 +1532,44 @@ impl Parser { }) } - /// `music Name { duty: N, volume: N, repeat: true|false, notes: [..] }`. - /// Notes are encoded as a flat list: `[pitch1, dur1, pitch2, dur2, ...]`. - /// Pitch 0 is a rest; nonzero pitches are indices into the builtin - /// period table (1 = C1, 60 = B5). Duration is in frames (1-255). + /// `music Name { duty, volume, repeat, tempo, notes }`. + /// + /// Notes can be authored in two styles. The parser picks the style + /// based on whether a `tempo:` field is present: + /// + /// **Raw form** (`tempo:` absent) — a flat list of `pitch, duration` + /// integer pairs. Every entry is a `u8` literal and pairs are + /// separated by commas: + /// ```text + /// music Theme { + /// notes: [ + /// 37, 20, // C4 for 20 frames + /// 41, 20, // E4 + /// 44, 20, // G4 + /// 0, 10, // rest for 10 frames + /// ] + /// } + /// ``` + /// + /// **Note-name form** (`tempo:` present) — each entry is a note + /// name (`C4`, `Cs4`, `Db4`, …, `B5`) or `rest`, with an optional + /// per-note duration override. Entries are separated by commas, + /// and `tempo:` sets the default duration when none is given: + /// ```text + /// music Theme { + /// tempo: 20 // default frames per note + /// notes: [ + /// C4, E4, G4, C5, // all use tempo (20 frames each) + /// G4 40, // this one is held twice as long + /// rest 10, // short rest + /// E4, C4 + /// ] + /// } + /// ``` + /// + /// Integer literals still work inside the note-name form too — + /// useful for raw period-table indices when you don't feel like + /// spelling out a name. fn parse_music_decl(&mut self) -> Result { let start = self.current_span(); self.expect(&TokenKind::KwMusic)?; @@ -881,7 +1579,12 @@ impl Parser { let mut duty: u8 = 2; let mut volume: u8 = 10; let mut loops: bool = true; - let mut notes: Option> = None; + let mut tempo: Option = None; + // Defer note parsing until we've seen all the simple scalar + // fields, so `tempo:` can be declared after `notes:` if the + // user prefers that order — we stash the token position to + // rewind to. + let mut notes_pos: Option<(usize, Span)> = None; while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { let (key, key_span) = self.expect_ident()?; @@ -907,6 +1610,17 @@ impl Parser { )); } } + "tempo" => { + let t = self.parse_u8_literal("tempo")?; + if t == 0 { + return Err(Diagnostic::error( + ErrorCode::E0201, + "music 'tempo' must be >= 1 (frames per note)", + key_span, + )); + } + tempo = Some(t); + } "repeat" => match self.peek().clone() { TokenKind::BoolLiteral(b) => { self.advance(); @@ -921,36 +1635,18 @@ impl Parser { } }, "notes" => { - let flat = self.parse_byte_array("notes")?; - if flat.len() % 2 != 0 { + if notes_pos.is_some() { return Err(Diagnostic::error( - ErrorCode::E0201, - "music 'notes' must have an even number of entries \ - (pitch, duration, pitch, duration, ...)", + ErrorCode::E0501, + "duplicate 'notes' in music declaration", key_span, )); } - let mut n = Vec::with_capacity(flat.len() / 2); - for pair in flat.chunks(2) { - let pitch = pair[0]; - let duration = pair[1]; - if duration == 0 { - return Err(Diagnostic::error( - ErrorCode::E0201, - "music note duration must be >= 1", - key_span, - )); - } - if pitch > 60 { - return Err(Diagnostic::error( - ErrorCode::E0201, - format!("music note pitch must be 0 (rest) or 1-60, got {pitch}"), - key_span, - )); - } - n.push(MusicNote { pitch, duration }); - } - notes = Some(n); + notes_pos = Some((self.pos, key_span)); + // Skip past the notes list without parsing it yet + // — we need to know whether `tempo:` is set before + // picking between raw-pair form and note-name form. + self.skip_balanced_brackets()?; } _ => { return Err(Diagnostic::error( @@ -966,14 +1662,27 @@ impl Parser { } self.expect(&TokenKind::RBrace)?; - let notes = notes.ok_or_else(|| { + let (notes_token_pos, notes_span) = notes_pos.ok_or_else(|| { Diagnostic::error(ErrorCode::E0201, "music requires 'notes' property", start) })?; + // Rewind to the `[` of the notes list and parse it with the + // right format chosen by whether `tempo:` was set above. + let saved_pos = self.pos; + self.pos = notes_token_pos; + let notes = if let Some(default_dur) = tempo { + self.parse_named_notes(default_dur, notes_span)? + } else { + self.parse_flat_note_pairs(notes_span)? + }; + // Restore the cursor past the closing brace so the outer + // program loop keeps marching through the source. + self.pos = saved_pos; + if notes.is_empty() { return Err(Diagnostic::error( ErrorCode::E0201, - "music 'notes' must contain at least one (pitch, duration) pair", + "music 'notes' must contain at least one note", start, )); } @@ -988,6 +1697,160 @@ impl Parser { }) } + /// Fast-forward `self.pos` past a matched `[` … `]` pair, used by + /// music parsing so the notes list can be re-scanned later once + /// `tempo:` presence is known. + fn skip_balanced_brackets(&mut self) -> Result<(), Diagnostic> { + self.expect(&TokenKind::LBracket)?; + let mut depth = 1i32; + while depth > 0 { + match self.peek().clone() { + TokenKind::LBracket => { + depth += 1; + self.advance(); + } + TokenKind::RBracket => { + depth -= 1; + self.advance(); + } + TokenKind::Eof => { + return Err(Diagnostic::error( + ErrorCode::E0201, + "unterminated '[' in music notes", + self.current_span(), + )); + } + _ => { + self.advance(); + } + } + } + Ok(()) + } + + /// Parse a legacy-form `notes: [pitch, duration, pitch, duration, ...]` + /// flat pair list. Used when the music block has no `tempo:` field. + fn parse_flat_note_pairs(&mut self, key_span: Span) -> Result, Diagnostic> { + let flat = self.parse_byte_array("notes")?; + if flat.len() % 2 != 0 { + return Err(Diagnostic::error( + ErrorCode::E0201, + "music 'notes' must have an even number of entries \ + (pitch, duration, pitch, duration, ...) when 'tempo' is not set", + key_span, + )); + } + let mut out = Vec::with_capacity(flat.len() / 2); + for pair in flat.chunks(2) { + let pitch = pair[0]; + let duration = pair[1]; + if duration == 0 { + return Err(Diagnostic::error( + ErrorCode::E0201, + "music note duration must be >= 1", + key_span, + )); + } + if pitch > 60 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("music note pitch must be 0 (rest) or 1-60, got {pitch}"), + key_span, + )); + } + out.push(MusicNote { pitch, duration }); + } + Ok(out) + } + + /// Parse a note-name form note list with entries like + /// `C4`, `Cs4 40`, `rest`, `rest 10`. Each entry is a pitch + /// (note-name identifier, `rest`, or integer literal) with an + /// optional inline duration; missing durations default to `tempo`. + /// Entries are comma-separated; trailing commas are allowed. + fn parse_named_notes( + &mut self, + default_dur: u8, + key_span: Span, + ) -> Result, Diagnostic> { + self.expect(&TokenKind::LBracket)?; + let mut out = Vec::new(); + while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof { + // ── Parse the pitch ── + let pitch = match self.peek().clone() { + TokenKind::IntLiteral(v) => { + self.advance(); + if v > 60 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("music note pitch must be 0 (rest) or 1-60, got {v}"), + key_span, + )); + } + v as u8 + } + TokenKind::Ident(name) => { + let span = self.current_span(); + self.advance(); + crate::assets::note_name_to_index(&name).ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + format!( + "unknown note name '{name}'; use a name like C4/Cs4/Db4, \ + 'rest', or a numeric pitch index 0-60" + ), + span, + ) + })? + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "expected note name or pitch index in music notes, found '{}'", + self.peek() + ), + self.current_span(), + )); + } + }; + + // ── Optional duration override ── + // + // A bare integer literal before the next comma is the + // duration for this note. Otherwise use the block's tempo. + let duration = if let TokenKind::IntLiteral(v) = self.peek().clone() { + self.advance(); + if v == 0 { + return Err(Diagnostic::error( + ErrorCode::E0201, + "music note duration must be >= 1", + key_span, + )); + } + if v > 255 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("music note duration {v} doesn't fit in a u8 (max 255)"), + key_span, + )); + } + v as u8 + } else { + default_dur + }; + + out.push(MusicNote { pitch, duration }); + + // Entries are comma-separated; trailing commas are fine. + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBracket)?; + Ok(out) + } + /// Parse a `[byte, byte, ...]` array. Used by sfx/music property /// parsing — the main `parse_asset_source` also does this, but /// without the array-literal-only restriction we want here. @@ -2026,6 +2889,155 @@ impl Parser { } } +/// Convert a tilemap authored as rows of characters into the flat +/// byte array the nametable expects. Each row is up to 32 characters +/// wide; shorter rows pad with the default tile 0, longer rows are an +/// error. The full tile map is 30 rows × 32 cols = 960 bytes; fewer +/// rows are zero-padded (the analyzer does the final padding, so we +/// just emit whatever the user declared without the trailing zeros +/// here). +fn tilemap_to_bytes( + bg_name: &str, + rows: &[String], + legend: &std::collections::HashMap, + span: Span, +) -> Result, Diagnostic> { + if rows.len() > 30 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "background '{bg_name}' tilemap has {} rows; maximum is 30", + rows.len() + ), + span, + )); + } + let mut out = Vec::with_capacity(rows.len() * 32); + for (ry, row) in rows.iter().enumerate() { + let chars: Vec = row.chars().collect(); + if chars.len() > 32 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "background '{bg_name}' tilemap row {ry} has {} cells; \ + maximum is 32", + chars.len() + ), + span, + )); + } + for (rx, ch) in chars.iter().enumerate() { + let tile = legend.get(ch).copied().ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + format!( + "background '{bg_name}' tilemap cell ({rx}, {ry}) uses \ + character '{ch}' which is not in the legend" + ), + span, + ) + })?; + out.push(tile); + } + // Pad the remainder of this row with tile 0 so subsequent + // rows land at the right column in the flat array. + out.resize(out.len() + (32 - chars.len()), 0); + } + Ok(out) +} + +/// Convert a `palette_map:` grid (rows of digit characters `0`-`3`, +/// one per 16×16 metatile) into the 64-byte attribute table the PPU +/// expects. +/// +/// 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. 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> { + if rows.len() > 16 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "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, + )); + } + // Build a dense 16×16 grid of sub-palette indices (rows beyond + // declared are 0). Using 16 metatile rows keeps the packing loop + // branch-free. + let mut grid = [[0u8; 16]; 16]; + for (ry, row) in rows.iter().enumerate() { + let chars: Vec = row.chars().collect(); + if chars.len() > 16 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "background '{bg_name}' palette_map row {ry} has {} cells; \ + maximum is 16 (one per 16×16 metatile)", + chars.len() + ), + span, + )); + } + for (rx, ch) in chars.iter().enumerate() { + let idx = match ch { + '0' => 0u8, + '1' => 1, + '2' => 2, + '3' => 3, + ' ' | '.' => 0, + other => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "background '{bg_name}' palette_map cell ({rx}, {ry}) \ + has '{other}'; must be a sub-palette digit '0'-'3'" + ), + span, + )); + } + }; + grid[ry][rx] = idx; + } + } + // If the user gave exactly 15 rows, replicate row 14 into row 15 + // so the last attribute byte's bottom-half picks up the same + // sub-palette as the visible bottom of the screen. Users who + // want explicit control over the off-screen row can supply all + // 16 rows. + if rows.len() == 15 { + grid[15] = grid[14]; + } + // Pack into the 8×8 attribute table. Each attribute byte covers + // a 2×2 block of metatiles: + // bits 0-1 = top-left (grid[ay*2 ][ax*2 ]) + // bits 2-3 = top-right (grid[ay*2 ][ax*2+1]) + // bits 4-5 = bottom-left (grid[ay*2+1][ax*2 ]) + // bits 6-7 = bottom-right (grid[ay*2+1][ax*2+1]) + let mut out = vec![0u8; 64]; + for ay in 0..8 { + for ax in 0..8 { + let tl = grid[ay * 2][ax * 2] & 0b11; + let tr = grid[ay * 2][ax * 2 + 1] & 0b11; + let bl = grid[ay * 2 + 1][ax * 2] & 0b11; + let br = grid[ay * 2 + 1][ax * 2 + 1] & 0b11; + out[ay * 8 + ax] = tl | (tr << 2) | (bl << 4) | (br << 6); + } + } + Ok(out) +} + pub fn parse(source: &str) -> (Option, Vec) { let (tokens, lex_diags) = crate::lexer::lex(source); if lex_diags.iter().any(Diagnostic::is_error) { diff --git a/src/parser/tests.rs b/src/parser/tests.rs index 86352d8..9181f2d 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -1532,3 +1532,723 @@ fn parse_debug_assert() { let frame = prog.states[0].on_frame.as_ref().unwrap(); assert!(matches!(frame.statements[0], Statement::DebugAssert(..))); } + +// ── Named colour palettes ── + +#[test] +fn parse_palette_with_named_colors_in_flat_list() { + let src = r#" + game "T" { mapper: NROM } + palette Main { + colors: [ + black, dk_blue, blue, sky_blue, + black, dk_red, red, peach, + black, dk_green, green, mint, + black, dk_gray, lt_gray, white, + black, dk_blue, blue, sky_blue, + black, dk_red, red, peach, + black, dk_green, green, mint, + black, dk_gray, lt_gray, white + ] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.palettes.len(), 1); + assert_eq!(prog.palettes[0].colors.len(), 32); + // `black` must resolve to the canonical `$0F` universal slot. + assert_eq!(prog.palettes[0].colors[0], 0x0F); + // `dk_blue` must resolve to `$01`. + assert_eq!(prog.palettes[0].colors[1], 0x01); +} + +#[test] +fn parse_palette_flat_colors_still_accept_hex() { + // Backward compat: a pre-existing palette that uses raw bytes + // must keep parsing identically. + let src = r#" + game "T" { mapper: NROM } + palette Legacy { + colors: [ + 0x0F, 0x01, 0x11, 0x21, + 0x0F, 0x02, 0x12, 0x22, + 0x0F, 0x0C, 0x1C, 0x2C, + 0x0F, 0x0B, 0x1B, 0x2B, + 0x0F, 0x01, 0x11, 0x21, + 0x0F, 0x16, 0x27, 0x30, + 0x0F, 0x14, 0x24, 0x34, + 0x0F, 0x0B, 0x1B, 0x2B + ] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.palettes[0].colors[0], 0x0F); + assert_eq!(prog.palettes[0].colors[1], 0x01); + assert_eq!(prog.palettes[0].colors[6], 0x12); +} + +#[test] +fn parse_palette_with_grouped_subpalettes() { + let src = r#" + game "T" { mapper: NROM } + palette Pretty { + universal: black + bg0: [dk_blue, blue, sky_blue] + bg1: [dk_red, red, peach] + bg2: [dk_green, green, mint] + bg3: [dk_gray, lt_gray, white] + sp0: [dk_blue, blue, sky_blue] + sp1: [dk_red, red, peach] + sp2: [dk_green, green, mint] + sp3: [dk_gray, lt_gray, white] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + let colors = &prog.palettes[0].colors; + assert_eq!(colors.len(), 32); + // Every sub-palette's first byte must equal the universal + // (black = $0F). This is the whole reason grouped form exists — + // it auto-fixes the $3F10 mirror trap. + for i in 0..8 { + assert_eq!( + colors[i * 4], + 0x0F, + "sub-palette {i} universal byte should be black" + ); + } + assert_eq!(colors[1], 0x01); // dk_blue + assert_eq!(colors[2], 0x11); // blue + assert_eq!(colors[3], 0x21); // sky_blue + assert_eq!(colors[5], 0x06); // bg1 dk_red +} + +#[test] +fn parse_palette_grouped_without_universal_defaults_to_black() { + let src = r#" + game "T" { mapper: NROM } + palette Pretty { + bg0: [dk_blue, blue, sky_blue] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!( + prog.palettes[0].colors[0], 0x0F, + "default universal = black" + ); +} + +#[test] +fn parse_palette_grouped_allows_full_4_entry_slot_overriding_universal() { + // Edge case: if the user provides all 4 colours for a slot, the + // leading colour *overrides* the universal for that slot. This is + // rarely useful but matches what a by-hand `colors:` list can do. + let src = r#" + game "T" { mapper: NROM } + palette Mixed { + universal: black + bg0: [white, dk_blue, blue, sky_blue] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.palettes[0].colors[0], 0x30); // white overrides black + assert_eq!(prog.palettes[0].colors[1], 0x01); +} + +#[test] +fn parse_palette_rejects_mixing_flat_and_grouped_forms() { + let src = r#" + game "T" { mapper: NROM } + palette Broken { + colors: [0x0F, 0x01, 0x11, 0x21] + bg0: [blue, sky_blue, white] + } + on frame { wait_frame } + start Main + "#; + let diags = parse_err(src); + assert!( + diags.contains(&crate::errors::ErrorCode::E0201), + "expected type-mismatch error for mixed palette form, got {diags:?}" + ); +} + +#[test] +fn parse_palette_rejects_unknown_color_name() { + let src = r#" + game "T" { mapper: NROM } + palette Bad { + colors: [mauve, 0x01, 0x11, 0x21] + } + on frame { wait_frame } + start Main + "#; + let diags = parse_err(src); + assert!(diags.contains(&crate::errors::ErrorCode::E0201)); +} + +// ── Pixel-art sprites ── + +#[test] +fn parse_sprite_with_pixel_art() { + // A simple arrow. The lower-left `#` column is index 1 and the + // right column is `@` = index 3, so we can check both planes. + let src = r#" + game "T" { mapper: NROM } + sprite Arrow { + pixels: [ + "........", + "...##...", + "..###...", + ".####@@@", + ".####@@@", + "..###...", + "...##...", + "........" + ] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.sprites.len(), 1); + match &prog.sprites[0].chr_source { + AssetSource::Inline(bytes) => { + // 8×8 tile = 16 bytes of CHR. + assert_eq!(bytes.len(), 16); + // Row 3 (`. # # # # @ @ @`) is palette indices + // `0 1 1 1 1 3 3 3`. Bitplane 0 (bit 0 of each index) is + // `0 1 1 1 1 1 1 1` = 0b0111_1111 = 0x7F. + // Bitplane 1 (bit 1 of each index) is `0 0 0 0 0 1 1 1` + // = 0b0000_0111 = 0x07. + assert_eq!(bytes[3], 0x7F, "row 3 plane 0"); + assert_eq!(bytes[11], 0x07, "row 3 plane 1"); + } + _ => panic!("expected inline CHR bytes from pixel art"), + } +} + +#[test] +fn parse_sprite_pixel_art_multi_tile() { + // 16×8 sprite → 2 tiles = 32 bytes, emitted in reading order. + // (Escaped quotes because the pixel rows contain long `#` runs + // that collide with Rust's raw-string delimiter.) + let filled = "\"################\""; + let src = format!( + "game \"T\" {{ mapper: NROM }}\n\ + sprite Wide {{\n\ + pixels: [{filled},{filled},{filled},{filled},\ + {filled},{filled},{filled},{filled}]\n\ + }}\n\ + on frame {{ wait_frame }}\n\ + start Main\n" + ); + let src = src.as_str(); + let prog = parse_ok(src); + match &prog.sprites[0].chr_source { + AssetSource::Inline(bytes) => { + assert_eq!(bytes.len(), 32, "16x8 = 2 tiles = 32 bytes"); + // Every pixel is index 1: bitplane 0 = 0xFF, bitplane 1 = 0x00. + for tile in 0..2 { + for row in 0..8 { + assert_eq!(bytes[tile * 16 + row], 0xFF); + assert_eq!(bytes[tile * 16 + 8 + row], 0x00); + } + } + } + _ => panic!("expected inline CHR"), + } +} + +#[test] +fn parse_sprite_pixel_art_rejects_non_multiple_of_8() { + let src = "\ + game \"T\" { mapper: NROM }\n\ + sprite Bad { pixels: [\"....\", \"####\", \"####\", \"....\"] }\n\ + on frame { wait_frame }\n\ + start Main\n\ + "; + let diags = parse_err(src); + assert!(diags.contains(&crate::errors::ErrorCode::E0201)); +} + +#[test] +fn parse_sprite_pixel_art_accepts_abc_alias() { + // `.abc` is the vocabulary every NES tool uses for 2-bit pixel + // art; the parser should accept it interchangeably with `.#%@` + // and `.0123`. Two sprites written in the two forms must + // encode to bit-identical CHR bytes. + let letters = r#" + game "T" { mapper: NROM } + sprite A { + pixels: [ + "........", + "...aa...", + "..abba..", + ".abbccb.", + ".abbccb.", + "..abba..", + "...aa...", + "........" + ] + } + on frame { wait_frame } + start Main + "#; + let glyphs = r#" + game "T" { mapper: NROM } + sprite A { + pixels: [ + "........", + "...##...", + "..#%%#..", + ".#%%@@%.", + ".#%%@@%.", + "..#%%#..", + "...##...", + "........" + ] + } + on frame { wait_frame } + start Main + "#; + let p1 = parse_ok(letters); + let p2 = parse_ok(glyphs); + match (&p1.sprites[0].chr_source, &p2.sprites[0].chr_source) { + (AssetSource::Inline(a), AssetSource::Inline(b)) => assert_eq!(a, b), + _ => panic!("expected inline CHR on both sprites"), + } +} + +#[test] +fn parse_sprite_pixel_art_rejects_ragged_rows() { + let src = r#" + game "T" { mapper: NROM } + sprite Bad { + pixels: [ + "........", + "......." + ] + } + on frame { wait_frame } + start Main + "#; + let diags = parse_err(src); + assert!(diags.contains(&crate::errors::ErrorCode::E0201)); +} + +// ── SFX with scalar pitch + envelope alias ── + +#[test] +fn parse_sfx_with_scalar_pitch_and_envelope_alias() { + let src = r#" + game "T" { mapper: NROM } + sfx Pickup { + duty: 2 + pitch: 0x50 + envelope: [15, 12, 9, 6, 3] + } + on frame { play Pickup } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.sfx.len(), 1); + let s = &prog.sfx[0]; + // Scalar pitch expands to repeat for every envelope frame. + assert_eq!(s.pitch, vec![0x50; 5]); + assert_eq!(s.volume, vec![15, 12, 9, 6, 3]); +} + +#[test] +fn parse_sfx_with_legacy_pitch_array_still_works() { + let src = r#" + game "T" { mapper: NROM } + sfx Pickup { + duty: 2 + pitch: [0x50, 0x50, 0x50] + volume: [15, 10, 5] + } + on frame { play Pickup } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.sfx[0].pitch, vec![0x50, 0x50, 0x50]); + assert_eq!(prog.sfx[0].volume, vec![15, 10, 5]); +} + +#[test] +fn parse_sfx_rejects_mixing_volume_and_envelope() { + let src = r#" + game "T" { mapper: NROM } + sfx Bad { + pitch: 0x50 + volume: [10] + envelope: [10] + } + on frame { play Bad } + start Main + "#; + let diags = parse_err(src); + assert!(diags.contains(&crate::errors::ErrorCode::E0201)); +} + +// ── Music with note names + tempo ── + +#[test] +fn parse_music_with_note_names_and_tempo_default() { + let src = r#" + game "T" { mapper: NROM } + music Theme { + duty: 2 + volume: 10 + repeat: true + tempo: 20 + notes: [C4, E4, G4, C5, G4, E4] + } + on frame { start_music Theme } + start Main + "#; + let prog = parse_ok(src); + let t = &prog.music[0]; + assert_eq!(t.notes.len(), 6); + // C4 is index 37, E4 = 41, G4 = 44, C5 = 49. + assert_eq!(t.notes[0].pitch, 37); + assert_eq!(t.notes[0].duration, 20); // from tempo + assert_eq!(t.notes[1].pitch, 41); + assert_eq!(t.notes[2].pitch, 44); + assert_eq!(t.notes[3].pitch, 49); + assert_eq!(t.notes[3].duration, 20); +} + +#[test] +fn parse_music_with_per_note_duration_override() { + let src = r#" + game "T" { mapper: NROM } + music Theme { + tempo: 20 + notes: [ + C4, + E4 40, + rest 10, + G4 + ] + } + on frame { start_music Theme } + start Main + "#; + let prog = parse_ok(src); + let t = &prog.music[0]; + assert_eq!(t.notes[0].pitch, 37); + assert_eq!(t.notes[0].duration, 20); + assert_eq!(t.notes[1].pitch, 41); + assert_eq!(t.notes[1].duration, 40); // override + assert_eq!(t.notes[2].pitch, 0); // rest + assert_eq!(t.notes[2].duration, 10); + assert_eq!(t.notes[3].pitch, 44); + assert_eq!(t.notes[3].duration, 20); +} + +#[test] +fn parse_music_enharmonic_note_names() { + let src = r#" + game "T" { mapper: NROM } + music Theme { + tempo: 10 + notes: [Cs4, Db4, Ds4, Eb4] + } + on frame { start_music Theme } + start Main + "#; + let prog = parse_ok(src); + let t = &prog.music[0]; + // Cs4 == Db4 and Ds4 == Eb4. + assert_eq!(t.notes[0].pitch, t.notes[1].pitch); + assert_eq!(t.notes[2].pitch, t.notes[3].pitch); +} + +#[test] +fn parse_music_legacy_flat_pair_form_still_works() { + // No `tempo:` → legacy (pitch, duration) pair form. + let src = r#" + game "T" { mapper: NROM } + music Theme { + duty: 2 + volume: 10 + notes: [ + 37, 20, + 41, 20, + 44, 20 + ] + } + on frame { start_music Theme } + start Main + "#; + let prog = parse_ok(src); + let t = &prog.music[0]; + assert_eq!(t.notes.len(), 3); + assert_eq!(t.notes[0].pitch, 37); + assert_eq!(t.notes[0].duration, 20); +} + +#[test] +fn parse_music_rejects_unknown_note_name() { + let src = r#" + game "T" { mapper: NROM } + music Theme { + tempo: 20 + notes: [C4, Z9, G4] + } + on frame { start_music Theme } + start Main + "#; + let diags = parse_err(src); + assert!(diags.contains(&crate::errors::ErrorCode::E0201)); +} + +// ── Background tilemap + legend + palette_map ── + +#[test] +fn parse_background_with_tilemap_and_legend() { + let src = r##" + game "T" { mapper: NROM } + background StageOne { + legend { + ".": 0 + "#": 1 + "X": 2 + } + map: [ + "................................", + "................................", + "......##........##..............", + "....##..##....##..##............" + ] + } + on frame { wait_frame } + start Main + "##; + let prog = parse_ok(src); + assert_eq!(prog.backgrounds.len(), 1); + let tiles = &prog.backgrounds[0].tiles; + // Four rows × 32 cells = 128 bytes declared. + assert_eq!(tiles.len(), 128); + // Row 2 (index 2*32 = 64), columns 6 and 7 should be tile 1. + assert_eq!(tiles[2 * 32 + 6], 1); + assert_eq!(tiles[2 * 32 + 7], 1); + // Column 5 of row 2 is still the empty tile 0. + assert_eq!(tiles[2 * 32 + 5], 0); +} + +#[test] +fn parse_background_map_short_rows_pad_to_32_cells() { + let src = "\ + game \"T\" { mapper: NROM }\n\ + background StageOne {\n\ + legend { \".\": 0, \"#\": 1 }\n\ + map: [\"##\", \"#.\"]\n\ + }\n\ + on frame { wait_frame }\n\ + start Main\n\ + "; + let prog = parse_ok(src); + let tiles = &prog.backgrounds[0].tiles; + // 2 rows × 32 cols = 64 bytes. First two cells of row 0 are 1, + // rest of row 0 is 0. + assert_eq!(tiles.len(), 64); + assert_eq!(tiles[0], 1); + assert_eq!(tiles[1], 1); + assert_eq!(tiles[2], 0); + assert_eq!(tiles[32], 1); // row 1 col 0 + assert_eq!(tiles[33], 0); // row 1 col 1 +} + +#[test] +fn parse_background_map_rejects_unknown_legend_char() { + let src = r##" + game "T" { mapper: NROM } + background Bad { + legend { ".": 0, "#": 1 } + map: ["..?.."] + } + on frame { wait_frame } + start Main + "##; + let diags = parse_err(src); + assert!(diags.contains(&crate::errors::ErrorCode::E0201)); +} + +#[test] +fn parse_background_map_requires_legend() { + let src = r#" + game "T" { mapper: NROM } + background Bad { + map: ["..##.."] + } + on frame { wait_frame } + start Main + "#; + let diags = parse_err(src); + assert!(diags.contains(&crate::errors::ErrorCode::E0201)); +} + +#[test] +fn parse_background_palette_map_packs_attributes() { + let src = r#" + game "T" { mapper: NROM } + background Stage { + legend { ".": 0 } + map: ["................................"] + palette_map: [ + "0011001100110011", + "0011001100110011" + ] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + let attrs = &prog.backgrounds[0].attributes; + assert_eq!(attrs.len(), 64, "always packs to 64 attribute bytes"); + // Attribute byte [0,0] covers metatiles at: + // TL = grid[0][0] = 0 + // TR = grid[0][1] = 0 + // BL = grid[1][0] = 0 + // BR = grid[1][1] = 0 + // So byte[0] = 0. + assert_eq!(attrs[0], 0); + // Byte [0,1] covers metatiles: + // TL = grid[0][2] = 1 TR = grid[0][3] = 1 + // BL = grid[1][2] = 1 BR = grid[1][3] = 1 + // = (1) | (1 << 2) | (1 << 4) | (1 << 6) = 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] +fn parse_background_raw_tiles_and_attributes_still_work() { + // Backward compat: legacy inline byte arrays should keep parsing + // identically. + let src = r#" + game "T" { mapper: NROM } + background Legacy { + tiles: [0, 1, 2, 3] + attributes: [0xFF, 0x55] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.backgrounds[0].tiles, vec![0, 1, 2, 3]); + assert_eq!(prog.backgrounds[0].attributes, vec![0xFF, 0x55]); +} diff --git a/tests/emulator/goldens/friendly_assets.audio.hash b/tests/emulator/goldens/friendly_assets.audio.hash new file mode 100644 index 0000000..cf37b43 --- /dev/null +++ b/tests/emulator/goldens/friendly_assets.audio.hash @@ -0,0 +1 @@ +c49fe29d 132084 diff --git a/tests/emulator/goldens/friendly_assets.png b/tests/emulator/goldens/friendly_assets.png new file mode 100644 index 0000000..25c9589 Binary files /dev/null and b/tests/emulator/goldens/friendly_assets.png differ