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:
parent
59905147b4
commit
48832ccb13
12 changed files with 2365 additions and 134 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1159
src/parser/mod.rs
1159
src/parser/mod.rs
File diff suppressed because it is too large
Load diff
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue