From 48832ccb13af00034e691014dbdd13d10fcc360b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 16:09:53 +0000 Subject: [PATCH] language: pleasant asset syntax for palettes, CHR, bg, sfx, music MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds six NES-friendly authoring shortcuts so programs don't have to hand-pack hex bytes for every kind of art asset. Every new syntax is strictly additive — existing examples keep their byte-identical ROMs and goldens. * palette: ~50 named NES colours (`black`, `sky_blue`, `dk_red`, …) usable anywhere a colour byte is expected, plus a grouped-form `bg0..sp3` + `universal:` shape that auto-fills every sub- palette's first byte (fixing the `$3F10` mirror trap). * sprite: `pixels:` ASCII-art alternative to 16-byte CHR, supporting multi-tile sprites split in row-major reading order. * sfx: scalar `pitch:` matching the v1 driver's latch-once behaviour, plus `envelope:` as a friendlier alias for `volume:`. * music: `tempo:` default duration + note-name notes (`C4, Eb4, rest 10`) alongside the existing `pitch, duration` pair form. * background: `legend { '.': 0, '#': 1 }` + `map:` string rows, plus `palette_map:` grids that auto-pack the 64-byte attribute table from 16×15 sub-palette digits. A new `examples/friendly_assets.ne` exercises every shortcut at once with a matching pixel + audio golden; the other 22 golden tests still match byte-for-byte. https://claude.ai/code/session_01PzaSFj3VahDzxEYTKCESkz --- README.md | 1 + docs/language-guide.md | 259 +++- examples/README.md | 1 + examples/friendly_assets.ne | 204 +++ examples/friendly_assets.nes | Bin 0 -> 24592 bytes src/assets/audio.rs | 133 ++ src/assets/mod.rs | 6 +- src/assets/palette.rs | 167 +++ src/parser/mod.rs | 1159 +++++++++++++++-- src/parser/tests.rs | 568 ++++++++ .../goldens/friendly_assets.audio.hash | 1 + tests/emulator/goldens/friendly_assets.png | Bin 0 -> 39321 bytes 12 files changed, 2365 insertions(+), 134 deletions(-) create mode 100644 examples/friendly_assets.ne create mode 100644 examples/friendly_assets.nes create mode 100644 tests/emulator/goldens/friendly_assets.audio.hash create mode 100644 tests/emulator/goldens/friendly_assets.png 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..1195f80 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,48 @@ 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), no more than 15 rows. The + parser packs adjacent 2×2 metatile groups into 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 +899,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/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 0000000000000000000000000000000000000000..33b199cbbdc8a9c252b91cfdc572ddedeb255eda GIT binary patch literal 24592 zcmeI(L1-LR7zgk-^JaJ7%Rc_4K+`j>4=4q6>1R~VU!6q zn=*sju4&e0Ek++h|IuQmrXFuGtHm-cmTj@zmH6^*-Wm0& zcE*fy!PaIF@1zOAFkx{wp=}LQl6Xg%<>f@0iZ3(Cx8ra1IAvY>+c06gqxaFo^@=R( z!?L28tjaOT6-$1jK2yBQ65jZFv&{xmzCv64GezuPRT+~;V)n{(G!ye;dbH@gLPtU8 z>2WP7=`b==2OjFZGEdJ?(0%DpEWBx~+I5nQW{qbrXd}5(jnM>Nxu8$WoW5HYWroJ^ zWL6u}m1C;fT^Z!Ar@7U1`Zu{@<%F#&O+q)C2FW)~QmK%sG@_S|Bi-G8kB^p8RXO z)rr@S)!(cuv#xkO$aSa~@<5xBAF7e$8G7$kZYY!VX1x7+c~m3x-aT+bksK69e~^$C zM?Wo&z8YO-a+HcAU0fQ((xS-Zh&G}URc>n9Vxtt!PZ%3}s1@7sRVqGgN%AFWE}dLD zwG@VZY!H9|1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009X6*8+=|*ru~pn^i|ubGBAnP3N;(D^rJB zR^C5*basFFR{!yc>mUFD2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_< K0ucBY1^xuYCqvr+ literal 0 HcmV?d00001 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..e4869c6 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,268 @@ 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() { + let val = match ch { + '.' | ' ' | '0' => 0u8, + '#' | '1' => 1, + '%' | '2' => 2, + '@' | '3' => 3, + other => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "sprite '{sprite_name}' pixel at ({rx}, {ry}) has \ + invalid character '{other}'; use '.'/' '/'0' for \ + index 0, '#'/'1' for 1, '%'/'2' for 2, '@'/'3' for 3" + ), + 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 +1016,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 +1059,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 +1303,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 +1420,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 +1470,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 +1526,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 +1573,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 +1604,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 +1629,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 +1656,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 +1691,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 +2883,144 @@ 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. A 32×30-pixel nametable has a 16×15 metatile grid but the +/// attribute table is a fixed 8×8 = 64 bytes, so the last half-row +/// of metatiles (rows 14-15, which render below the visible area) is +/// always zero-padded. +fn palette_map_to_attrs(bg_name: &str, rows: &[String], span: Span) -> Result, Diagnostic> { + // 16 metatile cols × up to 15 metatile rows. + if rows.len() > 15 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "background '{bg_name}' palette_map has {} rows; maximum is 15 \ + (one entry per 16×16 metatile row)", + 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; + } + } + // 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..cd87010 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -1532,3 +1532,571 @@ 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_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_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 0000000000000000000000000000000000000000..25c9589df5f9c9b539256af0fd557e0d5ed6f046 GIT binary patch literal 39321 zcmeHQ2~-o;8cvEpOU0-NRJOoD0TrcK1Vzh;K!E}kEzfH4H3(F3MTA!Dp&2U5Vv3-& z1&b7dNI|V=MIYUa%3^tCuM6dQRKOCXzSqQnC(&W%{ln5)5DE&(BpJ`~p2^(F=-lP| zzwiIpOFp@AI=XW0KR~$$m4ol7n`4Wp}cm zypCQ&u&(PIVhtg>4yQMCm-bY4J-IRkwXZGnE3k~v`^@jp|uJMbG(EA@{5AiDq#&yGDtQo)-EW?i_$s(R&@Pu&`;m8q2K^})D) z(0x0V?x8Ulh`1lqNYh`s~U2(T$vfs1?63KNIS12rEV>PuLM4FW?;EEO_X!q@%T>Rn1-!VB~NUVsh z6x0c4IJ1jgm+0_Ztm|btx-BbKcP>h*(cU^a!+MY~?xLEEapn?=toyHflo-I%pJE z<8)}LNlhj0&@OcqGoi8K9Opp`nifYSL3`yS`#>XVZ(U2aEOoxZo^#<2M|}Wel!(|9 z)7m4y=6SCBvbJ)b+1@M{>+hncz5np=y_OY?cPU?ihBFaQ2cRd?EL7^Ih&VB9aKalA%t8@yV%Xq>-a|1yRl~!`;ud;> zk%p2pNNtJeaM(lSGt!V8Wegv^(GD-`TaKWafxW1PB4fC|{X>vRouGB%L7ur9y9;x5 zd$=EcI9xjrDX%Up;cvNm(5A%Z!!r%KCmJ8*>6aYf);mv}{QTXkSvx&qb%G7_%W{H~ zTs7@^I>G*@%W`&Scxc)enExQ=;G!cLbK9nzDPIzNF2l3RK$7@Nu$g^emF0TguCgum zDPvq&i;FDvGxsIz^DB2N&u`%ezbW=VT^L=^7_cN@%WSjRj{25kE@uWrC(+{JJGd#YTQupu{zk_wJLY_O98e3CF@tXPMrPOO97M56qv&t zn>aghO~B;1eKVE+HeTUaI)d4Z4<0@lX3rp)mmJZtA9|5TY-AtwxPN)=~dOfFMl zR2(>0(Vt^0Sq@fTc2;>F3|_*byR?y-=Sa^Q~r=-5w8Yw^n|B#B-^wE{cAOcT_p5LfJRoKO)3aO5fc2qc|fLKOps;HeufQfWkl1@u1KC4NmC8?f%rqhyt&9%~LNyTR= z>9izuGL#RI2e)spI;9_Yu6@RP)jF(W$@)KiaccL2E_1Bq#k^(4;!k!p33hzkS&?3G zXU-4eqxQ1b!?7|u{NZm!#2TQ;AN6G~b9?pq={^Q(+h_goRHa&M4G$aN`kXq;`fD7&hE;zr1-{LR! zk0^*PSO_k-3Ao^szy%+NY77=``9m*ecv7ukgD3~TqsP`Z=(Dpudi|b|KKFaI%2Xqe z71MUG;@nNPORjwDgI1rA`=8#FHe|#cbvjSU)7D%u`2v;NO={LjPB7$D8Tm#7=dc`Q zCs*t?4q!MW#3x#q> zmKL+o_k4pf$0)7nFXWACrx7tv0BjN^O&)dXBmp>DYDygk0Vvqu0tpM5_7n^f2>4SH z(3I|$gr}s9t`7xy1n(J$C~PQ0rhyD-oARs$EPO;|MV)B9l*J*nhuN4ET=2wUxna-x zNx3jc2dlfpeexmh>MDT8O4GD_xl6~GPvSly2XQ-&5n%!Ag~tVjdvrMQ&IfL-2$o)f zf+FjBOa3U@D|8hTS+ij&gK@71q-y$l5J-+BX#lI97Yn3(xv23z0{RRv`b^qAgb=xn z3D%7bV*Ys-xnrjyl|P=xOk>wkSA!2q5@buu$=8`8$K@R06x~U;AeyUJf^uj8g*_v; zWaX@KdF3quDY^Fykvuu&D<2o<>_-V~Sep*}lc-jNsGE5Z`=^0IH4Q$9p3)8v?eM^s zIpuYDh!EDKZ4&}do9mdped|P&#+;L{Zq(eC$N4x&JYiYKvF@lcw{lN#kw*TvsQa>A zc~7>1VukE1h*!1AoB`kDD8)WVV^k{4MsacHat!8s)wdqL`WnhlGfKR?0T=8-Kh&8df-lIUa(V*bpT}z{6zY(Lz6)+f$lGI)P zK%?Y<V*6CIZY6}I16%n4(Cm1^AS!@FW3 z)_d;Tj`OCc9d+6Bqb8ZXof*%X?ySxF%C9tGVpd$jwfttc_?nAun|~HrxCw7B*E%IL zd^=MsG;CpL*wVJJrSb7@@mit#)#^mM36_aUeRPZBeM*lvH;6WsIDBt3Z`?l?!4GjC z+*P?Y^qu*JhtjJ&+Ga)`81Ikhe^Klx^l!Y6#(dAxqs&7`nj2!P>bGx%zAtewk91gT zl)4C>{YMksbopI|on>ypm-Y=w{@IPvRDPtWuE`(#&AS))S#pKaTA$-p^XD0F_qt(%7$y5aJ<=Wfjq$RF%%ZSk2QJA0~{YiVeBj+PFDmJ4vW8{>kzIrh6v?K0et=QX&?_7 z;y;g70?xQ@mK={1`6yNCgf}CE4BiK<(TIICfO8G;tSS-U@K8o33J<83JhGoP1HTr? z5(HP|MI`&M5^qAVnerl%eOM{Ej$h_%QYd+R5@K?UFSM~#i7@^hZdwD*Xns{)RCCcz zsmfc@1OgMnuS@6#Nwoh)tEv)iO4C5`?7fz+mOQebHA7y4Xk+6m0=`lH>vVz=(y?@cQo)waFbPVc=+%Y! z=jJASWK+_$eSt3jWwRrbliQB2smW_!&RzZwt(0Tpck7uOLH5=2+;c3wL3`D?**^iDG1kGIDg?kAT8?fyR@`zy}? literal 0 HcmV?d00001