1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00
nescript/src/assets/resolve.rs
Claude 8610aecdac
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting
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<u8>`. 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.
2026-04-14 03:01:32 +00:00

493 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::path::Path;
use crate::linker::SpriteData;
use crate::parser::ast::{AssetSource, Program};
/// Resolved palette data, ready for the linker to splice into PRG
/// ROM as a 32-byte data blob at the label returned by [`Self::label`].
/// Declarations shorter than 32 bytes are zero-padded so the runtime
/// can always push exactly 32 bytes to `$3F00-$3F1F`.
#[derive(Debug, Clone)]
pub struct PaletteData {
pub name: String,
/// Exactly 32 bytes. Index `i` is the value written to PPU
/// address `$3F00 + i`.
pub colors: [u8; 32],
}
impl PaletteData {
/// The ROM-level label under which the linker emits the 32-byte
/// blob. The IR codegen references this label when lowering
/// `set_palette Name`.
#[must_use]
pub fn label(&self) -> String {
format!("__palette_{}", self.name)
}
}
/// Resolved background data. `tiles` is the 960-byte nametable
/// (32 columns × 30 rows) and `attrs` is the 64-byte attribute
/// table. Both are zero-padded up from the declared sizes so the
/// runtime NMI helper can always push fixed-length data.
#[derive(Debug, Clone)]
pub struct BackgroundData {
pub name: String,
pub tiles: [u8; 960],
pub attrs: [u8; 64],
}
impl BackgroundData {
#[must_use]
pub fn tiles_label(&self) -> String {
format!("__bg_tiles_{}", self.name)
}
#[must_use]
pub fn attrs_label(&self) -> String {
format!("__bg_attrs_{}", self.name)
}
}
/// Resolve sprite declarations in a program into concrete CHR byte blobs and
/// assign each one a tile index in CHR ROM.
///
/// Tile index 0 is reserved for the built-in default smiley sprite, so user
/// sprites start at tile index 1. A single sprite declaration may occupy
/// multiple consecutive tiles if its CHR data is larger than 16 bytes.
///
/// `source_dir` is used as the base for `@binary` / `@chr` relative paths.
/// Missing files are silently skipped (not an error) so programs that
/// reference external assets for documentation purposes compile without
/// requiring the files to exist yet.
pub fn resolve_sprites(program: &Program, source_dir: &Path) -> Result<Vec<SpriteData>, String> {
let mut sprites = Vec::new();
// Tile index 0 is the built-in smiley; user sprites start at 1.
let mut next_tile: u8 = 1;
for sprite_decl in &program.sprites {
let chr_bytes = match &sprite_decl.chr_source {
AssetSource::Inline(bytes) => bytes.clone(),
AssetSource::Binary(path) => {
// Try to read raw bytes from the file. Missing files are
// skipped silently so declarations can reference assets
// that haven't been added yet.
let full_path = source_dir.join(path);
match std::fs::read(&full_path) {
Ok(bytes) => bytes,
Err(_) => continue,
}
}
AssetSource::Chr(path) => {
// PNG → CHR conversion. Missing files skipped silently.
let full_path = source_dir.join(path);
match crate::assets::png_to_chr(&full_path) {
Ok(bytes) => bytes,
Err(_) => continue,
}
}
};
// Each NES 8x8 tile is 16 bytes of 2-bitplane CHR data. A single
// sprite declaration can span multiple tiles when its CHR blob is
// longer than 16 bytes.
let tile_count = chr_bytes.len().div_ceil(16);
if tile_count == 0 {
continue;
}
if next_tile as usize + tile_count > 256 {
return Err(format!(
"sprite '{}' would exceed CHR ROM tile limit",
sprite_decl.name
));
}
sprites.push(SpriteData {
name: sprite_decl.name.clone(),
tile_index: next_tile,
chr_bytes,
});
next_tile += tile_count as u8;
}
Ok(sprites)
}
/// Resolve all `palette Name { ... }` declarations in `program` into
/// 32-byte fixed-size blobs suitable for splicing into PRG ROM.
///
/// 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<Vec<PaletteData>, 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;
}
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.
///
/// 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<Vec<BackgroundData>, 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;
}
let mut attrs = [0u8; 64];
for (i, a) in b.attributes.iter().enumerate().take(64) {
attrs[i] = *a;
}
(tiles, attrs)
};
out.push(BackgroundData {
name: b.name.clone(),
tiles,
attrs,
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lexer::Span;
use crate::parser::ast::{GameDecl, HeaderFormat, Mapper, Mirroring, SpriteDecl};
fn make_program(sprite: SpriteDecl) -> Program {
Program {
game: GameDecl {
name: "Test".to_string(),
mapper: Mapper::NROM,
mirroring: Mirroring::Horizontal,
header: HeaderFormat::Ines1,
span: Span::dummy(),
},
globals: Vec::new(),
constants: Vec::new(),
enums: Vec::new(),
structs: Vec::new(),
functions: Vec::new(),
states: Vec::new(),
sprites: vec![sprite],
palettes: Vec::new(),
backgrounds: Vec::new(),
sfx: Vec::new(),
music: Vec::new(),
banks: Vec::new(),
start_state: "Main".to_string(),
span: Span::dummy(),
}
}
#[test]
fn resolve_inline_sprite() {
let sprite = SpriteDecl {
name: "Player".to_string(),
chr_source: AssetSource::Inline(vec![0u8; 16]),
span: Span::dummy(),
};
let program = make_program(sprite);
let sprites = resolve_sprites(&program, Path::new(".")).unwrap();
assert_eq!(sprites.len(), 1);
assert_eq!(sprites[0].name, "Player");
assert_eq!(sprites[0].tile_index, 1);
assert_eq!(sprites[0].chr_bytes.len(), 16);
}
#[test]
fn resolve_binary_file_reads_bytes() {
let dir = std::env::temp_dir();
let file_path = dir.join("nescript_resolve_test.bin");
let bytes: Vec<u8> = (0x40..0x50).collect();
std::fs::write(&file_path, &bytes).unwrap();
let sprite = SpriteDecl {
name: "Tile".to_string(),
chr_source: AssetSource::Binary(
file_path.file_name().unwrap().to_string_lossy().to_string(),
),
span: Span::dummy(),
};
let program = make_program(sprite);
let sprites = resolve_sprites(&program, &dir).unwrap();
assert_eq!(sprites.len(), 1);
assert_eq!(sprites[0].chr_bytes, bytes);
let _ = std::fs::remove_file(&file_path);
}
#[test]
fn resolve_missing_binary_skipped() {
let sprite = SpriteDecl {
name: "Missing".to_string(),
chr_source: AssetSource::Binary("nonexistent.bin".to_string()),
span: Span::dummy(),
};
let program = make_program(sprite);
let sprites = resolve_sprites(&program, Path::new(".")).unwrap();
// Missing binary file → silently skipped
assert!(sprites.is_empty());
}
use crate::parser::ast::{BackgroundDecl, PaletteDecl};
fn blank_program() -> Program {
Program {
game: GameDecl {
name: "Test".to_string(),
mapper: Mapper::NROM,
mirroring: Mirroring::Horizontal,
header: HeaderFormat::Ines1,
span: Span::dummy(),
},
globals: Vec::new(),
constants: Vec::new(),
enums: Vec::new(),
structs: Vec::new(),
functions: Vec::new(),
states: Vec::new(),
sprites: Vec::new(),
palettes: Vec::new(),
backgrounds: Vec::new(),
sfx: Vec::new(),
music: Vec::new(),
banks: Vec::new(),
start_state: "Main".to_string(),
span: Span::dummy(),
}
}
#[test]
fn resolve_palette_zero_pads_to_32_bytes() {
let mut program = blank_program();
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, Path::new(".")).unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "Cool");
assert_eq!(resolved[0].colors.len(), 32);
assert_eq!(&resolved[0].colors[..4], &[0x0F, 0x01, 0x11, 0x21]);
// Remainder is zero-padded.
assert!(resolved[0].colors[4..].iter().all(|&b| b == 0));
assert_eq!(resolved[0].label(), "__palette_Cool");
}
#[test]
fn resolve_palette_truncates_beyond_32_bytes() {
// The analyzer rejects >32-byte palettes with E0201; at the
// resolve level we defensively truncate so downstream code
// always sees exactly 32 bytes. This lets bad input still
// produce a valid ROM structure for diagnostic purposes.
let mut program = blank_program();
program.palettes.push(PaletteDecl {
name: "Big".to_string(),
colors: (0u8..40).collect(),
png_source: None,
span: Span::dummy(),
});
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();
program.backgrounds.push(BackgroundDecl {
name: "Stage".to_string(),
tiles: vec![1, 2, 3],
attributes: vec![0xFF],
png_source: None,
span: Span::dummy(),
});
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);
assert_eq!(resolved[0].tiles[0], 1);
assert_eq!(resolved[0].tiles[2], 3);
assert!(resolved[0].tiles[3..].iter().all(|&b| b == 0));
assert_eq!(resolved[0].attrs.len(), 64);
assert_eq!(resolved[0].attrs[0], 0xFF);
assert!(resolved[0].attrs[1..].iter().all(|&b| b == 0));
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}"
);
}
}