diff --git a/Cargo.lock b/Cargo.lock index df1d928..735c00c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 9e3f645..fa20b9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index da48336..09893e2 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -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) { | Statement::Transition(_, _) | Statement::WaitFrame(_) | Statement::Break(_) - | Statement::Continue(_) => {} + | Statement::Continue(_) + | Statement::LoadBackground(_, _) + | Statement::SetPalette(_, _) => {} } } diff --git a/src/assets/chr.rs b/src/assets/chr.rs new file mode 100644 index 0000000..6fe07b9 --- /dev/null +++ b/src/assets/chr.rs @@ -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, 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 +} diff --git a/src/assets/mod.rs b/src/assets/mod.rs new file mode 100644 index 0000000..e2debb7 --- /dev/null +++ b/src/assets/mod.rs @@ -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}; diff --git a/src/assets/palette.rs b/src/assets/palette.rs new file mode 100644 index 0000000..b421ffc --- /dev/null +++ b/src/assets/palette.rs @@ -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 +} diff --git a/src/assets/tests.rs b/src/assets/tests.rs new file mode 100644 index 0000000..96c3963 --- /dev/null +++ b/src/assets/tests.rs @@ -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); +} diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index 57db002..5d52ba4 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -148,6 +148,9 @@ impl CodeGen { | Statement::Call(_, _, _) => { // TODO: implement for later milestones } + Statement::LoadBackground(_, _) | Statement::SetPalette(_, _) => { + // TODO: implement in asset pipeline + } } } diff --git a/src/debug/mod.rs b/src/debug/mod.rs new file mode 100644 index 0000000..625482c --- /dev/null +++ b/src/debug/mod.rs @@ -0,0 +1,5 @@ +mod source_map; +#[cfg(test)] +mod tests; + +pub use source_map::{DebugSymbols, SourceMap, SourceMapping}; diff --git a/src/debug/source_map.rs b/src/debug/source_map.rs new file mode 100644 index 0000000..9a89622 --- /dev/null +++ b/src/debug/source_map.rs @@ -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, +} + +/// Source map for debug symbol output. +/// Maps ROM addresses back to `NEScript` source locations. +#[derive(Debug, Clone, Default)] +pub struct SourceMap { + pub mappings: Vec, +} + +impl SourceMap { + pub fn new() -> Self { + Self::default() + } + + pub fn add(&mut self, rom_address: u16, span: Span, label: Option) { + 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, + 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 + } +} diff --git a/src/debug/tests.rs b/src/debug/tests.rs new file mode 100644 index 0000000..eaeb757 --- /dev/null +++ b/src/debug/tests.rs @@ -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")); +} diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 68eb585..955d8e5 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -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 + } } } diff --git a/src/lib.rs b/src/lib.rs index 6cea43e..1e0c8fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index de3be34..713c007 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,10 @@ enum Cli { /// Output ROM file (default: input with .nes extension) #[arg(short, long)] output: Option, + + /// 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, ()> { +fn compile(input: &PathBuf, _debug: bool) -> Result, ()> { let source = std::fs::read_to_string(input).map_err(|e| { eprintln!("error: failed to read {}: {e}", input.display()); })?; diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 8dde9b2..b0841f1 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -7,10 +7,41 @@ pub struct Program { pub constants: Vec, pub functions: Vec, pub states: Vec, + pub sprites: Vec, + pub palettes: Vec, + pub backgrounds: Vec, 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, + 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), +} + #[derive(Debug, Clone)] pub struct GameDecl { pub name: String, @@ -188,6 +219,8 @@ pub enum Statement { Transition(String, Span), WaitFrame(Span), Call(String, Vec, Span), + LoadBackground(String, Span), + SetPalette(String, Span), } #[derive(Debug, Clone)] diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9cf0fca..06ff919 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { @@ -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, diff --git a/src/parser/tests.rs b/src/parser/tests.rs index f965b19..27ea68a 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -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:?}"), + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index df782a0..d2a622d 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -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"); +}