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

M3: Asset pipeline, sprite/palette/background declarations, debug symbols

Parser extensions:
- sprite declarations with chr: @chr("file.png"), @binary("file.bin"), or inline [hex]
- palette declarations with colors: [0x0F, 0x00, 0x10, 0x20]
- background declarations with chr: asset source
- @chr/@binary asset source parsing
- load_background and set_palette statements
- --debug CLI flag (plumbed through, not yet wired to codegen)

Asset pipeline (new module):
- PNG → CHR tile conversion using image crate (8x8 tiles, 2-bitplane encoding)
- NES color palette table (all 64 standard NES colors as RGB)
- Nearest-color matching (Euclidean distance in RGB space)

Debug module (new):
- Source map (ROM address → source Span mapping)
- Debug symbols with variable address table
- Mesen-compatible .mlb label export
- .sym symbol table export

192 tests total (13 new: 5 parser + 3 asset + 5 debug)

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
This commit is contained in:
Claude 2026-04-12 00:09:47 +00:00
parent 0dc06f7f1a
commit 058f7a87b6
No known key found for this signature in database
18 changed files with 884 additions and 5 deletions

132
Cargo.lock generated
View file

@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anstream"
version = "1.0.0"
@ -62,6 +68,36 @@ dependencies = [
"yansi",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.0"
@ -108,39 +144,123 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "nescript"
version = "0.1.0"
dependencies = [
"ariadne",
"clap",
"image",
"pretty_assertions",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
@ -160,6 +280,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "quote"
version = "1.0.45"
@ -169,6 +295,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "strsim"
version = "0.11.1"

View file

@ -8,6 +8,7 @@ license = "MIT"
[dependencies]
clap = { version = "4", features = ["derive"] }
ariadne = "0.4"
image = { version = "0.25", default-features = false, features = ["png"] }
[dev-dependencies]
pretty_assertions = "1"

View file

@ -364,7 +364,9 @@ impl Analyzer {
Statement::Break(_)
| Statement::Continue(_)
| Statement::WaitFrame(_)
| Statement::Return(None, _) => {}
| Statement::Return(None, _)
| Statement::LoadBackground(_, _)
| Statement::SetPalette(_, _) => {}
}
}
@ -519,7 +521,9 @@ fn collect_calls_stmt(stmt: &Statement, calls: &mut Vec<String>) {
| Statement::Transition(_, _)
| Statement::WaitFrame(_)
| Statement::Break(_)
| Statement::Continue(_) => {}
| Statement::Continue(_)
| Statement::LoadBackground(_, _)
| Statement::SetPalette(_, _) => {}
}
}

55
src/assets/chr.rs Normal file
View file

@ -0,0 +1,55 @@
use image::GenericImageView;
/// Convert a PNG image to NES CHR tile data (2-bitplane format).
/// Each 8x8 tile = 16 bytes (8 bytes plane 0, 8 bytes plane 1).
pub fn png_to_chr(path: &std::path::Path) -> Result<Vec<u8>, String> {
let img = image::open(path).map_err(|e| format!("failed to open {}: {e}", path.display()))?;
let (w, h) = img.dimensions();
if w % 8 != 0 || h % 8 != 0 {
return Err(format!("image dimensions {w}x{h} must be multiples of 8"));
}
let mut chr_data = Vec::new();
let tiles_x = w / 8;
let tiles_y = h / 8;
for ty in 0..tiles_y {
for tx in 0..tiles_x {
let tile = encode_tile(&img, tx * 8, ty * 8);
chr_data.extend_from_slice(&tile);
}
}
Ok(chr_data)
}
fn encode_tile(img: &image::DynamicImage, x: u32, y: u32) -> [u8; 16] {
let mut tile = [0u8; 16];
for row in 0..8u32 {
let mut plane0 = 0u8;
let mut plane1 = 0u8;
for col in 0..8u32 {
let pixel = img.get_pixel(x + col, y + row);
// Map to 2-bit palette index based on brightness
let brightness = (u16::from(pixel[0]) + u16::from(pixel[1]) + u16::from(pixel[2])) / 3;
let index = match brightness {
0..=63 => 0u8,
64..=127 => 1,
128..=191 => 2,
_ => 3,
};
if index & 1 != 0 {
plane0 |= 0x80 >> col;
}
if index & 2 != 0 {
plane1 |= 0x80 >> col;
}
}
tile[row as usize] = plane0;
tile[row as usize + 8] = plane1;
}
tile
}

7
src/assets/mod.rs Normal file
View file

@ -0,0 +1,7 @@
mod chr;
mod palette;
#[cfg(test)]
mod tests;
pub use chr::png_to_chr;
pub use palette::{nearest_nes_color, NES_COLORS};

88
src/assets/palette.rs Normal file
View file

@ -0,0 +1,88 @@
/// NES color palette -- 64 colors as (R, G, B) tuples.
pub const NES_COLORS: [(u8, u8, u8); 64] = [
// Row 0
(84, 84, 84), // 0x00
(0, 30, 116), // 0x01
(8, 16, 144), // 0x02
(48, 0, 136), // 0x03
(68, 0, 100), // 0x04
(92, 0, 48), // 0x05
(84, 4, 0), // 0x06
(60, 24, 0), // 0x07
(32, 42, 0), // 0x08
(8, 58, 0), // 0x09
(0, 64, 0), // 0x0A
(0, 60, 0), // 0x0B
(0, 50, 60), // 0x0C
(0, 0, 0), // 0x0D
(0, 0, 0), // 0x0E
(0, 0, 0), // 0x0F
// Row 1
(152, 150, 152), // 0x10
(8, 76, 196), // 0x11
(48, 50, 236), // 0x12
(92, 30, 228), // 0x13
(136, 20, 176), // 0x14
(160, 20, 100), // 0x15
(152, 34, 32), // 0x16
(120, 60, 0), // 0x17
(84, 90, 0), // 0x18
(40, 114, 0), // 0x19
(8, 124, 0), // 0x1A
(0, 118, 40), // 0x1B
(0, 102, 120), // 0x1C
(0, 0, 0), // 0x1D
(0, 0, 0), // 0x1E
(0, 0, 0), // 0x1F
// Row 2
(236, 238, 236), // 0x20
(76, 154, 236), // 0x21
(120, 124, 236), // 0x22
(176, 98, 236), // 0x23
(228, 84, 236), // 0x24
(236, 88, 180), // 0x25
(236, 106, 100), // 0x26
(212, 136, 32), // 0x27
(160, 170, 0), // 0x28
(116, 196, 0), // 0x29
(76, 208, 32), // 0x2A
(56, 204, 108), // 0x2B
(56, 180, 204), // 0x2C
(60, 60, 60), // 0x2D
(0, 0, 0), // 0x2E
(0, 0, 0), // 0x2F
// Row 3
(236, 238, 236), // 0x30
(168, 204, 236), // 0x31
(188, 188, 236), // 0x32
(212, 178, 236), // 0x33
(236, 174, 236), // 0x34
(236, 174, 212), // 0x35
(236, 180, 176), // 0x36
(228, 196, 144), // 0x37
(204, 210, 120), // 0x38
(180, 222, 120), // 0x39
(168, 226, 144), // 0x3A
(152, 226, 180), // 0x3B
(160, 214, 228), // 0x3C
(160, 162, 160), // 0x3D
(0, 0, 0), // 0x3E
(0, 0, 0), // 0x3F
];
/// Find the nearest NES color index for an RGB value.
pub fn nearest_nes_color(r: u8, g: u8, b: u8) -> u8 {
let mut best_idx = 0u8;
let mut best_dist = u32::MAX;
for (i, &(nr, ng, nb)) in NES_COLORS.iter().enumerate() {
let dr = i32::from(r) - i32::from(nr);
let dg = i32::from(g) - i32::from(ng);
let db = i32::from(b) - i32::from(nb);
let dist = (dr * dr + dg * dg + db * db).unsigned_abs();
if dist < best_dist {
best_dist = dist;
best_idx = i as u8;
}
}
best_idx
}

