1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-15 04:35:37 +00:00

compiler: VRAM update buffer (nt_set / nt_attr / nt_fill_h)

Closes the highest-priority remaining catalogue item (§G). User
code queues PPU writes during `on frame` via three new intrinsics;
the NMI drains the 256-byte ring at `$0400-$04FF` to `$2007`
during vblank. Programs that never touch the buffer pay zero
bytes and zero cycles for the feature — verified by the existing
46 ROMs all matching their goldens with no drift.

Also fixes the failing CI Format check from 7b4570e by running
cargo fmt across the working tree.

**Runtime:**
- New `runtime::gen_vram_buf_drain` emits the drain routine
  (`__vram_buf_drain`). Walks entries `[len][addr_hi][addr_lo]
  [byte_0]...[byte_(len-1)]` and stops at `len == 0`. Uses
  `LDA $0400,X` indexed-absolute so no ZP scratch is needed.
  Drain costs ~12 setup cycles + 8 cycles per data byte; the
  256-byte buffer can hold ~50 single-tile writes that drain
  in roughly 1000 cycles, well inside the ~2273-cycle vblank.
- `NmiOptions` gains `has_vram_buf`. The NMI JSRs the drain
  after the existing palette/background handshake (compiler-
  queued PPU writes win priority for vblank cycles).

**IR + codegen:**
- Three new ops `IrOp::NtSet`, `IrOp::NtAttr`, `IrOp::NtFillH`.
- The codegen helpers compute the PPU address inline:
  `$2000 + y*32 + x` for nametable, `$23C0 + (y/4)*8 + (x/4)`
  for attribute. Each append lays down a fresh `0` sentinel so
  the NMI sees a well-formed buffer regardless of whether more
  entries get appended later in the frame.
- `__vram_buf_used` marker drops on first use; gates the
  runtime splice + NMI JSR.

**Analyzer:**
- AST-walking helper `program_uses_vram_buf` detects intrinsic
  use at analyze-init time so the user-RAM bump pointer can
  start at `$0500` (past the buffer) rather than the legacy
  `$0300`. Programs that don't use the buffer keep the legacy
  start.
- Three intrinsic names registered in `is_intrinsic` /
  `is_void_intrinsic` with arity checks.

**Tests + example:**
- `examples/vram_buffer_demo.ne` exercises all three intrinsics
  on a backgrounded program — three single-tile score writes,
  a 16-tile horizontal fill, and an attribute write that flips
  the top-left metatile group's palette to red. Committed
  golden + audio hash.
- Four new integration tests: byte-level JSR-to-drain
  assertion, drain-omitted-when-unused, RAM-bump assertion for
  programs that DO use the buffer, and arity enforcement for
  `nt_set`.

**CI fix:**
- `cargo fmt` ran across the tree. Picks up a one-line fmt
  diff in `tests/integration_test.rs` that the prior commit
  shipped without running fmt, causing the Format CI job to
  fail on `7b4570e`.

All 758 tests pass. Clippy clean. 47/47 emulator goldens match.
This commit is contained in:
Claude 2026-04-18 21:14:31 +00:00
parent 7b4570eee5
commit 807c9c7318
No known key found for this signature in database
15 changed files with 699 additions and 27 deletions

View file

@ -3505,6 +3505,168 @@ start Main
let _rom = compile(source);
}
#[test]
fn nt_set_emits_buffer_append_and_drain_marker() {
// `nt_set(x, y, tile)` should:
// 1. Mark `__vram_buf_used` so the linker splices the
// drain routine and gates the NMI JSR.
// 2. Emit a 4-byte append: write `[1][addr_hi][addr_lo][tile]`
// starting at VRAM_BUF_BASE+head, bump the head, write a
// fresh `0` sentinel after.
let source = r#"
game "VramSet" { mapper: NROM }
on frame {
nt_set(2, 1, 7)
}
start Main
"#;
let (program, _) = nescript::parser::parse(source);
let program = program.unwrap();
let analysis = analyzer::analyze(&program);
let mut ir_program = ir::lower(&program, &analysis);
optimizer::optimize(&mut ir_program);
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 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 linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed(
&instructions,
&sprites,
&sfx,
&music,
&[],
&[],
&[],
);
// The drain routine must be linked in.
assert!(
linked.labels.contains_key("__vram_buf_drain"),
"nt_set should pull __vram_buf_drain into the ROM"
);
// The NMI must JSR the drain. JSR opcode is 0x20; target
// address is the drain label resolved by the linker.
let drain_addr = *linked.labels.get("__vram_buf_drain").unwrap();
let lo = (drain_addr & 0xFF) as u8;
let hi = (drain_addr >> 8) as u8;
assert!(
linked
.rom
.windows(3)
.any(|w| w[0] == 0x20 && w[1] == lo && w[2] == hi),
"NMI should JSR __vram_buf_drain"
);
}
#[test]
fn vram_buf_omitted_without_use() {
// Programs that never call any of the buffer intrinsics should
// not link in the drain routine and should keep main RAM
// starting at $0300.
let source = r#"
game "NoVram" { mapper: NROM }
var x: u8 = 0
on frame { x = x + 1 }
start Main
"#;
let (program, _) = nescript::parser::parse(source);
let program = program.unwrap();
let analysis = analyzer::analyze(&program);
let x_alloc = analysis
.var_allocations
.iter()
.find(|a| a.name == "x")
.expect("x should be allocated");
// Without the buffer, `x` lives in zero page (the default
// for u8 globals); pre-buffer programs never had main-RAM
// allocations bumped to $0500.
assert!(
x_alloc.address < 0x100,
"u8 global should still land in zero page when buffer unused"
);
let mut ir_program = ir::lower(&program, &analysis);
optimizer::optimize(&mut ir_program);
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 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 linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed(
&instructions,
&sprites,
&sfx,
&music,
&[],
&[],
&[],
);
assert!(
!linked.labels.contains_key("__vram_buf_drain"),
"drain routine must not be linked when no buffer intrinsic was used"
);
}
#[test]
fn vram_buf_bumps_user_ram_past_buffer_when_used() {
// When user code touches the buffer, the analyzer should bump
// the main-RAM allocator to $0500 so user globals don't alias
// the buffer at $0400-$04FF. Force a main-RAM allocation by
// declaring a large array (too big for ZP).
let source = r#"
game "VramBigVar" { mapper: NROM }
var big: u8[200]
on frame {
nt_set(0, 0, 1)
}
start Main
"#;
let (program, _) = nescript::parser::parse(source);
let program = program.expect("parse should succeed");
let analysis = analyzer::analyze(&program);
let big_alloc = analysis
.var_allocations
.iter()
.find(|a| a.name == "big")
.expect("big should be allocated");
assert!(
big_alloc.address >= 0x0500,
"user array should land at $0500+ when VRAM buffer is in use, got ${:04X}",
big_alloc.address
);
assert!(
big_alloc.address + big_alloc.size <= 0x0800,
"and should fit in main RAM"
);
}
#[test]
fn nt_set_arity_enforced() {
let source = r#"
game "BadVram" { mapper: NROM }
on frame {
nt_set(1, 2)
}
start Main
"#;
let (program, _) = nescript::parser::parse(source);
let program = program.expect("parse should succeed");
let analysis = analyzer::analyze(&program);
assert!(
analysis
.diagnostics
.iter()
.any(|d| d.is_error() && d.message.contains("nt_set")),
"wrong-arity nt_set should error; got {:?}",
analysis.diagnostics
);
}
#[test]
fn save_block_allocates_at_sram_window_and_sets_battery_bit() {
// `save { var x: u16 = 0 }` should land at $6000+ (iNES SRAM
@ -3586,8 +3748,7 @@ start Main
analysis
.diagnostics
.iter()
.any(|d| !d.is_error()
&& matches!(d.code, nescript::errors::ErrorCode::W0111)),
.any(|d| !d.is_error() && matches!(d.code, nescript::errors::ErrorCode::W0111)),
"save-block initializer should emit W0111; got {:?}",
analysis.diagnostics
);