1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 01:16:12 +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

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