17
src/assets/tests.rs Normal file
View file

@ -0,0 +1,17 @@
use super::*;
#[test]
fn nearest_nes_color_black() {
assert_eq!(palette::nearest_nes_color(0, 0, 0), 0x0D); // NES black
}
#[test]
fn nearest_nes_color_white() {
let idx = palette::nearest_nes_color(255, 255, 255);
assert!(idx == 0x20 || idx == 0x30); // near-white colors
}
#[test]
fn nes_color_table_has_64_entries() {
assert_eq!(palette::NES_COLORS.len(), 64);
}

View file

@ -148,6 +148,9 @@ impl CodeGen {
| Statement::Call(_, _, _) => {
// TODO: implement for later milestones
}
Statement::LoadBackground(_, _) | Statement::SetPalette(_, _) => {
// TODO: implement in asset pipeline
}
}
}

5
src/debug/mod.rs Normal file
View file

@ -0,0 +1,5 @@
mod source_map;
#[cfg(test)]
mod tests;
pub use source_map::{DebugSymbols, SourceMap, SourceMapping};

89
src/debug/source_map.rs Normal file
View file

@ -0,0 +1,89 @@
use std::collections::HashMap;
use std::fmt::Write;
use crate::analyzer::VarAllocation;
use crate::lexer::Span;
/// A mapping from ROM address to source location.
#[derive(Debug, Clone)]
pub struct SourceMapping {
pub rom_address: u16,
pub span: Span,
pub label: Option<String>,
}
/// Source map for debug symbol output.
/// Maps ROM addresses back to `NEScript` source locations.
#[derive(Debug, Clone, Default)]
pub struct SourceMap {
pub mappings: Vec<SourceMapping>,
}
impl SourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, rom_address: u16, span: Span, label: Option<String>) {
self.mappings.push(SourceMapping {
rom_address,
span,
label,
});
}
/// Sort mappings by ROM address.
pub fn finalize(&mut self) {
self.mappings.sort_by_key(|m| m.rom_address);
}
}
/// Debug symbols for a compiled ROM.
/// Compatible with Mesen's .dbg format.
#[derive(Debug, Clone)]
pub struct DebugSymbols {
pub source_map: SourceMap,
pub symbols: HashMap<String, u16>,
pub source_file: String,
}
impl DebugSymbols {
pub fn new(source_file: &str) -> Self {
Self {
source_map: SourceMap::new(),
symbols: HashMap::new(),
source_file: source_file.to_string(),
}
}
/// Add variable symbols from analysis.
pub fn add_variables(&mut self, allocations: &[VarAllocation]) {
for alloc in allocations {
self.symbols.insert(alloc.name.clone(), alloc.address);
}
}
/// Generate Mesen-compatible .mlb (label) file content.
pub fn to_mesen_labels(&self) -> String {
let mut output = String::new();
for (name, &addr) in &self.symbols {
// Mesen label format: P:ADDR:NAME (P = PRG ROM)
let _ = writeln!(output, "P:{addr:04X}:{name}");
}
// Sort for deterministic output
let mut lines: Vec<&str> = output.lines().collect();
lines.sort_unstable();
lines.join("\n") + "\n"
}
/// Generate a simple .sym (symbol table) file.
pub fn to_sym_file(&self) -> String {
let mut output = String::from("; NEScript debug symbols\n");
let mut entries: Vec<_> = self.symbols.iter().collect();
entries.sort_by_key(|(_, &addr)| addr);
for (name, &addr) in &entries {
let _ = writeln!(output, "{addr:04X} {name}");
}
output
}
}

66
src/debug/tests.rs Normal file
View file

@ -0,0 +1,66 @@
use super::*;
use crate::lexer::Span;
#[test]
fn source_map_add_and_finalize() {
let mut sm = SourceMap::new();
sm.add(0xC010, Span::new(0, 10, 20), Some("main_loop".into()));
sm.add(0xC000, Span::new(0, 0, 5), Some("reset".into()));
sm.finalize();
// Should be sorted by ROM address
assert_eq!(sm.mappings[0].rom_address, 0xC000);
assert_eq!(sm.mappings[1].rom_address, 0xC010);
}
#[test]
fn debug_symbols_new() {
let ds = DebugSymbols::new("test.ne");
assert_eq!(ds.source_file, "test.ne");
assert!(ds.symbols.is_empty());
}
#[test]
fn debug_symbols_add_variables() {
use crate::analyzer::VarAllocation;
let mut ds = DebugSymbols::new("test.ne");
ds.add_variables(&[
VarAllocation {
name: "px".into(),
address: 0x10,
size: 1,
},
VarAllocation {
name: "py".into(),
address: 0x11,
size: 1,
},
]);
assert_eq!(ds.symbols["px"], 0x10);
assert_eq!(ds.symbols["py"], 0x11);
}
#[test]
fn mesen_labels_format() {
let mut ds = DebugSymbols::new("test.ne");
ds.symbols.insert("player_x".into(), 0x0010);
ds.symbols.insert("score".into(), 0x0012);
let labels = ds.to_mesen_labels();
assert!(labels.contains("P:0010:player_x"));
assert!(labels.contains("P:0012:score"));
}
#[test]
fn sym_file_format() {
let mut ds = DebugSymbols::new("test.ne");
ds.symbols.insert("px".into(), 0x10);
ds.symbols.insert("py".into(), 0x11);
let sym = ds.to_sym_file();
assert!(sym.contains("0010 px"));
assert!(sym.contains("0011 py"));
// Should have header comment
assert!(sym.starts_with("; NEScript"));
}

View file

@ -299,6 +299,9 @@ impl LoweringContext {
let arg_temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect();
self.emit(IrOp::Call(None, name.clone(), arg_temps));
}
Statement::LoadBackground(_, _) | Statement::SetPalette(_, _) => {
// TODO: implement in asset pipeline
}
}
}

View file

@ -1,6 +1,8 @@
pub mod analyzer;
pub mod asm;
pub mod assets;
pub mod codegen;
pub mod debug;
pub mod errors;
pub mod ir;
pub mod lexer;

View file

