1
0
Fork 0
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:
Claude 2026-04-14 03:01:32 +00:00
parent b575921c8e
commit 8610aecdac
No known key found for this signature in database
10 changed files with 1095 additions and 69 deletions

View file

@ -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)

View file

@ -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}"
);
}
}

View file

@ -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,
};

View file

@ -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 [

View file

@ -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}"
);
}
}

View file

@ -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"));
}
}

View file

@ -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,
}

View file

@ -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),
})
}

View file

@ -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#"

View file

@ -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)