1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00

language: pleasant asset syntax for palettes, CHR, bg, sfx, music

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
This commit is contained in:
Claude 2026-04-13 16:09:53 +00:00
parent 59905147b4
commit 48832ccb13
No known key found for this signature in database
12 changed files with 2365 additions and 134 deletions

View file

@ -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` |

View file

@ -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

View file

@ -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

204
examples/friendly_assets.ne Normal file
View file

@ -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

Binary file not shown.

View file

@ -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<u8> {
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

View file

@ -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,
};

View file

@ -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<u8> {
// 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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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]);
}

View file

@ -0,0 +1 @@
c49fe29d 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB