mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +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
|
|
@ -108,8 +108,10 @@ fn compile_pipeline(source: &str, source_dir: &Path) -> Vec<u8> {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,130 @@ pub fn png_to_chr(path: &std::path::Path) -> Result<Vec<u8>, 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<Vec<u8>> 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<u8>> = 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<u8> = 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 [
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
colors
|
||||
};
|
||||
out.push(PaletteData {
|
||||
name: p.name.clone(),
|
||||
colors,
|
||||
});
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
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 {
|
||||
(tiles, attrs)
|
||||
};
|
||||
out.push(BackgroundData {
|
||||
name: b.name.clone(),
|
||||
tiles,
|
||||
attrs,
|
||||
});
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
251
src/main.rs
251
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_<name>`, `__bg_tiles_<name>`,
|
||||
// `__bg_attrs_<name>` — 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<u8>`.
|
||||
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<Vec<u8>, ()> {
|
|||
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<Vec<u8>, ()> {
|
|||
})?;
|
||||
|
||||
// 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<Vec<u8>, ()> {
|
|||
&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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@ pub struct SpriteDecl {
|
|||
pub struct PaletteDecl {
|
||||
pub name: String,
|
||||
pub colors: Vec<u8>,
|
||||
/// 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<String>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +93,11 @@ pub struct BackgroundDecl {
|
|||
pub name: String,
|
||||
pub tiles: Vec<u8>,
|
||||
pub attributes: Vec<u8>,
|
||||
/// 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<String>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, Diagnostic> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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#"
|
||||
|
|
|
|||
|
|
@ -1104,8 +1104,10 @@ fn compile_banked(source: &str) -> Vec<u8> {
|
|||
.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<u8> {
|
|||
.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<u8>, 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue