mirror of
https://github.com/imjasonh/nescript
synced 2026-07-10 01:37:45 +00:00
palette/background: first-class declarations with reset-time load and runtime swaps
Re-adds `palette Name { colors: [...] }` and
`background Name { tiles: [...], attributes: [...] }` as first-class
declarations, plus `set_palette Name` and `load_background Name`
statements for runtime swaps. Unlike the previous iteration that
quietly no-op'd, this one is fully wired through the pipeline and
its behavior is pinned by both unit tests and an emulator golden.
Pipeline:
- Lexer: re-adds `palette`, `background`, `set_palette`,
`load_background` keywords and tokenizes them.
- AST: `PaletteDecl` (name + 1..=32 colour bytes) and `BackgroundDecl`
(name + 0..=960 tile bytes + 0..=64 attribute bytes) live in
`Program`. `Statement::SetPalette` and `Statement::LoadBackground`
name-reference these declarations.
- Parser: `palette Name { colors: [...] }` / `background Name
{ tiles: [...], attributes: [...] }` blocks and their statement
forms parse via the existing byte-array helper.
- Analyzer: validates colour indices ($00-$3F), palette length
(<=32), nametable length (<=960), attribute length (<=64), and
duplicate decl names. `set_palette` / `load_background` targets
must reference a declared name (E0502 otherwise). When a program
declares palette or background, the analyzer bumps the user
zero-page allocator's starting address from `$10` to `$18` to
reserve `$11-$17` for the runtime update handshake — programs
that don't use the feature keep the old layout so their emulator
goldens stay byte-exact.
- Assets: `PaletteData` and `BackgroundData` resolve declarations
into zero-padded fixed-size blobs (32 / 960 / 64 bytes) and
expose `label()` / `tiles_label()` / `attrs_label()` for codegen
to reference.
- IR: new `IrOp::SetPalette(String)` and
`IrOp::LoadBackground(String)`; lowering forwards the names
verbatim.
- Codegen: `gen_set_palette` writes the palette label pointer into
ZP `$12/$13` and ORs bit 0 into the update flags at `$11`;
`gen_load_background` does the same for tile/attribute pointers
at `$14/$15/$16/$17` with bit 1. Both emit a `__ppu_update_used`
marker so the linker splices in the NMI apply helper only when
the feature is actually used.
- Runtime: `gen_initial_palette_load` and
`gen_initial_background_load` write the first declared
palette/background at reset time (before rendering is enabled,
where PPU writes are safe). `gen_nmi(has_ppu_updates)` takes a
new flag; when true it splices `gen_ppu_update_apply` at the top
of the NMI body, which checks the `$11` flags byte and copies
pending palette / nametable data to `$3F00` / `$2000` inside
vblank. All helpers use only ZP $02/$03 as scratch at reset time
and never clobber ZP slots live across NMI.
- Linker: new `link_banked_with_ppu` takes slice of `PaletteData` /
`BackgroundData`; splices each blob as a labelled data block in
PRG ROM, picks the first-declared as the reset-time load target,
enables background rendering automatically when a background is
declared, and threads `has_ppu_updates` into `gen_nmi`. Old
`link_banked` remains as a thin wrapper for callers without
palette/background data so existing tests don't shift.
Tests:
- Lexer: tokenization of the 4 new keywords (single added test case).
- Parser: 5 new tests for `palette` / `background` decls with and
without attributes, plus `set_palette` / `load_background`
statements.
- Analyzer: 9 new tests covering acceptance of declared
palettes/backgrounds, E0502 for unknown names, E0201 for
out-of-range NES colors and oversized blobs, E0501 for duplicate
names, and the zero-page-layout guard (palette/bg decls bump ZP
start; no decls keeps it at $10).
- Resolver: 3 new tests for zero-padding, truncation of oversized
decls, and label derivation.
- IR: 2 new lowering tests for `set_palette` and `load_background`.
- Integration: 5 new tests — blob contents spliced verbatim into
PRG, `STA $12` / `STA $14` emitted by set_palette /
load_background codegen, and a regression guard that programs
without palette/background still land user vars at $10.
- Emulator: new `examples/palette_and_background.ne` driven by a
frame counter that toggles between `CoolBlues` / `WarmReds` and
`TitleScreen` / `StageOne` every 90 frames. Golden PNG and audio
hash checked in under `tests/emulator/goldens/` and verified via
`node run_examples.mjs` — rendered image shows the blue
`CoolBlues` palette with the nametable populated from
`TitleScreen`.
Docs:
- `README.md` adds the feature to the headline list and the example
table.
- `docs/language-guide.md` restores the palette/background sections
with the full 32-byte layout table and `set_palette` /
`load_background` statement references.
- `docs/future-work.md` replaces the "removed as dead code" entry
with the remaining gaps (PNG-sourced palette and nametable
assets, cross-vblank large background updates, memory-map
reporting).
- `spec.md` restores the grammar productions and usage examples.
- `examples/README.md` lists the new demo.
All 497 unit + integration tests pass. Clippy clean. All 21
emulator goldens match after the update pass.
https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
This commit is contained in:
parent
fdb1ec7c91
commit
d98c7f3d82
29 changed files with 1757 additions and 46 deletions
|
|
@ -57,14 +57,31 @@ pub fn analyze(program: &Program) -> AnalysisResult {
|
|||
for decl in &program.music {
|
||||
music_names.insert(decl.name.clone());
|
||||
}
|
||||
let mut palette_names = HashSet::new();
|
||||
for decl in &program.palettes {
|
||||
palette_names.insert(decl.name.clone());
|
||||
}
|
||||
let mut background_names = HashSet::new();
|
||||
for decl in &program.backgrounds {
|
||||
background_names.insert(decl.name.clone());
|
||||
}
|
||||
// Programs that use palette or background declarations need 7
|
||||
// bytes of zero page for the vblank-safe update handshake
|
||||
// (`$11` flags + 2 × 3 pointer slots). Bump the user zero-page
|
||||
// start past that region so var allocation doesn't collide with
|
||||
// the runtime slots.
|
||||
let needs_ppu_update_slots = !program.palettes.is_empty() || !program.backgrounds.is_empty();
|
||||
let next_zp_addr = if needs_ppu_update_slots { 0x18 } else { 0x10 };
|
||||
let mut analyzer = Analyzer {
|
||||
symbols: HashMap::new(),
|
||||
var_allocations: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
sfx_names,
|
||||
music_names,
|
||||
palette_names,
|
||||
background_names,
|
||||
next_ram_addr: 0x0300, // $0300 is first usable RAM after OAM buffer
|
||||
next_zp_addr: 0x10, // $10 is first usable zero-page after reserved area
|
||||
next_zp_addr,
|
||||
call_graph: HashMap::new(),
|
||||
max_depths: HashMap::new(),
|
||||
stack_depth_limit: DEFAULT_STACK_DEPTH,
|
||||
|
|
@ -97,6 +114,12 @@ struct Analyzer {
|
|||
sfx_names: HashSet<String>,
|
||||
/// Set of music names declared in the program.
|
||||
music_names: HashSet<String>,
|
||||
/// Set of palette names declared in the program. Used to
|
||||
/// validate `set_palette Name` targets.
|
||||
palette_names: HashSet<String>,
|
||||
/// Set of background names declared in the program. Used to
|
||||
/// validate `load_background Name` targets.
|
||||
background_names: HashSet<String>,
|
||||
next_ram_addr: u16,
|
||||
next_zp_addr: u8,
|
||||
call_graph: HashMap<String, Vec<String>>,
|
||||
|
|
@ -153,6 +176,78 @@ impl Analyzer {
|
|||
self.register_var(var);
|
||||
}
|
||||
|
||||
// Validate palette and background declarations. Palettes
|
||||
// must be ≤ 32 bytes (PPU palette RAM is $3F00-$3F1F) and
|
||||
// each byte must fit in 6 bits (NES master palette is
|
||||
// `$00-$3F`). Backgrounds must fit in a single 32×30
|
||||
// nametable: ≤ 960 tile bytes, ≤ 64 attribute bytes.
|
||||
// Duplicate names are caught via the shared symbol table.
|
||||
let mut seen_palettes = HashSet::new();
|
||||
for palette in &program.palettes {
|
||||
if !seen_palettes.insert(palette.name.clone()) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0501,
|
||||
format!("duplicate palette '{}'", palette.name),
|
||||
palette.span,
|
||||
));
|
||||
}
|
||||
if palette.colors.len() > 32 {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"palette '{}' has {} colors; maximum is 32",
|
||||
palette.name,
|
||||
palette.colors.len()
|
||||
),
|
||||
palette.span,
|
||||
));
|
||||
}
|
||||
for (i, c) in palette.colors.iter().enumerate() {
|
||||
if *c > 0x3F {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"palette '{}' color {i} is ${c:02X}; NES master palette indices are $00-$3F",
|
||||
palette.name,
|
||||
),
|
||||
palette.span,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen_backgrounds = HashSet::new();
|
||||
for bg in &program.backgrounds {
|
||||
if !seen_backgrounds.insert(bg.name.clone()) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0501,
|
||||
format!("duplicate background '{}'", bg.name),
|
||||
bg.span,
|
||||
));
|
||||
}
|
||||
if bg.tiles.len() > 960 {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"background '{}' has {} tile bytes; maximum is 960 (32×30)",
|
||||
bg.name,
|
||||
bg.tiles.len()
|
||||
),
|
||||
bg.span,
|
||||
));
|
||||
}
|
||||
if bg.attributes.len() > 64 {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"background '{}' has {} attribute bytes; maximum is 64 (8×8)",
|
||||
bg.name,
|
||||
bg.attributes.len()
|
||||
),
|
||||
bg.span,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Register functions as symbols
|
||||
for fun in &program.functions {
|
||||
self.register_fun(fun);
|
||||
|
|
@ -1061,6 +1156,24 @@ impl Analyzer {
|
|||
}
|
||||
}
|
||||
Statement::WaitFrame(_) => {}
|
||||
Statement::SetPalette(name, span) => {
|
||||
if !self.palette_names.contains(name) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0502,
|
||||
format!("unknown palette '{name}'"),
|
||||
*span,
|
||||
));
|
||||
}
|
||||
}
|
||||
Statement::LoadBackground(name, span) => {
|
||||
if !self.background_names.contains(name) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0502,
|
||||
format!("unknown background '{name}'"),
|
||||
*span,
|
||||
));
|
||||
}
|
||||
}
|
||||
Statement::DebugLog(args, _) => {
|
||||
for arg in args {
|
||||
self.walk_expr_reads(arg);
|
||||
|
|
@ -1367,7 +1480,9 @@ fn collect_calls_stmt(stmt: &Statement, calls: &mut Vec<String>) {
|
|||
| Statement::RawAsm(_, _)
|
||||
| Statement::Play(_, _)
|
||||
| Statement::StartMusic(_, _)
|
||||
| Statement::StopMusic(_) => {}
|
||||
| Statement::StopMusic(_)
|
||||
| Statement::SetPalette(_, _)
|
||||
| Statement::LoadBackground(_, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -968,3 +968,183 @@ fn analyze_stop_music_needs_no_name_and_is_always_valid() {
|
|||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Palette / background validation ──
|
||||
|
||||
#[test]
|
||||
fn analyze_accepts_declared_palette() {
|
||||
analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
palette Cool { colors: [0x0F, 0x01, 0x11, 0x21] }
|
||||
on frame { set_palette Cool }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_rejects_unknown_palette() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame { set_palette Ghost }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::E0502),
|
||||
"expected E0502 for unknown palette, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_accepts_declared_background() {
|
||||
analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
background Stage { tiles: [0, 1, 2] }
|
||||
on frame { load_background Stage }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_rejects_unknown_background() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame { load_background Ghost }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::E0502),
|
||||
"expected E0502 for unknown background, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_rejects_palette_color_out_of_range() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
palette Bad { colors: [0x0F, 0x40] }
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::E0201),
|
||||
"expected E0201 for out-of-range NES color, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_rejects_palette_too_long() {
|
||||
// 33 bytes > 32-byte PPU palette RAM limit.
|
||||
let colors = (0..33)
|
||||
.map(|_| "0x0F".to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let src = format!(
|
||||
r#"
|
||||
game "T" {{ mapper: NROM }}
|
||||
palette Big {{ colors: [{colors}] }}
|
||||
on frame {{ wait_frame }}
|
||||
start Main
|
||||
"#
|
||||
);
|
||||
let errors = analyze_errors(&src);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::E0201),
|
||||
"expected E0201 for >32-byte palette, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_rejects_background_tiles_too_long() {
|
||||
// 961 bytes > 960-byte nametable.
|
||||
let tiles = (0..961)
|
||||
.map(|_| "0".to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let src = format!(
|
||||
r#"
|
||||
game "T" {{ mapper: NROM }}
|
||||
background Big {{ tiles: [{tiles}] }}
|
||||
on frame {{ wait_frame }}
|
||||
start Main
|
||||
"#
|
||||
);
|
||||
let errors = analyze_errors(&src);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::E0201),
|
||||
"expected E0201 for >960-byte nametable, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_rejects_duplicate_palette_name() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
palette Dup { colors: [0x0F] }
|
||||
palette Dup { colors: [0x10] }
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::E0501),
|
||||
"expected E0501 for duplicate palette name, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_reserves_zero_page_when_palette_declared() {
|
||||
// When a program declares any palette or background, the
|
||||
// analyzer bumps the user zero-page start from $10 to $18 so
|
||||
// the runtime can own $11-$17 for the vblank update handshake.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
palette P { colors: [0x0F] }
|
||||
var x: u8 = 0
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let x = result
|
||||
.var_allocations
|
||||
.iter()
|
||||
.find(|a| a.name == "x")
|
||||
.expect("x should be allocated");
|
||||
assert!(
|
||||
x.address >= 0x18,
|
||||
"user var `x` should land at $18+ when palette is declared (got ${:02X})",
|
||||
x.address
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_does_not_reserve_zero_page_without_palette_or_bg() {
|
||||
// Programs that don't declare palette/background keep the old
|
||||
// user-ZP start at $10 so existing examples (and their
|
||||
// goldens) don't shift.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var x: u8 = 0
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let x = result
|
||||
.var_allocations
|
||||
.iter()
|
||||
.find(|a| a.name == "x")
|
||||
.expect("x should be allocated");
|
||||
assert_eq!(x.address, 0x10);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue