mirror of
https://github.com/imjasonh/nescript
synced 2026-07-15 04:35:37 +00:00
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.
This commit is contained in:
parent
b575921c8e
commit
8610aecdac
10 changed files with 1095 additions and 69 deletions
|
|
@ -113,34 +113,65 @@ pub fn resolve_sprites(program: &Program, source_dir: &Path) -> Result<Vec<Sprit
|
|||
|
||||
/// Resolve all `palette Name { ... }` declarations in `program` into
|
||||
/// 32-byte fixed-size blobs suitable for splicing into PRG ROM.
|
||||
/// Declarations with fewer than 32 colors are zero-padded.
|
||||
#[must_use]
|
||||
pub fn resolve_palettes(program: &Program) -> Vec<PaletteData> {
|
||||
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<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;
|
||||
}
|
||||
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<BackgroundData> {
|
||||
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<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;
|
||||
|
|
@ -149,13 +180,15 @@ pub fn resolve_backgrounds(program: &Program) -> Vec<BackgroundData> {
|
|||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue