mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +00:00
assets: auto-generate CHR data from @nametable() PNG sources
`background Foo @nametable("file.png")` previously decoded the PNG
into a tile-index table and an attribute table but left CHR
generation to the user — they had to supply matching tiles via a
separate `sprite Tileset @chr(...)` declaration in the same
deduplication order, which was both error-prone and the main thing
keeping the shortcut form from being a one-liner.
The CHR pipeline now closes the gap. `png_to_nametable_with_chr`
returns a `PngNametable` carrying the tile-index table, the
attribute table, *and* a per-tile CHR blob encoded with the same
brightness-bucketing `png_to_chr` already uses for sprites. The
resolver passes `next_sprite_tile` (computed from the resolved
sprite list) so each background's CHR allocation slots in
immediately after the sprite range, and rewrites the nametable
indices to point at the actual physical tile numbers. The linker
copies each background's `chr_bytes` into CHR ROM at
`chr_base_tile * 16`, so the final image renders without any
user-supplied CHR.
`BackgroundData` carries `chr_bytes` and `chr_base_tile` so the
linker has everything it needs at a glance. Inline `tiles:` /
`attributes:` declarations leave them empty and behave exactly
like before — that path doesn't auto-generate CHR because the
user is implicitly opting into "I'll provide tiles myself" by
typing the indices out by hand.
The new `examples/auto_chr_background.ne` is a 256×240 grayscale
gradient committed alongside its `auto_chr_bg.png` source; the
emulator harness verifies the rendered output against a
committed golden so a regression in the dedupe/encode/linker
plumbing fails CI loudly. Existing example ROMs are byte-
identical because their backgrounds either have no PNG source or
already provided their own CHR.
https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
This commit is contained in:
parent
6b080316a4
commit
cc3f7eec7e
15 changed files with 337 additions and 54 deletions
|
|
@ -84,6 +84,7 @@ start Main
|
|||
| [`uxrom_user_banked.ne`](examples/uxrom_user_banked.ne) | UxROM mapper with a `bank Foo { fun ... }` block — first example to put real user code in a switchable bank, called via a generated cross-bank trampoline |
|
||||
| [`uxrom_banked_to_banked.ne`](examples/uxrom_banked_to_banked.ne) | UxROM with two `bank Foo { fun ... }` blocks — exercises a banked→banked call (`step` in `Logic` calls `clamp` in `Helpers`) routed through the same trampoline that handles fixed→banked |
|
||||
| [`palette_and_background.ne`](examples/palette_and_background.ne) | Palette and background declarations, reset-time load, vblank-safe `set_palette` / `load_background` swaps |
|
||||
| [`auto_chr_background.ne`](examples/auto_chr_background.ne) | `background Stage @nametable("file.png")` with **automatic CHR generation** — the resolver dedupes the PNG's 8×8 cells, encodes them as 2-bitplane CHR, and slots them into CHR ROM after the sprite tile range |
|
||||
| [`friendly_assets.ne`](examples/friendly_assets.ne) | **Pleasant asset syntax** — named NES colours, grouped `bg0..sp3` palettes with `universal:`, ASCII pixel-art sprites, `legend { } + map:` tilemaps, `palette_map:` attribute grids, scalar sfx `pitch:`, note-name music with `tempo:` |
|
||||
| [`structs_enums_for.ne`](examples/structs_enums_for.ne) | Structs, enums, `for` loops, struct literals |
|
||||
| [`nested_structs.ne`](examples/nested_structs.ne) | Nested-struct fields (`hero.pos.x`) and array struct fields (`hero.inv[0]`) with chained literal initializers |
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
|
|||
| `uxrom_user_banked.ne` | UxROM, `bank Foo { fun ... }`, cross-bank trampoline | First example to put real user code inside a switchable bank. The animation step lives in `bank Extras` and is invoked from the fixed-bank state handler via a generated `__tramp_step_animation` stub that selects bank 0, JSRs the body, then restores the fixed bank before returning. |
|
||||
| `uxrom_banked_to_banked.ne` | UxROM, banked → banked cross-bank call | Two `bank Foo { fun ... }` blocks: `step` lives in bank Logic and calls `clamp` in bank Helpers. The trampoline uses `ZP_BANK_CURRENT + PHA/PLA` to save and restore the caller's bank, so the same per-callee stub works whether the caller is in the fixed bank or another switchable bank. |
|
||||
| `palette_and_background.ne` | palette, background, set_palette, load_background | Reset-time initial load plus vblank-safe runtime swaps |
|
||||
| `auto_chr_background.ne` | `background @nametable(...)` with auto-CHR | First example to use the `@nametable("file.png")` shortcut without supplying any matching CHR data. The resolver dedupes the PNG's 8×8 cells, encodes them via the same brightness-bucketing the sprite CHR encoder uses, and slots them into CHR ROM at the next free tile slot. The committed `auto_chr_bg.png` is a 256×240 grayscale gradient that exercises ~50 unique tiles. |
|
||||
| `friendly_assets.ne` | named colours, grouped palette, pixel art, tilemap+legend, palette_map, scalar sfx pitch, note-name music | Exercises every "friendlier" asset syntax at once — the `palette` uses `bg0..sp3` + a shared `universal:`, the sprite is authored as ASCII pixel art, the background uses a `legend { ... } + map:` tilemap with a `palette_map:` for attributes, the sfx uses a scalar `pitch:` + `envelope:` alias, and the music uses note names (`C4, E4 40, rest 10`) with a `tempo:` default. |
|
||||
| `noise_triangle_sfx.ne` | `channel: noise`, `channel: triangle` on `sfx` blocks | Demonstrates the noise and triangle sfx channels. Declares one noise burst and one triangle bass note, plays each on a timer so the emulator harness captures both the pixel output and the APU state. |
|
||||
| `sfx_pitch_envelope.ne` | varying-pitch pulse SFX | A 16-frame frequency sweep written as a per-frame `pitch:` array on a Pulse-1 sfx. The compiler emits a separate `__sfx_pitch_<name>` blob and gates the audio tick's pitch update path on the `__sfx_pitch_used` marker, so programs that stick to the scalar `pitch:` form still get byte-identical ROM output. |
|
||||
|
|
|
|||
48
examples/auto_chr_background.ne
Normal file
48
examples/auto_chr_background.ne
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Auto-CHR background — first NEScript example to use the
|
||||
// `@nametable("file.png")` shortcut without supplying any matching
|
||||
// CHR data. Previously the resolver decoded the PNG into a tile
|
||||
// index table but left CHR generation to the user; the new
|
||||
// `png_to_nametable_with_chr` helper also generates the per-tile
|
||||
// CHR bytes from the same brightness-bucketing code that
|
||||
// `png_to_chr` uses for sprite imports, then the asset resolver
|
||||
// allocates contiguous CHR-ROM tile indices starting after the
|
||||
// last sprite tile and rewrites the nametable to point at them.
|
||||
//
|
||||
// `auto_chr_bg.png` is a 256×240 grayscale gradient rendered to
|
||||
// roughly fifty distinct tiles — enough variety to exercise the
|
||||
// dedupe + CHR generation path but well within the 256-tile cap.
|
||||
//
|
||||
// Build: cargo run -- build examples/auto_chr_background.ne
|
||||
|
||||
game "Auto CHR Background" {
|
||||
mapper: NROM
|
||||
}
|
||||
|
||||
// Grayscale palette with every background sub-palette set to the
|
||||
// same three shades. The PNG-to-attribute helper buckets each
|
||||
// 16×16 quadrant by average brightness and assigns sub-palette
|
||||
// 0..3, so we need *every* bg sub-palette to carry the same
|
||||
// dk_gray / lt_gray / white triple — otherwise quadrants that
|
||||
// pick a non-zero palette would render as the universal colour.
|
||||
palette Main {
|
||||
universal: black
|
||||
bg0: [dk_gray, lt_gray, white]
|
||||
bg1: [dk_gray, lt_gray, white]
|
||||
bg2: [dk_gray, lt_gray, white]
|
||||
bg3: [dk_gray, lt_gray, white]
|
||||
sp0: [black, black, black]
|
||||
sp1: [black, black, black]
|
||||
sp2: [black, black, black]
|
||||
sp3: [black, black, black]
|
||||
}
|
||||
|
||||
// `@nametable` is the shortcut form: no `tiles:` / `attributes:`
|
||||
// body, and the resolver fills both — and now CHR data too — from
|
||||
// the PNG itself.
|
||||
background Stage @nametable("auto_chr_bg.png")
|
||||
|
||||
on frame {
|
||||
wait_frame
|
||||
}
|
||||
|
||||
start Main
|
||||
BIN
examples/auto_chr_background.nes
Normal file
BIN
examples/auto_chr_background.nes
Normal file
Binary file not shown.
BIN
examples/auto_chr_bg.png
Normal file
BIN
examples/auto_chr_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
|
|
@ -1,5 +1,22 @@
|
|||
use image::GenericImageView;
|
||||
|
||||
/// Output of [`png_to_nametable_with_chr`]. Bundles the three
|
||||
/// outputs into a named struct so the function signature stays
|
||||
/// readable and clippy's `type_complexity` lint stays quiet.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PngNametable {
|
||||
/// 960-byte tile-index table (32 cols × 30 rows). Each entry
|
||||
/// is offset by `chr_base_tile` so it points at the actual
|
||||
/// physical CHR-ROM tile the linker will assign.
|
||||
pub tiles: [u8; 960],
|
||||
/// 64-byte attribute table (8 × 8, each covering 32×32 px).
|
||||
pub attrs: [u8; 64],
|
||||
/// Flat CHR data for the unique tiles, `unique * 16` bytes
|
||||
/// in plane-0-then-plane-1 layout. Linker copies this into
|
||||
/// CHR ROM at `chr_base_tile * 16`.
|
||||
pub chr_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Convert a PNG image to NES CHR tile data (2-bitplane format).
|
||||
/// Each 8x8 tile = 16 bytes (8 bytes plane 0, 8 bytes plane 1).
|
||||
pub fn png_to_chr(path: &std::path::Path) -> Result<Vec<u8>, String> {
|
||||
|
|
@ -26,24 +43,43 @@ pub fn png_to_chr(path: &std::path::Path) -> Result<Vec<u8>, String> {
|
|||
|
||||
/// 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.
|
||||
/// Thin wrapper around [`png_to_nametable_with_chr`] for callers
|
||||
/// that don't need the CHR data — e.g. the analyzer test fixtures
|
||||
/// and the existing background tests. Production callers should
|
||||
/// prefer the full triple form so the auto-generated CHR data
|
||||
/// gets spliced into PRG.
|
||||
pub fn png_to_nametable(path: &std::path::Path) -> Result<([u8; 960], [u8; 64]), String> {
|
||||
let nt = png_to_nametable_with_chr(path, 0)?;
|
||||
Ok((nt.tiles, nt.attrs))
|
||||
}
|
||||
|
||||
/// Convert a 256×240 PNG into a nametable plus the per-tile CHR
|
||||
/// data needed to render it on real hardware.
|
||||
///
|
||||
/// Returns a 4-tuple unpacked into 3 values: the 960-byte tile-
|
||||
/// index table, the 64-byte attribute table, and a flat CHR blob
|
||||
/// (`unique_tile_count * 16` bytes, plane-0 then plane-1 per row,
|
||||
/// ready to splice into PRG/CHR ROM).
|
||||
///
|
||||
/// `chr_base_tile` shifts every entry in the tile-index table by
|
||||
/// that constant so the output references the actual physical
|
||||
/// tile indices the linker will assign — the asset pipeline
|
||||
/// reserves tile 0 for the runtime smiley and tiles 1..N for
|
||||
/// user sprites, so the resolver passes
|
||||
/// `chr_base_tile = next_sprite_tile` to slot the auto-generated
|
||||
/// background tiles right after the sprite range.
|
||||
///
|
||||
/// 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.
|
||||
pub fn png_to_nametable_with_chr(
|
||||
path: &std::path::Path,
|
||||
chr_base_tile: u8,
|
||||
) -> Result<PngNametable, 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 {
|
||||
|
|
@ -58,10 +94,23 @@ pub fn png_to_nametable(path: &std::path::Path) -> Result<([u8; 960], [u8; 64]),
|
|||
// 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.
|
||||
//
|
||||
// Once the dedup decides a tile is new, we *also* immediately
|
||||
// encode it into 16 bytes of CHR data (plane 0 then plane 1)
|
||||
// and append it to `chr_bytes`. The two streams stay in sync:
|
||||
// tile index `i` in `unique_tiles` corresponds to the 16-byte
|
||||
// window starting at `i * 16` in `chr_bytes`.
|
||||
let rgb = img.to_rgb8();
|
||||
let mut unique_tiles: Vec<Vec<u8>> = Vec::new();
|
||||
let mut chr_bytes: Vec<u8> = Vec::new();
|
||||
let mut tiles = [0u8; 960];
|
||||
|
||||
// Maximum unique tiles must leave room for the existing CHR
|
||||
// contents the linker has already promised — `chr_base_tile`
|
||||
// is the first index this background can claim, so the
|
||||
// top of the range is 256 - chr_base_tile.
|
||||
let max_unique = 256usize - chr_base_tile as usize;
|
||||
|
||||
for ty in 0..30u32 {
|
||||
for tx in 0..32u32 {
|
||||
let mut key = Vec::with_capacity(8 * 8 * 3);
|
||||
|
|
@ -76,17 +125,24 @@ pub fn png_to_nametable(path: &std::path::Path) -> Result<([u8; 960], [u8; 64]),
|
|||
let idx = if let Some(pos) = unique_tiles.iter().position(|t| t == &key) {
|
||||
pos
|
||||
} else {
|
||||
if unique_tiles.len() >= 256 {
|
||||
if unique_tiles.len() >= max_unique {
|
||||
return Err(format!(
|
||||
"nametable PNG {} has more than 256 unique 8×8 tiles; \
|
||||
simplify the image or split it into multiple backgrounds",
|
||||
"nametable PNG {} needs more than {max_unique} unique 8×8 \
|
||||
tiles after reserving {chr_base_tile} for sprites; \
|
||||
simplify the image, split it into multiple backgrounds, \
|
||||
or reduce the sprite count",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let new_idx = unique_tiles.len();
|
||||
let encoded = encode_tile_from_rgb(&rgb, tx * 8, ty * 8);
|
||||
chr_bytes.extend_from_slice(&encoded);
|
||||
unique_tiles.push(key);
|
||||
unique_tiles.len() - 1
|
||||
new_idx
|
||||
};
|
||||
tiles[(ty * 32 + tx) as usize] = idx as u8;
|
||||
// Add the per-background offset so the result references
|
||||
// physical CHR-ROM tile indices, not local 0..N.
|
||||
tiles[(ty * 32 + tx) as usize] = (idx as u8).wrapping_add(chr_base_tile);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +201,43 @@ pub fn png_to_nametable(path: &std::path::Path) -> Result<([u8; 960], [u8; 64]),
|
|||
}
|
||||
}
|
||||
|
||||
Ok((tiles, attrs))
|
||||
Ok(PngNametable {
|
||||
tiles,
|
||||
attrs,
|
||||
chr_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode an 8×8 region of an `RgbImage` into the NES 2-bitplane
|
||||
/// CHR format. Mirrors [`encode_tile`] but operates on the
|
||||
/// already-extracted RGB buffer used by the nametable resolver,
|
||||
/// which avoids re-opening the image and keeps the per-tile cost
|
||||
/// to a single rectangle scan.
|
||||
fn encode_tile_from_rgb(rgb: &image::RgbImage, x: u32, y: u32) -> [u8; 16] {
|
||||
let mut tile = [0u8; 16];
|
||||
for row in 0..8u32 {
|
||||
let mut plane0 = 0u8;
|
||||
let mut plane1 = 0u8;
|
||||
for col in 0..8u32 {
|
||||
let pixel = rgb.get_pixel(x + col, y + row);
|
||||
let brightness = (u16::from(pixel[0]) + u16::from(pixel[1]) + u16::from(pixel[2])) / 3;
|
||||
let index = match brightness {
|
||||
0..=63 => 0u8,
|
||||
64..=127 => 1,
|
||||
128..=191 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
if index & 1 != 0 {
|
||||
plane0 |= 0x80 >> col;
|
||||
}
|
||||
if index & 2 != 0 {
|
||||
plane1 |= 0x80 >> col;
|
||||
}
|
||||
}
|
||||
tile[row as usize] = plane0;
|
||||
tile[row as usize + 8] = plane1;
|
||||
}
|
||||
tile
|
||||
}
|
||||
|
||||
fn encode_tile(img: &image::DynamicImage, x: u32, y: u32) -> [u8; 16] {
|
||||
|
|
@ -252,6 +344,82 @@ mod tests {
|
|||
assert!(err.contains("must be 256"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_to_nametable_with_chr_emits_per_tile_data_and_offsets_indices() {
|
||||
// Two-tile gradient: the top half is uniformly mid-gray and
|
||||
// the bottom half is uniformly white. We feed in a sprite
|
||||
// base tile of 5 to exercise the offset path — the output
|
||||
// tile-index table must reference 5 and 6, and the chr_bytes
|
||||
// blob must be exactly 32 bytes (two 16-byte tiles).
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("nescript_png_to_nametable_with_chr.png");
|
||||
let mut img = RgbImage::new(256, 240);
|
||||
for y in 0..240u32 {
|
||||
let c: u8 = if y < 120 { 96 } else { 255 };
|
||||
for x in 0..256u32 {
|
||||
img.put_pixel(x, y, Rgb([c, c, c]));
|
||||
}
|
||||
}
|
||||
img.save(&path).unwrap();
|
||||
let nt = png_to_nametable_with_chr(&path, 5).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
// The first tile (index 0 inside the dedupe table) becomes
|
||||
// physical tile 5; the second becomes physical tile 6.
|
||||
assert_eq!(nt.tiles[0], 5, "top-left tile should be at base + 0");
|
||||
assert_eq!(
|
||||
nt.tiles[20 * 32],
|
||||
6,
|
||||
"bottom-left tile should be at base + 1"
|
||||
);
|
||||
// Two unique tiles → 32 bytes of CHR data.
|
||||
assert_eq!(nt.chr_bytes.len(), 32, "should emit exactly 2 * 16 bytes");
|
||||
// The mid-gray tile should encode every pixel as palette
|
||||
// index 1 (brightness 96 buckets to 1) — that's plane0 = $FF
|
||||
// and plane1 = $00 for every row. The white tile encodes
|
||||
// every pixel as index 3, so plane0 = plane1 = $FF.
|
||||
for byte in &nt.chr_bytes[..8] {
|
||||
assert_eq!(*byte, 0xFF, "tile 0 plane 0 should be all 1s");
|
||||
}
|
||||
for byte in &nt.chr_bytes[8..16] {
|
||||
assert_eq!(*byte, 0x00, "tile 0 plane 1 should be all 0s");
|
||||
}
|
||||
for byte in &nt.chr_bytes[16..32] {
|
||||
assert_eq!(*byte, 0xFF, "tile 1 should be all 1s in both planes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_to_nametable_with_chr_respects_sprite_base_for_max_unique() {
|
||||
// Reserving N tiles for sprites caps the background at
|
||||
// 256-N unique tiles. We exercise the bound by reserving
|
||||
// 250 sprite tiles and then asking the resolver to dedupe
|
||||
// an image with more than 6 distinct cells — it should
|
||||
// fail with a clear error pointing at both the cap and
|
||||
// the reservation.
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("nescript_png_to_nametable_with_chr_cap.png");
|
||||
// 8 distinct horizontal stripes (one per row group), each
|
||||
// 30 pixels tall so the dedupe table sees 8 unique tiles.
|
||||
let mut img = RgbImage::new(256, 240);
|
||||
for y in 0..240u32 {
|
||||
let c: u8 = ((y / 30) * 32) as u8;
|
||||
for x in 0..256u32 {
|
||||
img.put_pixel(x, y, Rgb([c, c, c]));
|
||||
}
|
||||
}
|
||||
img.save(&path).unwrap();
|
||||
let err = png_to_nametable_with_chr(&path, 250).unwrap_err();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(
|
||||
err.contains("more than 6"),
|
||||
"expected 'more than 6 unique' (256-250), got: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains("reserving 250"),
|
||||
"expected the error to mention the sprite reservation, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_to_nametable_rejects_too_many_unique_tiles() {
|
||||
// A 256×240 image of unique gradient tiles — each 8×8 cell
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ 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, png_to_nametable};
|
||||
pub use chr::{png_to_chr, png_to_nametable, png_to_nametable_with_chr};
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -29,11 +29,24 @@ impl PaletteData {
|
|||
/// (32 columns × 30 rows) and `attrs` is the 64-byte attribute
|
||||
/// table. Both are zero-padded up from the declared sizes so the
|
||||
/// runtime NMI helper can always push fixed-length data.
|
||||
///
|
||||
/// `chr_bytes` and `chr_base_tile` describe the per-background
|
||||
/// CHR data the resolver auto-generates from a `@nametable(...)`
|
||||
/// PNG source. `chr_bytes` is empty (and `chr_base_tile == 0`)
|
||||
/// for inline `tiles:` / `attributes:` declarations — those still
|
||||
/// reference whatever tiles the user supplied via separate
|
||||
/// sprite / `@chr(...)` declarations, so the linker doesn't
|
||||
/// touch the CHR ROM on their behalf. PNG-sourced backgrounds
|
||||
/// instead emit a flat 16-byte-per-tile blob keyed by
|
||||
/// `chr_base_tile`, which the linker copies into CHR ROM at
|
||||
/// `chr_base_tile * 16`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackgroundData {
|
||||
pub name: String,
|
||||
pub tiles: [u8; 960],
|
||||
pub attrs: [u8; 64],
|
||||
pub chr_bytes: Vec<u8>,
|
||||
pub chr_base_tile: u8,
|
||||
}
|
||||
|
||||
impl BackgroundData {
|
||||
|
|
@ -155,22 +168,42 @@ pub fn resolve_palettes(program: &Program, source_dir: &Path) -> Result<Vec<Pale
|
|||
///
|
||||
/// 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`.
|
||||
/// [`crate::assets::png_to_nametable_with_chr`] into a 960-byte
|
||||
/// tile index table + 64-byte attribute table + the CHR data for
|
||||
/// the unique tiles. The auto-generated CHR is offset by
|
||||
/// `next_sprite_tile` so it sits immediately after the user's
|
||||
/// sprite tile range — the linker copies it into CHR ROM via
|
||||
/// `BackgroundData::chr_bytes` and `chr_base_tile`. Inline
|
||||
/// `tiles:` / `attributes:` declarations leave `chr_bytes`
|
||||
/// empty; those still rely on the user supplying tiles via
|
||||
/// separate sprite declarations.
|
||||
pub fn resolve_backgrounds(
|
||||
program: &Program,
|
||||
source_dir: &Path,
|
||||
next_sprite_tile: u8,
|
||||
) -> Result<Vec<BackgroundData>, String> {
|
||||
let mut out = Vec::with_capacity(program.backgrounds.len());
|
||||
let mut next_tile = next_sprite_tile;
|
||||
for b in &program.backgrounds {
|
||||
let (tiles, attrs) = if let Some(png_path) = &b.png_source {
|
||||
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))?
|
||||
let nt = crate::assets::png_to_nametable_with_chr(&full_path, next_tile)
|
||||
.map_err(|e| format!("background '{}' PNG source: {e}", b.name))?;
|
||||
// Each unique tile is exactly 16 bytes of CHR data;
|
||||
// `next_tile` advances past the new range so a second
|
||||
// PNG-sourced background lands its tiles after the
|
||||
// first one's.
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let tile_count: u8 = (nt.chr_bytes.len() / 16) as u8;
|
||||
let chr_base_tile = next_tile;
|
||||
next_tile = next_tile.saturating_add(tile_count);
|
||||
out.push(BackgroundData {
|
||||
name: b.name.clone(),
|
||||
tiles: nt.tiles,
|
||||
attrs: nt.attrs,
|
||||
chr_bytes: nt.chr_bytes,
|
||||
chr_base_tile,
|
||||
});
|
||||
} else {
|
||||
let mut tiles = [0u8; 960];
|
||||
for (i, t) in b.tiles.iter().enumerate().take(960) {
|
||||
|
|
@ -180,13 +213,14 @@ pub fn resolve_backgrounds(
|
|||
for (i, a) in b.attributes.iter().enumerate().take(64) {
|
||||
attrs[i] = *a;
|
||||
}
|
||||
(tiles, attrs)
|
||||
};
|
||||
out.push(BackgroundData {
|
||||
name: b.name.clone(),
|
||||
tiles,
|
||||
attrs,
|
||||
});
|
||||
out.push(BackgroundData {
|
||||
name: b.name.clone(),
|
||||
tiles,
|
||||
attrs,
|
||||
chr_bytes: Vec::new(),
|
||||
chr_base_tile: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
@ -402,7 +436,7 @@ mod tests {
|
|||
png_source: None,
|
||||
span: Span::dummy(),
|
||||
});
|
||||
let resolved = resolve_backgrounds(&program, Path::new(".")).unwrap();
|
||||
let resolved = resolve_backgrounds(&program, Path::new("."), 0).unwrap();
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].name, "Stage");
|
||||
assert_eq!(resolved[0].tiles.len(), 960);
|
||||
|
|
@ -444,7 +478,7 @@ mod tests {
|
|||
png_source: Some(png_path.file_name().unwrap().to_string_lossy().to_string()),
|
||||
span: Span::dummy(),
|
||||
});
|
||||
let resolved = resolve_backgrounds(&program, &dir).unwrap();
|
||||
let resolved = resolve_backgrounds(&program, &dir, 0).unwrap();
|
||||
let _ = std::fs::remove_file(&png_path);
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].tiles.len(), 960);
|
||||
|
|
@ -481,7 +515,7 @@ mod tests {
|
|||
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 err = resolve_backgrounds(&program, &dir, 0).unwrap_err();
|
||||
let _ = std::fs::remove_file(&png_path);
|
||||
assert!(
|
||||
err.contains("background 'Oops' PNG source"),
|
||||
|
|
|
|||
|
|
@ -640,9 +640,7 @@ impl LoweringContext {
|
|||
// work could warn on it.
|
||||
let base_x = self.lower_expr(&draw.x);
|
||||
let base_y = self.lower_expr(&draw.y);
|
||||
for ((dx_off, dy_off), tile) in
|
||||
meta.dx.iter().zip(&meta.dy).zip(&meta.frame)
|
||||
{
|
||||
for ((dx_off, dy_off), tile) in meta.dx.iter().zip(&meta.dy).zip(&meta.frame) {
|
||||
let off_x = self.fresh_temp();
|
||||
self.emit(IrOp::LoadImm(off_x, *dx_off));
|
||||
let x_sum = self.fresh_temp();
|
||||
|
|
|
|||
|
|
@ -679,8 +679,14 @@ impl Linker {
|
|||
builder.set_prg_banks(banks);
|
||||
}
|
||||
|
||||
// CHR ROM: tile 0 is reserved for the default smiley, followed by
|
||||
// any user-declared sprites placed at their assigned tile indices.
|
||||
// CHR ROM: tile 0 is reserved for the default smiley,
|
||||
// followed by any user-declared sprites placed at their
|
||||
// assigned tile indices, followed by any auto-generated
|
||||
// background CHR data at `chr_base_tile * 16`. Each
|
||||
// background's `chr_bytes` was sized by the resolver
|
||||
// against the sprite range so no two contiguous tile
|
||||
// ranges overlap, but we still bounds-check on copy in
|
||||
// case a future change shifts the layout.
|
||||
let mut chr = vec![0u8; 8192];
|
||||
chr[..16].copy_from_slice(&DEFAULT_SPRITE_CHR);
|
||||
for sprite in sprites {
|
||||
|
|
@ -690,6 +696,16 @@ impl Linker {
|
|||
chr[offset..end].copy_from_slice(&sprite.chr_bytes);
|
||||
}
|
||||
}
|
||||
for bg in backgrounds {
|
||||
if bg.chr_bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let offset = bg.chr_base_tile as usize * 16;
|
||||
let end = offset + bg.chr_bytes.len();
|
||||
if end <= chr.len() {
|
||||
chr[offset..end].copy_from_slice(&bg.chr_bytes);
|
||||
}
|
||||
}
|
||||
builder.set_chr(chr);
|
||||
|
||||
let rom = builder.build();
|
||||
|
|
|
|||
|
|
@ -503,6 +503,8 @@ mod tests {
|
|||
name: "Stage".to_string(),
|
||||
tiles: [0u8; 960],
|
||||
attrs: [0u8; 64],
|
||||
chr_bytes: Vec::new(),
|
||||
chr_base_tile: 0,
|
||||
}];
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("__palette_Main".to_string(), 0xC100);
|
||||
|
|
|
|||
|
|
@ -158,7 +158,21 @@ pub fn compile_source(
|
|||
.map_err(|e| CompileError::AssetResolution(format!("music: {e}")))?;
|
||||
let palettes = assets::resolve_palettes(&program, source_dir)
|
||||
.map_err(|e| CompileError::AssetResolution(format!("palettes: {e}")))?;
|
||||
let backgrounds = assets::resolve_backgrounds(&program, source_dir)
|
||||
// Compute the first CHR tile index that backgrounds can claim.
|
||||
// Sprite tile 0 is the runtime default smiley; the resolver
|
||||
// packs user sprites in starting at tile 1, so the next free
|
||||
// tile is whatever sits past the last sprite. We derive it
|
||||
// from the resolved `SpriteData` rather than re-walking the
|
||||
// AST to keep the two sides honest.
|
||||
let next_sprite_tile = sprites
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let count = s.chr_bytes.len().div_ceil(16) as u16;
|
||||
u16::from(s.tile_index) + count
|
||||
})
|
||||
.max()
|
||||
.map_or(1u8, |max| max.min(255) as u8);
|
||||
let backgrounds = assets::resolve_backgrounds(&program, source_dir, next_sprite_tile)
|
||||
.map_err(|e| CompileError::AssetResolution(format!("backgrounds: {e}")))?;
|
||||
|
||||
// IR → 6502 codegen. We hold on to the codegen after
|
||||
|
|
|
|||
1
tests/emulator/goldens/auto_chr_background.audio.hash
Normal file
1
tests/emulator/goldens/auto_chr_background.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/auto_chr_background.png
Normal file
BIN
tests/emulator/goldens/auto_chr_background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 755 B |
|
|
@ -1154,7 +1154,7 @@ fn compile_banked(source: &str) -> Vec<u8> {
|
|||
let music = assets::resolve_music(&program).expect("music resolution should succeed");
|
||||
let palettes = assets::resolve_palettes(&program, Path::new("."))
|
||||
.expect("palette resolution should succeed");
|
||||
let backgrounds = assets::resolve_backgrounds(&program, Path::new("."))
|
||||
let backgrounds = assets::resolve_backgrounds(&program, Path::new("."), 0)
|
||||
.expect("background resolution should succeed");
|
||||
|
||||
let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
|
||||
|
|
@ -1932,7 +1932,7 @@ fn e2e_png_palette_source_compiles_and_splices_bytes_into_prg() {
|
|||
// 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");
|
||||
let backgrounds = assets::resolve_backgrounds(&program, &dir, 0).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
|
||||
|
|
@ -2018,7 +2018,7 @@ fn compile_banked_with_opts(source: &str, optimize: bool) -> Vec<u8> {
|
|||
let music = assets::resolve_music(&program).expect("music resolution should succeed");
|
||||
let palettes = assets::resolve_palettes(&program, Path::new("."))
|
||||
.expect("palette resolution should succeed");
|
||||
let backgrounds = assets::resolve_backgrounds(&program, Path::new("."))
|
||||
let backgrounds = assets::resolve_backgrounds(&program, Path::new("."), 0)
|
||||
.expect("background resolution should succeed");
|
||||
|
||||
let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
|
||||
|
|
@ -2261,7 +2261,7 @@ fn source_map_survives_aggressive_peephole_folding() {
|
|||
let sfx = assets::resolve_sfx(&program).unwrap();
|
||||
let music = assets::resolve_music(&program).unwrap();
|
||||
let palettes = assets::resolve_palettes(&program, Path::new(".")).unwrap();
|
||||
let backgrounds = assets::resolve_backgrounds(&program, Path::new(".")).unwrap();
|
||||
let backgrounds = assets::resolve_backgrounds(&program, Path::new("."), 0).unwrap();
|
||||
|
||||
let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
|
||||
.with_sprites(&sprites)
|
||||
|
|
@ -2402,7 +2402,7 @@ fn debug_build_emits_bounds_check_halt_routine() {
|
|||
let music = assets::resolve_music(&program).unwrap();
|
||||
let palettes = assets::resolve_palettes(&program, Path::new("."))
|
||||
.expect("palette resolution should succeed");
|
||||
let backgrounds = assets::resolve_backgrounds(&program, Path::new("."))
|
||||
let backgrounds = assets::resolve_backgrounds(&program, Path::new("."), 0)
|
||||
.expect("background resolution should succeed");
|
||||
|
||||
let mut cg_debug = IrCodeGen::new(&analysis.var_allocations, &ir_program)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue