From 8610aecdaca5e865ed7e98ca746ae7d4ce9e9980 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 03:01:32 +0000 Subject: [PATCH] assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean. --- benches/compile.rs | 6 +- src/assets/chr.rs | 233 +++++++++++++++++++++++++++++++++++ src/assets/mod.rs | 4 +- src/assets/palette.rs | 169 +++++++++++++++++++++++++ src/assets/resolve.rs | 222 ++++++++++++++++++++++++++++----- src/main.rs | 251 +++++++++++++++++++++++++++++++++----- src/parser/ast.rs | 11 ++ src/parser/mod.rs | 95 +++++++++++++++ src/parser/tests.rs | 57 +++++++++ tests/integration_test.rs | 116 ++++++++++++++++-- 10 files changed, 1095 insertions(+), 69 deletions(-) diff --git a/benches/compile.rs b/benches/compile.rs index dc65387..6a89515 100644 --- a/benches/compile.rs +++ b/benches/compile.rs @@ -108,8 +108,10 @@ fn compile_pipeline(source: &str, source_dir: &Path) -> Vec { let sprites = assets::resolve_sprites(&program, source_dir).expect("sprite resolution failed"); let sfx = assets::resolve_sfx(&program).expect("sfx resolution failed"); let music = assets::resolve_music(&program).expect("music resolution failed"); - let palettes = assets::resolve_palettes(&program); - let backgrounds = assets::resolve_backgrounds(&program); + let palettes = + assets::resolve_palettes(&program, source_dir).expect("palette resolution failed"); + let backgrounds = + assets::resolve_backgrounds(&program, source_dir).expect("background resolution failed"); let mut instructions = IrCodeGen::new(&analysis.var_allocations, &ir_program) .with_sprites(&sprites) diff --git a/src/assets/chr.rs b/src/assets/chr.rs index 6fe07b9..cc95066 100644 --- a/src/assets/chr.rs +++ b/src/assets/chr.rs @@ -24,6 +24,130 @@ pub fn png_to_chr(path: &std::path::Path) -> Result, String> { Ok(chr_data) } +/// Convert a 256×240 PNG into a nametable (`tiles`, `attrs`) pair. +/// +/// The image is sliced into 32×30 8×8 cells. Each cell's raw RGB +/// bytes are hashed; the first occurrence of a given hash becomes a +/// fresh tile index. A maximum of 256 unique tiles fit in a single +/// pattern table — anything beyond that is rejected. The 64-byte +/// attribute table is filled by computing, for each 16×16 quadrant of +/// a 32×32 meta-cell, the dominant brightness bucket (0-3) and +/// packing the four buckets into a single byte. +/// +/// **Important limitation.** This helper does **not** emit CHR data +/// — the 960-byte tile-index table it produces references tiles +/// assumed to sit at indices 0..N in the user's CHR ROM. Callers +/// typically provide matching CHR via a separate sprite / `@chr(...)` +/// declaration; without that the rendered output won't match the +/// source PNG. The parser warns via the `png_source` flow, the +/// resolver wires it up, and the rest is up to the user for now. +/// Tracked in `docs/future-work.md` as the next increment on this +/// feature. +pub fn png_to_nametable(path: &std::path::Path) -> Result<([u8; 960], [u8; 64]), String> { + let img = image::open(path).map_err(|e| format!("failed to open {}: {e}", path.display()))?; + let (w, h) = img.dimensions(); + if w != 256 || h != 240 { + return Err(format!( + "nametable PNG {} must be 256×240 (got {w}×{h})", + path.display() + )); + } + + // 32×30 tile grid. For each tile we serialise its 64 pixels into + // a 192-byte RGB blob, then use that blob as the dedup key via a + // small hand-rolled table rather than pulling a hash crate in. + // Keys are kept in a Vec> with the index as the tile id + // — O(N²) in unique tiles, but N ≤ 256 so it's fine. + let rgb = img.to_rgb8(); + let mut unique_tiles: Vec> = Vec::new(); + let mut tiles = [0u8; 960]; + + for ty in 0..30u32 { + for tx in 0..32u32 { + let mut key = Vec::with_capacity(8 * 8 * 3); + for row in 0..8u32 { + for col in 0..8u32 { + let p = rgb.get_pixel(tx * 8 + col, ty * 8 + row); + key.push(p[0]); + key.push(p[1]); + key.push(p[2]); + } + } + let idx = if let Some(pos) = unique_tiles.iter().position(|t| t == &key) { + pos + } else { + if unique_tiles.len() >= 256 { + return Err(format!( + "nametable PNG {} has more than 256 unique 8×8 tiles; \ + simplify the image or split it into multiple backgrounds", + path.display() + )); + } + unique_tiles.push(key); + unique_tiles.len() - 1 + }; + tiles[(ty * 32 + tx) as usize] = idx as u8; + } + } + + // Attribute table: 8×8 bytes, each covering a 32×32 region made + // up of four 16×16 quadrants. Each quadrant gets 2 bits + // (0..=3) packed into the byte as `TR<<6 | TL<<4 | BR<<2 | BL` + // per the PPU's documented layout. The 15-row nametable only + // half-fills the last attribute byte-row (rows 8..10 of the + // bottom attribute byte are unused and stay at 0, matching the + // hand-packed form the parser already emits). + // + // For each 16×16 quadrant we bucket the average brightness of + // its 256 pixels into 0..=3. That's a crude approximation but + // it's deterministic and maps "darker" regions to sub-palette 0 + // and "brighter" regions to sub-palette 3 — a reasonable default + // until per-quadrant palette selection is exposed in the source. + let mut attrs = [0u8; 64]; + for aty in 0..8u32 { + for atx in 0..8u32 { + let quadrant = |qx: u32, qy: u32| -> u8 { + // qx/qy are 0 or 1 → top-left/top-right/bottom-left/ + // bottom-right of the 32×32 attribute cell. + let base_x = atx * 32 + qx * 16; + let base_y = aty * 32 + qy * 16; + if base_x >= 256 || base_y >= 240 { + return 0; + } + let mut total: u32 = 0; + let mut count: u32 = 0; + let y_end = (base_y + 16).min(240); + let x_end = (base_x + 16).min(256); + for y in base_y..y_end { + for x in base_x..x_end { + let p = rgb.get_pixel(x, y); + total += u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]); + count += 1; + } + } + if count == 0 { + return 0; + } + let avg = total / (count * 3); + match avg { + 0..=63 => 0, + 64..=127 => 1, + 128..=191 => 2, + _ => 3, + } + }; + let tl = quadrant(0, 0); + let tr = quadrant(1, 0); + let bl = quadrant(0, 1); + let br = quadrant(1, 1); + let byte = (br << 6) | (bl << 4) | (tr << 2) | tl; + attrs[(aty * 8 + atx) as usize] = byte; + } + } + + Ok((tiles, attrs)) +} + fn encode_tile(img: &image::DynamicImage, x: u32, y: u32) -> [u8; 16] { let mut tile = [0u8; 16]; @@ -53,3 +177,112 @@ fn encode_tile(img: &image::DynamicImage, x: u32, y: u32) -> [u8; 16] { tile } + +#[cfg(test)] +mod tests { + use super::*; + use image::{Rgb, RgbImage}; + + #[test] + fn png_to_nametable_dedupes_tiles() { + // A 256×240 image split into 8×8 tiles: the top half is all + // black and the bottom half is all white. We expect the + // deduplicator to find exactly two unique tiles and to emit + // a 960-byte tile map where rows 0..14 reference tile 0 and + // rows 15..29 reference tile 1. + let dir = std::env::temp_dir(); + let path = dir.join("nescript_png_to_nametable_dedupe.png"); + let mut img = RgbImage::new(256, 240); + for y in 0..240u32 { + let c = if y < 120 { 0u8 } else { 255u8 }; + for x in 0..256u32 { + img.put_pixel(x, y, Rgb([c, c, c])); + } + } + img.save(&path).unwrap(); + let (tiles, attrs) = png_to_nametable(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + // Top 15 rows should be uniformly tile 0; bottom 15 rows + // should be uniformly tile 1. + for row in 0..15usize { + for col in 0..32usize { + assert_eq!(tiles[row * 32 + col], 0); + } + } + for row in 15..30usize { + for col in 0..32usize { + assert_eq!(tiles[row * 32 + col], 1); + } + } + // Attributes: top half dark → sub-palette 0; bottom half + // bright → sub-palette 3. Each attribute byte covers 32×32 + // so row 0..3 of the attribute table is "top half" and + // row 4..7 is "bottom half"; row 3 straddles the 120-pixel + // seam so we only check rows that are cleanly on one side. + for row in 0..3usize { + for col in 0..8usize { + assert_eq!( + attrs[row * 8 + col], + 0, + "attr row {row} col {col} should be dark" + ); + } + } + for row in 4..7usize { + for col in 0..8usize { + // 3 packed into every 2-bit slot = 0xFF. + assert_eq!( + attrs[row * 8 + col], + 0xFF, + "attr row {row} col {col} should be bright" + ); + } + } + } + + #[test] + fn png_to_nametable_rejects_wrong_size() { + let dir = std::env::temp_dir(); + let path = dir.join("nescript_png_nametable_wrong_size.png"); + let img = RgbImage::new(320, 240); + img.save(&path).unwrap(); + let err = png_to_nametable(&path).unwrap_err(); + let _ = std::fs::remove_file(&path); + assert!(err.contains("must be 256"), "unexpected error: {err}"); + } + + #[test] + fn png_to_nametable_rejects_too_many_unique_tiles() { + // A 256×240 image of unique gradient tiles — each 8×8 cell + // has a distinct top-left pixel value. With 32×30 = 960 + // tiles but only 256 unique slots available, this must + // fail with a clear error. We force uniqueness by tiling + // monotonically increasing colours across the 32×30 grid. + let dir = std::env::temp_dir(); + let path = dir.join("nescript_png_nametable_too_many.png"); + let mut img = RgbImage::new(256, 240); + for ty in 0..30u32 { + for tx in 0..32u32 { + let idx = ty * 32 + tx; + // 960 distinct (r, g, b) triplets. We use 10 bits + // worth of variation so no two tiles collide. + let r = (idx & 0xFF) as u8; + let g = ((idx >> 2) & 0xFF) as u8; + let b = ((idx >> 4) & 0xFF) as u8; + for row in 0..8u32 { + for col in 0..8u32 { + img.put_pixel(tx * 8 + col, ty * 8 + row, Rgb([r, g, b])); + } + } + } + } + img.save(&path).unwrap(); + let err = png_to_nametable(&path).unwrap_err(); + let _ = std::fs::remove_file(&path); + assert!( + err.contains("more than 256 unique"), + "unexpected error: {err}" + ); + } +} diff --git a/src/assets/mod.rs b/src/assets/mod.rs index 9e89883..c24ea90 100644 --- a/src/assets/mod.rs +++ b/src/assets/mod.rs @@ -9,8 +9,8 @@ pub use audio::{ 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::{color_name_to_index, nearest_nes_color, NES_COLORS}; +pub use chr::{png_to_chr, png_to_nametable}; +pub use palette::{color_name_to_index, nearest_nes_color, png_to_palette, NES_COLORS}; pub use resolve::{ resolve_backgrounds, resolve_palettes, resolve_sprites, BackgroundData, PaletteData, }; diff --git a/src/assets/palette.rs b/src/assets/palette.rs index e49464c..53d2b10 100644 --- a/src/assets/palette.rs +++ b/src/assets/palette.rs @@ -70,6 +70,91 @@ pub const NES_COLORS: [(u8, u8, u8); 64] = [ (0, 0, 0), // 0x3F ]; +/// Decode a PNG file into a 32-byte NES palette blob. +/// +/// Each pixel's RGB is mapped to the nearest NES master-palette +/// index via [`nearest_nes_color`]. Pixels are walked in row-major +/// order and deduplicated; the first `N` unique colours (up to 16) +/// become the palette. The first unique colour is treated as the +/// **universal** background colour and is written to every +/// sub-palette's first byte (indices 0, 4, 8, 12, 16, 20, 24, 28) +/// so the PPU's `$3F10/$3F14/$3F18/$3F1C` mirror doesn't silently +/// clobber it — the same convention the grouped-form parser +/// enforces. +/// +/// The output is always exactly 32 bytes, even when fewer than +/// 16 unique colours were found: remaining sub-palette slots are +/// filled from the leading unique colours (so short PNGs round- +/// trip cleanly into a valid `$3F00-$3F1F` blob). When more than +/// 16 unique NES colours are present, an error is returned — the +/// caller is expected to use a smaller image or the grouped +/// authoring form. +/// +/// Called from [`crate::assets::resolve::resolve_palettes`] when +/// a `palette Name @palette("file.png")` declaration sets +/// `PaletteDecl::png_source`. +pub fn png_to_palette(path: &std::path::Path) -> Result<[u8; 32], String> { + let img = image::open(path).map_err(|e| format!("failed to open {}: {e}", path.display()))?; + let rgb = img.to_rgb8(); + + // Walk pixels in row-major order, mapping each to its nearest + // NES index and deduplicating. The first hit becomes the + // universal colour; subsequent unique hits fill the remaining + // 15 palette slots. The hard cap mirrors the PPU's own limit: + // 4 sub-palettes × 4 bytes − 3 shared universals = 13 usable + // slots for backgrounds and 13 for sprites, i.e. 16 including + // the shared universal byte. More than that can't fit into + // a single `$3F00-$3F1F` write. + let mut unique: Vec = Vec::with_capacity(16); + for pixel in rgb.pixels() { + let idx = nearest_nes_color(pixel[0], pixel[1], pixel[2]); + if !unique.contains(&idx) { + unique.push(idx); + if unique.len() > 16 { + return Err(format!( + "palette PNG {} has more than 16 unique NES colours; \ + use a smaller image or switch to the grouped palette \ + authoring form", + path.display() + )); + } + } + } + + if unique.is_empty() { + return Err(format!( + "palette PNG {} has zero pixels; need at least one colour", + path.display() + )); + } + + // Pad with the universal so every slot index is valid. + while unique.len() < 16 { + unique.push(unique[0]); + } + + // Assemble the 32-byte blob. The first byte of every 4-byte + // sub-palette is forced to the shared universal (`unique[0]`) + // to avoid the PPU mirror bug described above. + let universal = unique[0]; + let mut out = [0u8; 32]; + for slot in 0..8 { + let base = slot * 4; + // The unique list is 16 bytes long but arranged as 4 + // background sub-palettes of 4 bytes. We reuse the same + // 16-entry layout for sprites so a tiny PNG still produces + // a fully-filled 32-byte blob. The universal byte overrides + // whatever happened to land at index `base`. + let slot_idx = slot % 4; // 4 bg + 4 sp -> same 4 source slots + let src = slot_idx * 4; + out[base] = universal; + out[base + 1] = unique[src + 1]; + out[base + 2] = unique[src + 2]; + out[base + 3] = unique[src + 3]; + } + Ok(out) +} + /// 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; @@ -242,6 +327,90 @@ mod tests { assert_eq!(color_name_to_index(""), None); } + #[test] + fn png_to_palette_dedupes_and_pads() { + // Build a 4×1 PNG with four known NES colours, save it to + // a tempfile, and verify `png_to_palette` pulls them back + // out deterministically. We use pure primaries so the + // `nearest_nes_color` mapping is unambiguous. + use image::{Rgb, RgbImage}; + + let mut img = RgbImage::new(4, 1); + // $0F (black), $16 (red), $19 (green), $11 (blue) — + // picked to be the nearest master-palette entries for + // these pure primaries. `nearest_nes_color` does the + // actual lookup at read time so the test doesn't need + // to hard-code the exact RGB. + img.put_pixel(0, 0, Rgb([0, 0, 0])); + img.put_pixel(1, 0, Rgb([248, 0, 0])); + img.put_pixel(2, 0, Rgb([0, 168, 0])); + img.put_pixel(3, 0, Rgb([0, 0, 200])); + + let dir = std::env::temp_dir(); + let path = dir.join("nescript_png_to_palette_test.png"); + img.save(&path).unwrap(); + + let blob = png_to_palette(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + // Expected colours recovered via the same mapper. + let e0 = nearest_nes_color(0, 0, 0); + let e1 = nearest_nes_color(248, 0, 0); + let e2 = nearest_nes_color(0, 168, 0); + let e3 = nearest_nes_color(0, 0, 200); + + // Sub-palette 0 = [universal, red, green, blue]. + assert_eq!(blob[0], e0); + assert_eq!(blob[1], e1); + assert_eq!(blob[2], e2); + assert_eq!(blob[3], e3); + // Every sub-palette's first byte is the shared universal + // so the PPU mirror doesn't wipe `$3F00` at runtime. + for slot in 0..8usize { + assert_eq!(blob[slot * 4], e0, "slot {slot} universal mismatch"); + } + } + + #[test] + fn png_to_palette_rejects_too_many_colours() { + // A PNG with 17+ distinct NES master-palette indices must + // be rejected: 16 is the hard cap. We pick pixels at the + // exact RGB values of 17 different NES master palette + // entries so the `nearest_nes_color` lookup produces 17 + // distinct indices deterministically (rather than hoping + // a gradient happens to hit enough unique slots). + use image::{Rgb, RgbImage}; + + // Indices carefully chosen to be well-separated so none + // map to the same NES index as another. The NES master + // palette has several near-duplicate entries in row 3, + // so we stay in rows 0-2 where every entry is distinct. + let indices: [usize; 17] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x11, + 0x16, 0x19, 0x21, + ]; + let mut img = RgbImage::new(indices.len() as u32, 1); + for (x, &idx) in indices.iter().enumerate() { + let (r, g, b) = NES_COLORS[idx]; + img.put_pixel(x as u32, 0, Rgb([r, g, b])); + } + let dir = std::env::temp_dir(); + let path = dir.join("nescript_png_to_palette_toomany.png"); + img.save(&path).unwrap(); + let err = png_to_palette(&path).unwrap_err(); + let _ = std::fs::remove_file(&path); + assert!( + err.contains("more than 16 unique"), + "unexpected error: {err}" + ); + } + + #[test] + fn png_to_palette_missing_file_errors() { + let err = png_to_palette(std::path::Path::new("/nope/does/not/exist.png")).unwrap_err(); + assert!(err.contains("failed to open")); + } + #[test] fn every_returned_index_is_in_master_palette_range() { for name in [ diff --git a/src/assets/resolve.rs b/src/assets/resolve.rs index a74d4e5..b2d2374 100644 --- a/src/assets/resolve.rs +++ b/src/assets/resolve.rs @@ -113,34 +113,65 @@ pub fn resolve_sprites(program: &Program, source_dir: &Path) -> Result Vec { - program - .palettes - .iter() - .map(|p| { +/// +/// Each declaration can take one of three shapes: +/// - `colors: [...]` flat byte array — shorter than 32 is zero-padded. +/// - grouped `universal / bg0..sp3` form — already assembled into +/// `colors` by the parser. +/// - `@palette("file.png")` — decoded on the fly via +/// [`crate::assets::png_to_palette`], which maps RGB pixels to +/// nearest NES master-palette indices and enforces the universal +/// first-byte convention. +/// +/// `source_dir` is the base for PNG-relative paths — callers typically +/// pass the source file's parent directory so `@palette("art/main.png")` +/// resolves next to the `.ne` file, the same convention the sprite +/// resolver uses. +pub fn resolve_palettes(program: &Program, source_dir: &Path) -> Result, String> { + let mut out = Vec::with_capacity(program.palettes.len()); + for p in &program.palettes { + let colors = if let Some(png_path) = &p.png_source { + let full_path = source_dir.join(png_path); + crate::assets::png_to_palette(&full_path) + .map_err(|e| format!("palette '{}' PNG source: {e}", p.name))? + } else { let mut colors = [0u8; 32]; for (i, c) in p.colors.iter().enumerate().take(32) { colors[i] = *c; } - PaletteData { - name: p.name.clone(), - colors, - } - }) - .collect() + colors + }; + out.push(PaletteData { + name: p.name.clone(), + colors, + }); + } + Ok(out) } /// Resolve all `background Name { ... }` declarations in `program` /// into fixed-size 960-byte tile maps and 64-byte attribute tables. /// Declarations shorter than the maximum are zero-padded. -#[must_use] -pub fn resolve_backgrounds(program: &Program) -> Vec { - program - .backgrounds - .iter() - .map(|b| { +/// +/// When a declaration uses the PNG shortcut form +/// (`@nametable("file.png")`), the image is decoded via +/// [`crate::assets::png_to_nametable`] into a 960-byte tile index +/// table + 64-byte attribute table. The CHR data itself is **not** +/// generated automatically — callers are expected to provide matching +/// CHR via a sprite / `@chr(...)` declaration in the same order the +/// deduplicator walks the PNG (row-major unique-first). This +/// limitation is tracked in `docs/future-work.md`. +pub fn resolve_backgrounds( + program: &Program, + source_dir: &Path, +) -> Result, String> { + let mut out = Vec::with_capacity(program.backgrounds.len()); + for b in &program.backgrounds { + let (tiles, attrs) = if let Some(png_path) = &b.png_source { + let full_path = source_dir.join(png_path); + crate::assets::png_to_nametable(&full_path) + .map_err(|e| format!("background '{}' PNG source: {e}", b.name))? + } else { let mut tiles = [0u8; 960]; for (i, t) in b.tiles.iter().enumerate().take(960) { tiles[i] = *t; @@ -149,13 +180,15 @@ pub fn resolve_backgrounds(program: &Program) -> Vec { for (i, a) in b.attributes.iter().enumerate().take(64) { attrs[i] = *a; } - BackgroundData { - name: b.name.clone(), - tiles, - attrs, - } - }) - .collect() + (tiles, attrs) + }; + out.push(BackgroundData { + name: b.name.clone(), + tiles, + attrs, + }); + } + Ok(out) } #[cfg(test)] @@ -274,9 +307,10 @@ mod tests { program.palettes.push(PaletteDecl { name: "Cool".to_string(), colors: vec![0x0F, 0x01, 0x11, 0x21], + png_source: None, span: Span::dummy(), }); - let resolved = resolve_palettes(&program); + let resolved = resolve_palettes(&program, Path::new(".")).unwrap(); assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].name, "Cool"); assert_eq!(resolved[0].colors.len(), 32); @@ -296,14 +330,66 @@ mod tests { program.palettes.push(PaletteDecl { name: "Big".to_string(), colors: (0u8..40).collect(), + png_source: None, span: Span::dummy(), }); - let resolved = resolve_palettes(&program); + let resolved = resolve_palettes(&program, Path::new(".")).unwrap(); assert_eq!(resolved[0].colors.len(), 32); assert_eq!(resolved[0].colors[0], 0); assert_eq!(resolved[0].colors[31], 31); } + #[test] + fn resolve_palette_from_png() { + // A 2×1 PNG with pure black and pure red goes through the + // PNG-sourced path. We write the fixture to a tempdir, point + // the resolver at it, and verify the universal-byte rule + // (every sub-palette's first byte = first unique colour). + use image::{Rgb, RgbImage}; + + let dir = std::env::temp_dir(); + let png_path = dir.join("nescript_resolve_palette_png.png"); + let mut img = RgbImage::new(2, 1); + img.put_pixel(0, 0, Rgb([0, 0, 0])); + img.put_pixel(1, 0, Rgb([248, 0, 0])); + img.save(&png_path).unwrap(); + + let mut program = blank_program(); + program.palettes.push(PaletteDecl { + name: "Fromimg".to_string(), + colors: Vec::new(), + png_source: Some(png_path.file_name().unwrap().to_string_lossy().to_string()), + span: Span::dummy(), + }); + let resolved = resolve_palettes(&program, &dir).unwrap(); + let _ = std::fs::remove_file(&png_path); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].colors.len(), 32); + // Every sub-palette slot's first byte is the universal. + let universal = resolved[0].colors[0]; + for slot in 0..8 { + assert_eq!(resolved[0].colors[slot * 4], universal); + } + } + + #[test] + fn resolve_palette_missing_png_is_error() { + // Unlike the sprite resolver (which silently skips missing + // `@binary` / `@chr` files to keep documentation-only + // declarations cheap), a missing PNG palette is a hard + // failure — the declaration has no fallback bytes to fall + // back on. The error bubbles up with the palette's name. + let mut program = blank_program(); + program.palettes.push(PaletteDecl { + name: "Missing".to_string(), + colors: Vec::new(), + png_source: Some("nonexistent_palette.png".to_string()), + span: Span::dummy(), + }); + let err = resolve_palettes(&program, Path::new(".")).unwrap_err(); + assert!(err.contains("palette 'Missing' PNG source")); + } + #[test] fn resolve_background_pads_tiles_and_attrs() { let mut program = blank_program(); @@ -311,9 +397,10 @@ mod tests { name: "Stage".to_string(), tiles: vec![1, 2, 3], attributes: vec![0xFF], + png_source: None, span: Span::dummy(), }); - let resolved = resolve_backgrounds(&program); + let resolved = resolve_backgrounds(&program, Path::new(".")).unwrap(); assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].name, "Stage"); assert_eq!(resolved[0].tiles.len(), 960); @@ -326,4 +413,81 @@ mod tests { assert_eq!(resolved[0].tiles_label(), "__bg_tiles_Stage"); assert_eq!(resolved[0].attrs_label(), "__bg_attrs_Stage"); } + + #[test] + fn resolve_background_from_png() { + // A 256×240 PNG with a simple horizontal-stripe pattern so + // the tile deduplicator produces a predictable number of + // tiles. We flag the tile count rather than exact bytes + // because the hashing is implementation-defined. + use image::{Rgb, RgbImage}; + + let dir = std::env::temp_dir(); + let png_path = dir.join("nescript_resolve_bg_png.png"); + let mut img = RgbImage::new(256, 240); + for y in 0..240u32 { + let band = (y / 16) as u8; + for x in 0..256u32 { + let c = band.wrapping_mul(30); + img.put_pixel(x, y, Rgb([c, c, c])); + } + } + img.save(&png_path).unwrap(); + + let mut program = blank_program(); + program.backgrounds.push(BackgroundDecl { + name: "Fromimg".to_string(), + tiles: Vec::new(), + attributes: Vec::new(), + png_source: Some(png_path.file_name().unwrap().to_string_lossy().to_string()), + span: Span::dummy(), + }); + let resolved = resolve_backgrounds(&program, &dir).unwrap(); + let _ = std::fs::remove_file(&png_path); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].tiles.len(), 960); + assert_eq!(resolved[0].attrs.len(), 64); + // Horizontal bands mean every column's tile in a given row + // is the same — the 32 tiles of row 0 are all tile index 0. + assert!( + resolved[0].tiles[..32] + .iter() + .all(|&t| t == resolved[0].tiles[0]), + "row 0 should be a single repeating tile" + ); + } + + #[test] + fn resolve_background_wrong_size_png_is_error() { + // Nametable PNGs must be exactly 256×240. Any other size + // is a hard failure with the background's name attached. + use image::{Rgb, RgbImage}; + + let dir = std::env::temp_dir(); + let png_path = dir.join("nescript_resolve_bg_wrong_size.png"); + let mut img = RgbImage::new(128, 128); + for p in img.pixels_mut() { + *p = Rgb([0, 0, 0]); + } + img.save(&png_path).unwrap(); + + let mut program = blank_program(); + program.backgrounds.push(BackgroundDecl { + name: "Oops".to_string(), + tiles: Vec::new(), + attributes: Vec::new(), + png_source: Some(png_path.file_name().unwrap().to_string_lossy().to_string()), + span: Span::dummy(), + }); + let err = resolve_backgrounds(&program, &dir).unwrap_err(); + let _ = std::fs::remove_file(&png_path); + assert!( + err.contains("background 'Oops' PNG source"), + "unexpected error: {err}" + ); + assert!( + err.contains("256") || err.contains("240"), + "unexpected error: {err}" + ); + } } diff --git a/src/main.rs b/src/main.rs index ca75761..fc6a0c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,14 @@ use clap::Parser; +use std::io::Write as _; use std::path::{Path, PathBuf}; use nescript::analyzer; use nescript::assets; +use nescript::assets::{BackgroundData, PaletteData}; use nescript::codegen::IrCodeGen; use nescript::errors::render_diagnostics; use nescript::ir; -use nescript::linker::{render_mlb, render_source_map, Linker, PrgBank}; +use nescript::linker::{render_mlb, render_source_map, LinkedRom, Linker, PrgBank}; use nescript::optimizer; use nescript::parser::ast::BankType; @@ -125,45 +127,64 @@ fn main() { } } -/// Print a human-readable memory map of variable allocations. -/// Entries are sorted by address and labelled with their scope -/// (zero-page vs RAM). -fn print_memory_map(analysis: &nescript::analyzer::AnalysisResult) { +/// Write a human-readable memory map of variable allocations to +/// `w`. Entries are sorted by address and labelled with their scope +/// (zero-page vs RAM). When `link_result` is `Some(_)`, a PRG ROM +/// section listing each palette and background data blob's CPU +/// address + size is appended — the CLI passes the linker result +/// whenever it's available, which is always unless the caller is +/// unit-testing the variable-only path. +/// +/// This function is factored out of the direct `println!` path so +/// tests can drive it against an in-memory buffer and assert on the +/// rendered output. +fn write_memory_map( + w: &mut impl std::io::Write, + analysis: &nescript::analyzer::AnalysisResult, + link_result: Option<&LinkedRom>, + palettes: &[PaletteData], + backgrounds: &[BackgroundData], +) -> std::io::Result<()> { let mut allocs: Vec<_> = analysis.var_allocations.iter().collect(); allocs.sort_by_key(|a| a.address); - println!("=== NEScript Memory Map ==="); - println!("Zero Page ($00-$FF):"); - println!(" $00-$0F [SYSTEM] reserved (frame flag, input, state, params, scratch)"); + writeln!(w, "=== NEScript Memory Map ===")?; + writeln!(w, "Zero Page ($00-$FF):")?; + writeln!( + w, + " $00-$0F [SYSTEM] reserved (frame flag, input, state, params, scratch)" + )?; for a in allocs.iter().filter(|a| a.address < 0x100) { if a.size == 1 { - println!(" ${:04X} [USER] {} (u8)", a.address, a.name); + writeln!(w, " ${:04X} [USER] {} (u8)", a.address, a.name)?; } else { - println!( + writeln!( + w, " ${:04X}-${:04X} [USER] {} ({} bytes)", a.address, a.address + a.size - 1, a.name, a.size - ); + )?; } } let ram_allocs: Vec<_> = allocs.iter().filter(|a| a.address >= 0x100).collect(); if !ram_allocs.is_empty() { - println!("\nRAM ($0200-$07FF):"); - println!(" $0200-$02FF [SYSTEM] OAM shadow buffer"); + writeln!(w, "\nRAM ($0200-$07FF):")?; + writeln!(w, " $0200-$02FF [SYSTEM] OAM shadow buffer")?; for a in &ram_allocs { if a.size == 1 { - println!(" ${:04X} [USER] {} (u8)", a.address, a.name); + writeln!(w, " ${:04X} [USER] {} (u8)", a.address, a.name)?; } else { - println!( + writeln!( + w, " ${:04X}-${:04X} [USER] {} ({} bytes)", a.address, a.address + a.size - 1, a.name, a.size - ); + )?; } } } @@ -179,9 +200,74 @@ fn print_memory_map(analysis: &nescript::analyzer::AnalysisResult) { .filter(|a| a.address >= 0x300) .map(|a| a.size) .sum(); - println!(); - println!("Zero Page: {zp_used}/128 bytes used"); - println!("Main RAM: {ram_used}/1280 bytes used"); + writeln!(w)?; + writeln!(w, "Zero Page: {zp_used}/128 bytes used")?; + writeln!(w, "Main RAM: {ram_used}/1280 bytes used")?; + + // PRG ROM: palette (32 B each) and background (960 + 64 B each) + // data blobs. The linker emits each one under a well-known + // label — `__palette_`, `__bg_tiles_`, + // `__bg_attrs_` — so we look those up in the label table + // and render the CPU address + byte count. + if let Some(link) = link_result { + if !palettes.is_empty() || !backgrounds.is_empty() { + writeln!(w, "\nPRG ROM data blobs:")?; + let mut total: u32 = 0; + for pal in palettes { + let label = pal.label(); + match link.labels.get(&label).copied() { + Some(addr) => { + writeln!(w, " ${addr:04X} [PALETTE] {} (32 bytes)", pal.name)?; + } + None => { + writeln!(w, " (unlinked) [PALETTE] {} (32 bytes)", pal.name)?; + } + } + total += 32; + } + for bg in backgrounds { + let tiles_label = bg.tiles_label(); + let attrs_label = bg.attrs_label(); + match link.labels.get(&tiles_label).copied() { + Some(addr) => { + writeln!(w, " ${addr:04X} [BG-TILES] {} (960 bytes)", bg.name)?; + } + None => { + writeln!(w, " (unlinked) [BG-TILES] {} (960 bytes)", bg.name)?; + } + } + match link.labels.get(&attrs_label).copied() { + Some(addr) => { + writeln!(w, " ${addr:04X} [BG-ATTRS] {} (64 bytes)", bg.name)?; + } + None => { + writeln!(w, " (unlinked) [BG-ATTRS] {} (64 bytes)", bg.name)?; + } + } + total += 960 + 64; + } + writeln!(w, "\nPRG ROM data total: {total} bytes")?; + } + } + + Ok(()) +} + +/// Print a human-readable memory map of variable allocations. Thin +/// wrapper around [`write_memory_map`] that drives stdout; tests +/// call `write_memory_map` directly against a `Vec`. +fn print_memory_map( + analysis: &nescript::analyzer::AnalysisResult, + link_result: Option<&LinkedRom>, + palettes: &[PaletteData], + backgrounds: &[BackgroundData], +) { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + // Infallible: stdout writes only return Err on broken pipes, + // which is the caller's problem. + let _ = write_memory_map(&mut handle, analysis, link_result, palettes, backgrounds); + let _ = handle.flush(); } /// Print a human-readable call graph of the analyzed program. @@ -312,10 +398,6 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { print!("{}", ir_program.pretty()); } - if memory_map { - print_memory_map(&analysis); - } - if call_graph { print_call_graph(&analysis); } @@ -338,11 +420,16 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { })?; // Resolve palette and background declarations into fixed-size - // ROM data blobs. These are purely compile-time — the byte - // arrays came from the parser and all the analyzer validation - // has already run. - let palettes = assets::resolve_palettes(&program); - let backgrounds = assets::resolve_backgrounds(&program); + // ROM data blobs. These are purely compile-time — either the + // parser handed us an inline byte array, or the declaration + // named a PNG to decode relative to the source file's directory + // (`@palette("art/main.png")` / `@nametable("levels/1.png")`). + let palettes = assets::resolve_palettes(&program, source_dir).map_err(|e| { + eprintln!("error: {e}"); + })?; + let backgrounds = assets::resolve_backgrounds(&program, source_dir).map_err(|e| { + eprintln!("error: {e}"); + })?; // IR-based code generation. Lower → optimize → emit 6502. // @@ -403,6 +490,12 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { &switchable_banks, ); + // Memory map is reported after linking so the palette / + // background PRG ROM addresses are available in `link_result.labels`. + if memory_map { + print_memory_map(&analysis, Some(&link_result), &palettes, &backgrounds); + } + if let Some(path) = symbols_path { let mlb = render_mlb(&link_result, &analysis.var_allocations); std::fs::write(path, mlb).map_err(|e| { @@ -456,3 +549,105 @@ fn check(input: &PathBuf) -> Result<(), ()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use nescript::analyzer::AnalysisResult; + use nescript::linker::LinkedRom; + use std::collections::HashMap; + + fn empty_analysis() -> AnalysisResult { + AnalysisResult { + symbols: HashMap::new(), + var_allocations: Vec::new(), + diagnostics: Vec::new(), + call_graph: HashMap::new(), + max_depths: HashMap::new(), + } + } + + #[test] + fn write_memory_map_without_link_result_covers_variable_path() { + // Without a link result (e.g. the unit-test path that + // only wants to inspect the variable allocator) the output + // should still render the Zero Page / RAM sections and the + // summary lines. No PRG ROM section appears because there + // are no linked labels to point at. + let analysis = empty_analysis(); + let mut buf = Vec::new(); + write_memory_map(&mut buf, &analysis, None, &[], &[]).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("=== NEScript Memory Map ===")); + assert!(s.contains("Zero Page")); + assert!(s.contains("0/128 bytes used")); + assert!(!s.contains("PRG ROM data blobs")); + } + + #[test] + fn write_memory_map_reports_palette_and_background_rom_addresses() { + // With palettes and backgrounds plus a faked LinkedRom + // carrying matching labels, the PRG ROM section should + // render each blob's CPU address + size and a grand total. + let analysis = empty_analysis(); + let palettes = vec![PaletteData { + name: "Main".to_string(), + colors: [0u8; 32], + }]; + let backgrounds = vec![BackgroundData { + name: "Stage".to_string(), + tiles: [0u8; 960], + attrs: [0u8; 64], + }]; + let mut labels = HashMap::new(); + labels.insert("__palette_Main".to_string(), 0xC100); + labels.insert("__bg_tiles_Stage".to_string(), 0xC200); + labels.insert("__bg_attrs_Stage".to_string(), 0xC5C0); + let link = LinkedRom { + rom: Vec::new(), + labels, + fixed_bank_file_offset: 16, + }; + let mut buf = Vec::new(); + write_memory_map(&mut buf, &analysis, Some(&link), &palettes, &backgrounds).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("PRG ROM data blobs:")); + assert!( + s.contains("$C100") && s.contains("[PALETTE] Main"), + "missing palette line in: {s}" + ); + assert!( + s.contains("$C200") && s.contains("[BG-TILES] Stage"), + "missing bg-tiles line in: {s}" + ); + assert!( + s.contains("$C5C0") && s.contains("[BG-ATTRS] Stage"), + "missing bg-attrs line in: {s}" + ); + // 32 (palette) + 960 + 64 (background) = 1056. + assert!(s.contains("1056 bytes"), "missing total in: {s}"); + } + + #[test] + fn write_memory_map_marks_unlinked_blobs() { + // If a palette's label isn't in `link.labels` (e.g. the + // linker skipped it for some reason), we still emit the + // line but mark it "(unlinked)" so the user knows the + // address isn't available. + let analysis = empty_analysis(); + let palettes = vec![PaletteData { + name: "Ghost".to_string(), + colors: [0u8; 32], + }]; + let link = LinkedRom { + rom: Vec::new(), + labels: HashMap::new(), + fixed_bank_file_offset: 16, + }; + let mut buf = Vec::new(); + write_memory_map(&mut buf, &analysis, Some(&link), &palettes, &[]).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("(unlinked)"), "missing unlinked marker in: {s}"); + assert!(s.contains("[PALETTE] Ghost")); + } +} diff --git a/src/parser/ast.rs b/src/parser/ast.rs index a9d5567..ed2dc1b 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -69,6 +69,12 @@ pub struct SpriteDecl { pub struct PaletteDecl { pub name: String, pub colors: Vec, + /// Optional PNG source — when set, the analyzer leaves `colors` + /// empty and the asset resolver decodes the PNG into a 32-byte + /// palette blob at compile time. Mutually exclusive with + /// `colors` being non-empty in practice (the parser never fills + /// both). + pub png_source: Option, pub span: Span, } @@ -87,6 +93,11 @@ pub struct BackgroundDecl { pub name: String, pub tiles: Vec, pub attributes: Vec, + /// Optional PNG source for `background Name @nametable("file.png")`. + /// When set, the asset resolver decodes the PNG into tile + attribute + /// tables at compile time. Mutually exclusive with inline + /// `tiles` / `attributes` (the parser never fills both). + pub png_source: Option, pub span: Span, } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 76fedbc..acd1026 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -946,6 +946,22 @@ impl Parser { let start = self.current_span(); self.expect(&TokenKind::KwPalette)?; let (name, _) = self.expect_ident()?; + + // Shortcut form: `palette Name @palette("file.png")` — the PNG + // is decoded at asset-resolve time into a 32-byte blob. No + // `{ ... }` body follows. The in-source `@palette(...)` token + // is distinct from the `palette` block keyword (they're + // different TokenKinds); don't confuse them. + if *self.peek() == TokenKind::At { + let png_path = self.parse_named_asset_path("palette")?; + return Ok(PaletteDecl { + name, + colors: Vec::new(), + png_source: Some(png_path), + span: Span::new(start.file_id, start.start, self.current_span().end), + }); + } + self.expect(&TokenKind::LBrace)?; // Flat-form output. @@ -1078,10 +1094,72 @@ impl Parser { Ok(PaletteDecl { name, colors, + png_source: None, span: Span::new(start.file_id, start.start, self.current_span().end), }) } + /// Parse a `@kind("path")` asset directive when the caller has + /// already matched `@` at `self.peek()`. Verifies that `kind` is + /// the expected identifier (e.g. `palette` or `nametable`) and + /// returns the string literal inside the parentheses. + /// + /// Note: `palette` and `background` are reserved keywords in the + /// lexer so `@palette` tokenises as `At` + `KwPalette` rather + /// than `At` + `Ident("palette")`. We match both shapes so the + /// directive kind can collide with a keyword without the user + /// having to worry about it. `nametable` isn't a keyword today + /// so it comes through as an `Ident`; if it ever becomes one, + /// this branch will still work. + fn parse_named_asset_path(&mut self, expected: &str) -> Result { + self.expect(&TokenKind::At)?; + let kind_span = self.current_span(); + let kind = match self.peek().clone() { + TokenKind::Ident(name) => { + self.advance(); + name + } + TokenKind::KwPalette => { + self.advance(); + "palette".to_string() + } + TokenKind::KwBackground => { + self.advance(); + "background".to_string() + } + other => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("expected '@{expected}(\"...\")', found '@{other}'"), + kind_span, + )); + } + }; + if kind != expected { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("expected '@{expected}(\"...\")', found '@{kind}'"), + kind_span, + )); + } + 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 in '@{expected}(...)', found '{}'", + self.peek() + ), + self.current_span(), + )); + }; + self.expect(&TokenKind::RParen)?; + Ok(path) + } + /// Parse a single NES colour value: either a `u8` integer literal or /// an identifier resolved via /// [`crate::assets::color_name_to_index`]. Used by palette @@ -1189,6 +1267,22 @@ impl Parser { let start = self.current_span(); self.expect(&TokenKind::KwBackground)?; let (name, _) = self.expect_ident()?; + + // Shortcut form: `background Name @nametable("file.png")` — + // the PNG is decoded at asset-resolve time into a 32×30 tile + // map plus a 64-byte attribute table. No `{ ... }` body + // follows. + if *self.peek() == TokenKind::At { + let png_path = self.parse_named_asset_path("nametable")?; + return Ok(BackgroundDecl { + name, + tiles: Vec::new(), + attributes: Vec::new(), + png_source: Some(png_path), + span: Span::new(start.file_id, start.start, self.current_span().end), + }); + } + self.expect(&TokenKind::LBrace)?; // Raw-form scratch. @@ -1359,6 +1453,7 @@ impl Parser { name, tiles, attributes, + png_source: None, span: Span::new(start.file_id, start.start, self.current_span().end), }) } diff --git a/src/parser/tests.rs b/src/parser/tests.rs index b8bc75d..c25f765 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -581,6 +581,63 @@ fn parse_background_decl_with_attributes() { assert_eq!(prog.backgrounds[0].attributes, vec![0xFF, 0x55]); } +#[test] +fn parse_palette_decl_from_png_source() { + // Shortcut form: `palette Name @palette("file.png")` sets + // `png_source` and leaves `colors` empty. The asset resolver + // decodes the actual bytes at compile time. + let src = r#" + game "Test" { mapper: NROM } + palette Main @palette("art/main.png") + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.palettes.len(), 1); + assert_eq!(prog.palettes[0].name, "Main"); + assert!(prog.palettes[0].colors.is_empty()); + assert_eq!(prog.palettes[0].png_source.as_deref(), Some("art/main.png")); +} + +#[test] +fn parse_palette_decl_rejects_wrong_directive() { + // The shortcut form insists the directive be `@palette`, not + // some other `@foo`. We want a clear error the first time + // someone confuses `@chr` / `@palette` / `@nametable`. + let src = r#" + game "Test" { mapper: NROM } + palette Main @chr("art/main.png") + on frame { wait_frame } + start Main + "#; + let (_, diags) = parse(src); + assert!( + diags + .iter() + .any(|d: &crate::errors::Diagnostic| d.message.contains("@palette")), + "expected diagnostic about @palette, got: {diags:?}" + ); +} + +#[test] +fn parse_background_decl_from_png_source() { + let src = r#" + game "Test" { mapper: NROM } + background Main @nametable("levels/stage1.png") + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.backgrounds.len(), 1); + assert_eq!(prog.backgrounds[0].name, "Main"); + assert!(prog.backgrounds[0].tiles.is_empty()); + assert!(prog.backgrounds[0].attributes.is_empty()); + assert_eq!( + prog.backgrounds[0].png_source.as_deref(), + Some("levels/stage1.png") + ); +} + #[test] fn parse_background_decl_without_attributes() { let src = r#" diff --git a/tests/integration_test.rs b/tests/integration_test.rs index c1f069e..ae408b3 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1104,8 +1104,10 @@ fn compile_banked(source: &str) -> Vec { .expect("sprite resolution should succeed"); let sfx = assets::resolve_sfx(&program).expect("sfx resolution should succeed"); let music = assets::resolve_music(&program).expect("music resolution should succeed"); - let palettes = assets::resolve_palettes(&program); - let backgrounds = assets::resolve_backgrounds(&program); + let palettes = assets::resolve_palettes(&program, Path::new(".")) + .expect("palette resolution should succeed"); + let backgrounds = assets::resolve_backgrounds(&program, Path::new(".")) + .expect("background resolution should succeed"); let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) .with_sprites(&sprites) @@ -1847,6 +1849,98 @@ fn e2e_banked_chr_rom_is_preserved() { assert_ne!(&rom[chr_start..chr_start + 16], &[0u8; 16]); } +#[test] +fn e2e_png_palette_source_compiles_and_splices_bytes_into_prg() { + // Full pipeline: parse `palette Main @palette("fixture.png")`, + // resolve the PNG into a 32-byte blob via the asset resolver, + // and verify the resulting bytes land in PRG ROM. We write a + // 2×1 test fixture (pure black + pure red) to a tempdir so + // the test is self-contained and deterministic. + use image::{Rgb, RgbImage}; + use nescript::codegen::IrCodeGen; + use nescript::linker::LinkedRom; + + let dir = std::env::temp_dir(); + let png_path = dir.join("nescript_e2e_palette.png"); + let mut img = RgbImage::new(2, 1); + img.put_pixel(0, 0, Rgb([0, 0, 0])); + img.put_pixel(1, 0, Rgb([248, 0, 0])); + img.save(&png_path).unwrap(); + + let source = r#" + game "PngPalette" { mapper: NROM } + palette Main @palette("nescript_e2e_palette.png") + on frame { wait_frame } + start Main + "#; + + let (program, diags) = nescript::parser::parse(source); + assert!(diags.is_empty(), "unexpected parse errors: {diags:?}"); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + assert!(analysis.diagnostics.iter().all(|d| !d.is_error())); + + // Resolve with the tempdir as the source dir so the + // relative PNG path lands on the fixture we just wrote. + let palettes = + assets::resolve_palettes(&program, &dir).expect("palette resolution should succeed"); + let backgrounds = assets::resolve_backgrounds(&program, &dir).expect("bg ok"); + assert_eq!(palettes.len(), 1); + assert_eq!(palettes[0].name, "Main"); + // First two bytes should map via `nearest_nes_color` to black + // and a red-ish index. We re-run the mapper so the test + // doesn't hard-code the NES palette table. + let e_black = assets::nearest_nes_color(0, 0, 0); + let e_red = assets::nearest_nes_color(248, 0, 0); + assert_eq!(palettes[0].colors[0], e_black); + assert_eq!(palettes[0].colors[1], e_red); + // Every sub-palette first byte equals the universal. + for slot in 0..8 { + assert_eq!(palettes[0].colors[slot * 4], e_black); + } + + // Link the program and verify the 32-byte blob shows up in PRG + // ROM at the linker-assigned label. + let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap(); + let sfx = assets::resolve_sfx(&program).unwrap(); + let music = assets::resolve_music(&program).unwrap(); + let mut ir_program = nescript::ir::lower(&program, &analysis); + nescript::optimizer::optimize(&mut ir_program); + let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music); + let mut instructions = codegen.generate(&ir_program); + nescript::codegen::peephole::optimize(&mut instructions); + + let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper); + let link: LinkedRom = linker.link_banked_with_ppu_detailed( + &instructions, + &sprites, + &sfx, + &music, + &palettes, + &backgrounds, + &[], + ); + let pal_label = palettes[0].label(); + let pal_addr = link + .labels + .get(&pal_label) + .copied() + .expect("palette label should be emitted"); + // Translate the CPU address into a byte offset inside the + // fixed bank. NROM: the fixed bank starts at file offset 16 + // (past the iNES header) and maps to CPU $C000-$FFFF. + let rom_offset = link.fixed_bank_file_offset + (pal_addr as usize - 0xC000); + let prg_bytes = &link.rom[rom_offset..rom_offset + 32]; + assert_eq!( + prg_bytes, &palettes[0].colors, + "PRG ROM should contain the decoded palette blob verbatim" + ); + + let _ = std::fs::remove_file(&png_path); +} + /// Same as `compile_banked` but lets the caller toggle whether the IR /// optimizer runs. Used to cover the `--no-opt` CLI flag: compiling /// with the optimizer disabled must still produce a valid iNES ROM. @@ -1874,8 +1968,10 @@ fn compile_banked_with_opts(source: &str, optimize: bool) -> Vec { .expect("sprite resolution should succeed"); let sfx = assets::resolve_sfx(&program).expect("sfx resolution should succeed"); let music = assets::resolve_music(&program).expect("music resolution should succeed"); - let palettes = assets::resolve_palettes(&program); - let backgrounds = assets::resolve_backgrounds(&program); + let palettes = assets::resolve_palettes(&program, Path::new(".")) + .expect("palette resolution should succeed"); + let backgrounds = assets::resolve_backgrounds(&program, Path::new(".")) + .expect("background resolution should succeed"); let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) .with_sprites(&sprites) @@ -1979,8 +2075,10 @@ fn compile_with_debug_artifacts(source: &str, debug: bool) -> (Vec, String, .expect("sprite resolution should succeed"); let sfx = assets::resolve_sfx(&program).expect("sfx resolution should succeed"); let music = assets::resolve_music(&program).expect("music resolution should succeed"); - let palettes = assets::resolve_palettes(&program); - let backgrounds = assets::resolve_backgrounds(&program); + let palettes = assets::resolve_palettes(&program, Path::new(".")) + .expect("palette resolution should succeed"); + let backgrounds = assets::resolve_backgrounds(&program, Path::new(".")) + .expect("background resolution should succeed"); let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) .with_sprites(&sprites) @@ -2141,8 +2239,10 @@ fn debug_build_emits_bounds_check_halt_routine() { let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap(); let sfx = assets::resolve_sfx(&program).unwrap(); let music = assets::resolve_music(&program).unwrap(); - let palettes = assets::resolve_palettes(&program); - let backgrounds = assets::resolve_backgrounds(&program); + let palettes = assets::resolve_palettes(&program, Path::new(".")) + .expect("palette resolution should succeed"); + let backgrounds = assets::resolve_backgrounds(&program, Path::new(".")) + .expect("background resolution should succeed"); let mut cg_debug = IrCodeGen::new(&analysis.var_allocations, &ir_program) .with_sprites(&sprites)