@ -19,6 +19,10 @@ enum Cli {
/// Output ROM file (default: input with .nes extension)
#[arg(short, long)]
output: Option<PathBuf>,
/// Enable debug mode (runtime checks, debug.log)
#[arg(long)]
debug: bool,
},
/// Type-check a source file without building
Check {
@ -31,9 +35,13 @@ fn main() {
let cli = Cli::parse();
match cli {
Cli::Build { input, output } => {
Cli::Build {
input,
output,
debug,
} => {
let output = output.unwrap_or_else(|| input.with_extension("nes"));
match compile(&input) {
match compile(&input, debug) {
Ok(rom) => {
std::fs::write(&output, rom).unwrap_or_else(|e| {
eprintln!("error: failed to write {}: {e}", output.display());
@ -56,7 +64,7 @@ fn main() {
}
}
fn compile(input: &PathBuf) -> Result<Vec<u8>, ()> {
fn compile(input: &PathBuf, _debug: bool) -> Result<Vec<u8>, ()> {
let source = std::fs::read_to_string(input).map_err(|e| {
eprintln!("error: failed to read {}: {e}", input.display());
})?;

View file

@ -7,10 +7,41 @@ pub struct Program {
pub constants: Vec<ConstDecl>,
pub functions: Vec<FunDecl>,
pub states: Vec<StateDecl>,
pub sprites: Vec<SpriteDecl>,
pub palettes: Vec<PaletteDecl>,
pub backgrounds: Vec<BackgroundDecl>,
pub start_state: String,
pub span: Span,
}
#[derive(Debug, Clone)]
pub struct SpriteDecl {
pub name: String,
pub chr_source: AssetSource,
pub span: Span,
}
#[derive(Debug, Clone)]
pub struct PaletteDecl {
pub name: String,
pub colors: Vec<u8>,
pub span: Span,
}
#[derive(Debug, Clone)]
pub struct BackgroundDecl {
pub name: String,
pub chr_source: AssetSource,
pub span: Span,
}
#[derive(Debug, Clone)]
pub enum AssetSource {
Chr(String),
Binary(String),
Inline(Vec<u8>),
}
#[derive(Debug, Clone)]
pub struct GameDecl {
pub name: String,
@ -188,6 +219,8 @@ pub enum Statement {
Transition(String, Span),
WaitFrame(Span),
Call(String, Vec<Expr>, Span),
LoadBackground(String, Span),
SetPalette(String, Span),
}
#[derive(Debug, Clone)]

View file

@ -111,6 +111,9 @@ impl Parser {
let mut constants = Vec::new();
let mut functions = Vec::new();
let mut states = Vec::new();
let mut sprites = Vec::new();
let mut palettes = Vec::new();
let mut backgrounds = Vec::new();
let mut start_state = None;
let mut on_frame = None;
let span = self.current_span();
@ -135,6 +138,15 @@ impl Parser {
TokenKind::KwState => {
states.push(self.parse_state_decl()?);
}
TokenKind::KwSprite => {
sprites.push(self.parse_sprite_decl()?);
}
TokenKind::KwPalette => {
palettes.push(self.parse_palette_decl()?);
}
TokenKind::KwBackground => {
backgrounds.push(self.parse_background_decl()?);
}
TokenKind::KwOn => {
// Top-level `on frame` — implicit single state for M1
on_frame = Some(self.parse_on_frame()?);
@ -187,6 +199,9 @@ impl Parser {
constants,
functions,
states,
sprites,
palettes,
backgrounds,
start_state,
span,
})
@ -450,6 +465,204 @@ impl Parser {
self.parse_block()
}
// ── Sprite / Palette / Background declarations ──
fn parse_sprite_decl(&mut self) -> Result<SpriteDecl, Diagnostic> {
let start = self.current_span();
self.expect(&TokenKind::KwSprite)?;
let (name, _) = self.expect_ident()?;
self.expect(&TokenKind::LBrace)?;
let mut chr_source = None;
while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof {
let (key, _) = self.expect_ident()?;
self.expect(&TokenKind::Colon)?;
match key.as_str() {
"chr" => {
chr_source = Some(self.parse_asset_source()?);
}
_ => {
return Err(Diagnostic::error(
ErrorCode::E0201,
format!("unknown sprite property '{key}'"),
self.current_span(),
));
}
}
}
self.expect(&TokenKind::RBrace)?;
let chr_source = chr_source.ok_or_else(|| {
Diagnostic::error(ErrorCode::E0201, "sprite requires 'chr' property", start)
})?;
Ok(SpriteDecl {
name,
chr_source,
span: Span::new(start.file_id, start.start, self.current_span().start),
})
}
fn parse_palette_decl(&mut self) -> Result<PaletteDecl, Diagnostic> {
let start = self.current_span();
self.expect(&TokenKind::KwPalette)?;
let (name, _) = self.expect_ident()?;
self.expect(&TokenKind::LBrace)?;
let mut colors = None;
while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof {
let (key, _) = self.expect_ident()?;
self.expect(&TokenKind::Colon)?;
match key.as_str() {
"colors" => {
self.expect(&TokenKind::LBracket)?;
let mut vals = Vec::new();
while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof {
if let TokenKind::IntLiteral(v) = self.peek().clone() {
self.advance();
vals.push(v as u8);
} else {
return Err(Diagnostic::error(
ErrorCode::E0201,
format!("expected color index, found '{}'", self.peek()),
self.current_span(),
));
}
if *self.peek() == TokenKind::Comma {
self.advance();
}
}
self.expect(&TokenKind::RBracket)?;
colors = Some(vals);
}
_ => {
return Err(Diagnostic::error(
ErrorCode::E0201,
format!("unknown palette property '{key}'"),
self.current_span(),
));
}
}
}
self.expect(&TokenKind::RBrace)?;
let colors = colors.ok_or_else(|| {
Diagnostic::error(
ErrorCode::E0201,
"palette requires 'colors' property",
start,
)
})?;
Ok(PaletteDecl {
name,
colors,
span: Span::new(start.file_id, start.start, self.current_span().start),
})
}
fn parse_background_decl(&mut self) -> Result<BackgroundDecl, Diagnostic> {
let start = self.current_span();
self.expect(&TokenKind::KwBackground)?;
let (name, _) = self.expect_ident()?;
self.expect(&TokenKind::LBrace)?;
let mut chr_source = None;
while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof {
let (key, _) = self.expect_ident()?;
self.expect(&TokenKind::Colon)?;
match key.as_str() {
"chr" => {
chr_source = Some(self.parse_asset_source()?);
}
_ => {
return Err(Diagnostic::error(
ErrorCode::E0201,
format!("unknown background property '{key}'"),
self.current_span(),
));
}
}
}
self.expect(&TokenKind::RBrace)?;
let chr_source = chr_source.ok_or_else(|| {
Diagnostic::error(
ErrorCode::E0201,
"background requires 'chr' property",
start,
)
})?;
Ok(BackgroundDecl {
name,
chr_source,
span: Span::new(start.file_id, start.start, self.current_span().start),
})
}
fn parse_asset_source(&mut self) -> Result<AssetSource, Diagnostic> {
match self.peek() {
TokenKind::At => {
self.advance(); // consume '@'
let (kind, _) = self.expect_ident()?;
self.expect(&TokenKind::LParen)?;
let path = if let TokenKind::StringLiteral(s) = self.peek().clone() {
self.advance();
s
} else {
return Err(Diagnostic::error(
ErrorCode::E0201,
format!("expected string path, found '{}'", self.peek()),
self.current_span(),
));
};
self.expect(&TokenKind::RParen)?;
match kind.as_str() {
"chr" => Ok(AssetSource::Chr(path)),
"binary" => Ok(AssetSource::Binary(path)),
_ => Err(Diagnostic::error(
ErrorCode::E0201,
format!("unknown asset source kind '@{kind}'"),
self.current_span(),
)),
}
}
TokenKind::LBracket => {
self.advance();
let mut bytes = Vec::new();
while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof {
if let TokenKind::IntLiteral(v) = self.peek().clone() {
self.advance();
bytes.push(v as u8);
} else {
return Err(Diagnostic::error(
ErrorCode::E0201,
format!("expected byte value, found '{}'", self.peek()),
self.current_span(),
));
}
if *self.peek() == TokenKind::Comma {
self.advance();
}
}
self.expect(&TokenKind::RBracket)?;
Ok(AssetSource::Inline(bytes))
}
_ => Err(Diagnostic::error(
ErrorCode::E0201,
format!(
"expected asset source (@chr, @binary, or [...]), found '{}'",
self.peek()
),
self.current_span(),
)),
}
}
// ── Block ──
fn parse_block(&mut self) -> Result<Block, Diagnostic> {
@ -511,6 +724,18 @@ impl Parser {
self.advance();
Ok(Statement::WaitFrame(span))
}
TokenKind::KwLoadBackground => {
let span = self.current_span();
self.advance();
let (name, _) = self.expect_ident()?;
Ok(Statement::LoadBackground(name, span))
}
TokenKind::KwSetPalette => {
let span = self.current_span();
self.advance();
let (name, _) = self.expect_ident()?;
Ok(Statement::SetPalette(name, span))
}
TokenKind::Ident(_) => self.parse_assign_or_call(),
_ => Err(Diagnostic::error(
ErrorCode::E0201,

View file

@ -482,3 +482,103 @@ fn parse_function_call_expr() {
other => panic!("expected assignment with Call, got {other:?}"),
}
}
// ── Milestone 3: Sprites / Palettes / Backgrounds ──
#[test]
fn parse_sprite_decl() {
let src = r#"
game "Test" { mapper: NROM }
sprite Player {
chr: [0x3C, 0x42, 0x81, 0x81, 0x81, 0x81, 0x42, 0x3C,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
}
on frame { wait_frame }
start Main
"#;
let prog = parse_ok(src);
assert_eq!(prog.sprites.len(), 1);
assert_eq!(prog.sprites[0].name, "Player");
match &prog.sprites[0].chr_source {
AssetSource::Inline(data) => {
assert_eq!(data.len(), 16);
assert_eq!(data[0], 0x3C);
}
other => panic!("expected Inline, got {other:?}"),
}
}
#[test]
fn parse_palette_decl() {
let src = r#"
game "Test" { mapper: NROM }
palette MainPal {
colors: [0x0F, 0x00, 0x10, 0x20]
}
on frame { wait_frame }
start Main
"#;
let prog = parse_ok(src);
assert_eq!(prog.palettes.len(), 1);
assert_eq!(prog.palettes[0].name, "MainPal");
assert_eq!(prog.palettes[0].colors, vec![0x0F, 0x00, 0x10, 0x20]);
}
#[test]
fn parse_background_decl() {
let src = r#"
game "Test" { mapper: NROM }
background TitleBg {
chr: @chr("title.png")
}
on frame { wait_frame }
start Main
"#;
let prog = parse_ok(src);
assert_eq!(prog.backgrounds.len(), 1);
assert_eq!(prog.backgrounds[0].name, "TitleBg");
match &prog.backgrounds[0].chr_source {
AssetSource::Chr(path) => assert_eq!(path, "title.png"),
other => panic!("expected Chr, got {other:?}"),
}
}
#[test]
fn parse_load_background_statement() {
let src = r#"
game "Test" { mapper: NROM }
state Title {
on frame {
load_background TitleBg
}
}
start Title
"#;
let prog = parse_ok(src);
let frame = prog.states[0].on_frame.as_ref().unwrap();
assert_eq!(frame.statements.len(), 1);
match &frame.statements[0] {
Statement::LoadBackground(name, _) => assert_eq!(name, "TitleBg"),
other => panic!("expected LoadBackground, got {other:?}"),
}
}
#[test]
fn parse_set_palette_statement() {
let src = r#"
game "Test" { mapper: NROM }
state Title {
on frame {
set_palette MainPal
}
}
start Title
"#;
let prog = parse_ok(src);
let frame = prog.states[0].on_frame.as_ref().unwrap();
assert_eq!(frame.statements.len(), 1);
match &frame.statements[0] {
Statement::SetPalette(name, _) => assert_eq!(name, "MainPal"),
other => panic!("expected SetPalette, got {other:?}"),
}
}

View file

@ -288,3 +288,44 @@ fn error_test_recursion_detected() {
"should detect recursion"
);
}
// ── M3 Tests ──
#[test]
fn program_with_sprites_and_palette() {
let source = r#"
game "M3 Assets" { mapper: NROM }
sprite Player {
chr: [0x3C, 0x42, 0x81, 0x81, 0x81, 0x81, 0x42, 0x3C,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
}
palette MainPal {
colors: [0x0F, 0x00, 0x10, 0x20]
}
background TitleBg {
chr: @binary("title.bin")
}
var px: u8 = 128
var py: u8 = 120
state Title {
on enter {
load_background TitleBg
set_palette MainPal
}
on frame {
if button.right { px += 2 }
if button.left { px -= 2 }
draw Player at: (px, py)
}
}
start Title
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}