From 807c9c7318b52bcdb1d9584940c459cdadce7a22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:14:31 +0000 Subject: [PATCH] compiler: VRAM update buffer (nt_set / nt_attr / nt_fill_h) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 1 + docs/future-work.md | 51 +++--- examples/README.md | 1 + examples/vram_buffer_demo.ne | 57 ++++++ examples/vram_buffer_demo.nes | Bin 0 -> 24592 bytes src/analyzer/mod.rs | 78 +++++++- src/codegen/ir_codegen.rs | 169 ++++++++++++++++++ src/ir/lowering.rs | 22 +++ src/ir/mod.rs | 33 ++++ src/linker/mod.rs | 14 ++ src/optimizer/mod.rs | 19 ++ src/runtime/mod.rs | 115 ++++++++++++ .../goldens/vram_buffer_demo.audio.hash | 1 + tests/emulator/goldens/vram_buffer_demo.png | Bin 0 -> 37759 bytes tests/integration_test.rs | 165 ++++++++++++++++- 15 files changed, 699 insertions(+), 27 deletions(-) create mode 100644 examples/vram_buffer_demo.ne create mode 100644 examples/vram_buffer_demo.nes create mode 100644 tests/emulator/goldens/vram_buffer_demo.audio.hash create mode 100644 tests/emulator/goldens/vram_buffer_demo.png diff --git a/README.md b/README.md index d1f55fd..925accd 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ start Main - **IR-based optimizer** -- constant folding, dead code elimination, strength reduction (incl. div/mod by power-of-two), copy propagation, peephole passes including INC/DEC fold and live-range slot recycling - **Full 16-bit arithmetic** -- `u16` and `i16` add/sub/compare lower to carry-propagating paired operations; negative `i16` literals fold to wide two's complement - **Battery-backed saves** -- `save { var ... }` blocks land at `$6000+`, flip the iNES battery flag, and persist across power cycles +- **VRAM update buffer** -- `nt_set(x, y, tile)`, `nt_attr(x, y, val)`, `nt_fill_h(x, y, len, tile)` queue PPU writes during `on frame`; the NMI drains them at vblank without touching `$2006`/`$2007` from user code - **Multiple mappers** -- NROM, MMC1, UxROM, MMC3 (including multi-scanline IRQ dispatch per state), AxROM (mapper 7), CNROM (mapper 3), GNROM / MHROM (mapper 66) - **Runtime PRNG** -- `rand8()`, `rand16()`, `seed_rand(s)` backed by a zero-cost-when-unused Galois LFSR - **Edge-triggered input** -- `p1.button.a.pressed` / `.released` for menu / one-shot input handling diff --git a/docs/future-work.md b/docs/future-work.md index 6342ae1..ca1733c 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -336,27 +336,31 @@ documented as a **design choice** anywhere. Add a paragraph to the language guide explaining why, plus a pointer to the hand-rolled explicit-stack pattern (small `u8[N]` stack + `u8` top). -### G. VRAM update buffer primitive +### G. VRAM update buffer follow-ups -The highest-leverage missing runtime feature. Today -`load_background` / `set_palette` queue PPU writes under the hood, -but there is no user-visible "write these N bytes into nametable -slot `(x,y)` next vblank" primitive. That's the idiom behind every -scoreboard, dialog box, destroyed-metatile animation, and streaming -scroll in the nesdoug chapters. Concrete API sketch: +`nt_set(x, y, tile)`, `nt_attr(x, y, value)`, and +`nt_fill_h(x, y, len, tile)` ship today — see +`examples/vram_buffer_demo.ne`. The runtime ring lives at +`$0400-$04FF` (gated on the `__vram_buf_used` marker; the analyzer +bumps the user-RAM bump pointer to `$0500` when the buffer is in +use). Each append lays down `[len][addr_hi][addr_lo][data…]` and +writes a fresh `0` sentinel; the NMI drains the buffer at vblank +via `LDA $0400,X / STA $2007` indexed-absolute (4 cycles per data +byte, no ZP cost). -``` -buffer.nametable_write(x, y, [0x20, 0x21, 0x22]) // horizontal -buffer.nametable_write_v(x, y, [0x20, 0x21, 0x22]) // vertical -buffer.attribute_write(x, y, 0b00011011) // one byte -buffer.flush() // force an eof -``` +Still TODO: -Runtime shape: a fixed ring buffer at a known RAM address -(`$0400`?). Each entry is `[header, addr_hi, addr_lo, len, data…]` -where `header` carries the `NT_UPD_HORZ` / `NT_UPD_VERT` / -`NT_UPD_EOF` bits the neslib engine already uses. The NMI handler -drains the buffer every frame and writes `$FF` as the sentinel. +- **Vertical (column) writes** — `nt_write_v(x, y, ...)` would set + `$2000` bit 2 (auto-increment 32) before the data writes and + clear it after. Useful for tilemap-driven scrolling. +- **Variable-length writes from a u8 array global** — today + `nt_fill_h` repeats one tile; a `nt_copy_h(x, y, src_var, len)` + variant that copies from a declared `u8[N]` global removes the + fill-only restriction. +- **Buffer-overflow detection** — the runtime drain assumes the + 256-byte buffer never overflows. A debug-mode check that traps + when `head` would advance past `$04FF` would catch the worst + failure mode (writes wrapping into adjacent RAM). ### H. Metatiles + collision as a first-class construct @@ -559,12 +563,13 @@ to a specific bank to avoid bank-switch cost on a hot path. Remaining gap items in order of user value: -1. VRAM update buffer (§G) — unblocks HUDs, dialog, streaming. -2. Register allocator (existing section) — compounding size win. -3. Signedness on Cmp16/Cmp ops (§A follow-up) — closes the i16 +1. Register allocator (existing section) — compounding size win. +2. Signedness on Cmp16/Cmp ops (§A follow-up) — closes the i16 correctness gap. -4. Metatiles + collision (§H) — closes several items at once. -5. Inline-asm format specifiers + directive list (§D follow-ups). +3. Metatiles + collision (§H) — closes several items at once. +4. Inline-asm format specifiers + directive list (§D follow-ups). +5. VRAM buffer follow-ups (§G) — vertical writes, array copy, + overflow detection. 6. Arrays-of-structs + bitfields (§C) + fn pointers (§B) — turns NEScript into a general-purpose NES language. 7. UNROM-512 + MMC5 (§V) — ecosystem fit. diff --git a/examples/README.md b/examples/README.md index aa031e0..eab92cb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -52,6 +52,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `sprite_0_split_demo.ne` | `sprite_0_split(x, y)` | Mid-frame scroll change driven by the PPU's sprite-0 hit flag (`$2002` bit 6), so the effect works on any mapper — NROM, UxROM, MMC1 — not just MMC3 via `on_scanline(N)`. Two-phase busy-wait (wait for clear, then wait for set) guarantees the hit we're responding to came from the current frame. Requires a sprite in OAM slot 0 that overlaps opaque background pixels; this demo uses a full smiley background so every frame's sprite-0 hit fires deterministically. | | `i16_demo.ne` | `i16` signed 16-bit type | Negative literals fold to wide two's complement (`-10` → `$FFF6`), so `var vy: i16 = -10` stores the right bytes instead of the zero-extended `$00F6`. Comparisons currently use the unsigned 16-bit compare path (matching existing `i8` behaviour) — fine for positive ranges, wrong for negative compares. The companion `i16_negative_literal_sign_extends_to_wide_store` integration test guards the literal-fold path. | | `sram_demo.ne` | `save { var ... }` | Battery-backed save block. The analyzer allocates `high_score` and `coins` at `$6000+` (cartridge SRAM window) instead of main RAM, and the linker flips iNES header byte-6 bit-1 so emulators (FCEUX, Mesen, Nestopia) load and persist the region from a `.sav` file alongside the ROM. SRAM is uninitialized at first power-on; production games should reserve a magic-byte sentinel and validate it before trusting the rest of the data — the compiler doesn't auto-initialize and emits W0111 if you try. | +| `vram_buffer_demo.ne` | `nt_set`, `nt_attr`, `nt_fill_h` | VRAM update buffer. User code queues PPU writes during `on frame` via three intrinsics; the NMI drains the 256-byte ring at `$0400-$04FF` to `$2007` during vblank. Each entry is `[len][addr_hi][addr_lo][data...]` with a zero-byte sentinel; the codegen lays down a fresh sentinel after every append. The runtime drain uses `LDA $0400,X` indexed-absolute (4 cycles per data byte, no ZP cost). Gated on the `__vram_buf_used` marker — programs that never call any of the three intrinsics keep the 256 bytes free for analyzer allocation and the NMI never JSRs the drain. | ## Emulator Controls diff --git a/examples/vram_buffer_demo.ne b/examples/vram_buffer_demo.ne new file mode 100644 index 0000000..561667a --- /dev/null +++ b/examples/vram_buffer_demo.ne @@ -0,0 +1,57 @@ +// VRAM update buffer demo — `nt_set`, `nt_attr`, and `nt_fill_h` +// append entries to a 256-byte ring at `$0400-$04FF` during +// `on frame`; the NMI handler drains them to PPU `$2007` during +// vblank. This is the idiom every nesdoug HUD / dialog box / score +// counter is built on — the user code never touches `$2006` or +// `$2007` directly, just appends record after record. +// +// This demo paints a "scoreboard" of three tiles in the top row +// each frame, then fills a 16-tile horizontal stripe a few rows +// down with a single tile pattern. Frame 180 captures the scene +// after the buffer has drained the same set of writes for ~3 +// seconds — the visible output is stable. + +game "VRAM Buffer Demo" { + mapper: NROM + mirroring: horizontal +} + +palette Default { + universal: black + bg0: [dk_blue, blue, sky_blue] + bg1: [dk_red, red, lt_red] + bg2: [dk_green, green, lt_green] + bg3: [black, lt_gray, white] + sp0: [dk_blue, blue, sky_blue] + sp1: [red, orange, white] + sp2: [dk_teal, teal, lt_teal] + sp3: [dk_olive, olive, yellow] +} + +background Empty { + legend { ".": 0 } + map: [ + "................................" + ] +} + +on frame { + // Three single-tile writes for a tiny "score" digit row. + // Each call appends a 4-byte buffer entry: [len=1][hi][lo][tile]. + nt_set(2, 1, 1) + nt_set(3, 1, 2) + nt_set(4, 1, 3) + + // A horizontal fill: 16 copies of the smiley starting at (8, 4). + // Buffer entry is [len=16][hi][lo][tile × 16] = 19 bytes. + nt_fill_h(8, 4, 16, 0) + + // Update the attribute byte for the metatile that contains the + // score row so it picks up sub-palette 1 (red gradient) for + // visual contrast against the rest of the screen. (x, y) here + // are nametable cell coordinates; the codegen translates to + // the attribute table address $23C0+. + nt_attr(0, 0, 0b01010101) +} + +start Main diff --git a/examples/vram_buffer_demo.nes b/examples/vram_buffer_demo.nes new file mode 100644 index 0000000000000000000000000000000000000000..77411bc42fed6a846bdcd596cd9f026a95fbbb46 GIT binary patch literal 24592 zcmeI(&ubGw6bJCPv)d%r!)7IkslONz)S!p-PJyU^1eKHq0pZ~`wi`is4BmVWjZwhvJ6-}3P_wXBu}SGw1;^p zls%--{+1r-UmW#$W*a}9>Kk0N=5fwlMTr+9)V%eD~&G}zi&8)l~ zsWmgNr@zbWmGa~Ctsy!HGU!^!G*)VETK*^!J8pa({1ff9K>@v1a-@g`Pav z(^g1>&>n5`+K% literal 0 HcmV?d00001 diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index c4d9919..eb1a844 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -108,6 +108,13 @@ pub fn analyze(program: &Program) -> AnalysisResult { } else { 0x10 }; + // Detect VRAM-buffer intrinsic usage by scanning the AST. When + // present, the buffer reserves `$0400-$04FF` and we have to + // start the user-RAM bump pointer past that region — otherwise + // user globals and the buffer would alias. Programs that don't + // use the buffer keep the legacy `$0300` start. + let uses_vram_buf = program_uses_vram_buf(program); + let initial_ram_addr: u16 = if uses_vram_buf { 0x0500 } else { 0x0300 }; let mut analyzer = Analyzer { symbols: HashMap::new(), var_allocations: Vec::new(), @@ -117,7 +124,7 @@ pub fn analyze(program: &Program) -> AnalysisResult { music_names, palette_names, background_names, - next_ram_addr: 0x0300, // $0300 is first usable RAM after OAM buffer + next_ram_addr: initial_ram_addr, next_zp_addr, call_graph: HashMap::new(), max_depths: HashMap::new(), @@ -2699,6 +2706,42 @@ fn collect_calls_block(block: &Block, calls: &mut Vec) { } } +/// True if the program references any of the VRAM-buffer +/// intrinsics anywhere user code can reach. Used at analyzer +/// init time to bump the user-RAM bump pointer past the buffer +/// region (`$0400-$04FF`) so user globals don't alias the +/// runtime's ring. Walks both function bodies and state handlers. +fn program_uses_vram_buf(program: &Program) -> bool { + fn block_uses(block: &Block) -> bool { + let mut calls = Vec::new(); + collect_calls_block(block, &mut calls); + calls + .iter() + .any(|c| matches!(c.as_str(), "nt_set" | "nt_attr" | "nt_fill_h")) + } + for fun in &program.functions { + if block_uses(&fun.body) { + return true; + } + } + for state in &program.states { + for b in [&state.on_enter, &state.on_exit, &state.on_frame] + .into_iter() + .flatten() + { + if block_uses(b) { + return true; + } + } + for (_, b) in &state.on_scanline { + if block_uses(b) { + return true; + } + } + } + false +} + /// Return true if the given block contains any statement that can /// either exit the enclosing loop (`break`, `return`, `transition`) /// or yield control back to the frame loop (`wait_frame`). @@ -2729,6 +2772,9 @@ fn is_intrinsic(name: &str) -> bool { | "fade_out" | "fade_in" | "sprite_0_split" + | "nt_set" + | "nt_attr" + | "nt_fill_h" ) } @@ -2740,7 +2786,15 @@ fn is_intrinsic(name: &str) -> bool { fn is_void_intrinsic(name: &str) -> bool { matches!( name, - "poke" | "seed_rand" | "set_palette_brightness" | "fade_out" | "fade_in" | "sprite_0_split" + "poke" + | "seed_rand" + | "set_palette_brightness" + | "fade_out" + | "fade_in" + | "sprite_0_split" + | "nt_set" + | "nt_attr" + | "nt_fill_h" ) } @@ -2822,6 +2876,26 @@ impl Analyzer { span, )); } + "nt_set" | "nt_attr" if args.len() != 3 => { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0203, + format!( + "`{name}` takes exactly 3 arguments (x: u8, y: u8, value: u8), got {}", + args.len() + ), + span, + )); + } + "nt_fill_h" if args.len() != 4 => { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0203, + format!( + "`nt_fill_h` takes exactly 4 arguments (x: u8, y: u8, len: u8, tile: u8), got {}", + args.len() + ), + span, + )); + } _ => {} } } diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 41951a2..de5fcff 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -235,6 +235,13 @@ pub struct IrCodeGen<'a> { /// splice the prev-input snapshot after the NMI's shift loop /// so edge detection sees stable current/previous bytes. edge_input_used: bool, + /// Set to true the first time we lower a VRAM-buffer + /// intrinsic (`nt_set`, `nt_attr`, `nt_fill_h`). Drives the + /// `__vram_buf_used` marker label — the linker uses it to + /// splice `runtime::gen_vram_buf_drain` into PRG, call it + /// from NMI, and reserve the 256-byte buffer at `$0400-$04FF` + /// from the analyzer's RAM allocator. + vram_buf_used: bool, /// Source-location markers produced from [`IrOp::SourceLoc`]. /// Each entry is a `(label_name, span)` pair — the codegen /// emits a unique label-definition pseudo-op at the current @@ -465,6 +472,7 @@ impl<'a> IrCodeGen<'a> { palette_bright_used: false, fade_used: false, edge_input_used: false, + vram_buf_used: false, source_locs: Vec::new(), next_source_loc: 0, emit_source_locs: false, @@ -2041,6 +2049,18 @@ impl<'a> IrCodeGen<'a> { self.load_temp(*scroll_y); self.emit(STA, AM::Absolute(0x2005)); } + IrOp::NtSet { x, y, tile } => { + self.emit_vram_buf_marker(); + self.gen_nt_buf_append_single(*x, *y, *tile, /* attr= */ false); + } + IrOp::NtAttr { x, y, value } => { + self.emit_vram_buf_marker(); + self.gen_nt_buf_append_single(*x, *y, *value, /* attr= */ true); + } + IrOp::NtFillH { x, y, len, tile } => { + self.emit_vram_buf_marker(); + self.gen_nt_buf_append_fill_h(*x, *y, *len, *tile); + } IrOp::ReadInputEdge { dest, player, @@ -2459,6 +2479,9 @@ impl<'a> IrCodeGen<'a> { if self.edge_input_used { self.emit_label("__edge_input_used"); } + if self.vram_buf_used { + self.emit_label("__vram_buf_used"); + } } /// Emit the `__rand_used` marker label at most once per @@ -2484,6 +2507,146 @@ impl<'a> IrCodeGen<'a> { self.palette_bright_used = true; } + /// Emit the `__vram_buf_used` marker. The linker uses it to + /// splice `runtime::gen_vram_buf_drain` and call it from the + /// NMI handler; the analyzer (separately, by scanning the AST + /// for any of the buffer intrinsics) bumps the user RAM start + /// past the 256-byte buffer at `$0400-$04FF`. + fn emit_vram_buf_marker(&mut self) { + self.vram_buf_used = true; + } + + /// Append a single-byte VRAM-buffer entry. Used by both `nt_set` + /// and `nt_attr` — the only difference is which PPU base + /// address the `(x, y)` cell maps to. With `attr=false` the + /// target is the nametable at `$2000 + y*32 + x`; with + /// `attr=true` it's the attribute table at + /// `$23C0 + (y/4)*8 + (x/4)`. + /// + /// Layout written to the buffer at `VRAM_BUF_HEAD`: + /// `[len=1][addr_hi][addr_lo][data]`. After the append we + /// bump the head by 4 and store a fresh `0` sentinel so the + /// next NMI drain sees a well-formed buffer regardless of + /// whether more entries get appended later in the frame. + /// + /// The address arithmetic stays in the accumulator end-to-end + /// (no extra ZP scratch needed): `addr_hi` is `$20 + (y >> 3)` + /// for nametables or just `$23` for the attribute table; and + /// `addr_lo` is `(y << 5) | x` for nametables or + /// `$C0 + (y / 4) * 8 + (x / 4)` for the attribute table. + fn gen_nt_buf_append_single(&mut self, x: IrTemp, y: IrTemp, data: IrTemp, attr: bool) { + let x_addr = self.temp_addr(x); + let y_addr = self.temp_addr(y); + let data_addr = self.temp_addr(data); + // X = head offset. + self.emit(LDX, AM::Absolute(crate::runtime::VRAM_BUF_HEAD)); + // Write `len = 1`. + self.emit(LDA, AM::Immediate(1)); + self.emit(STA, AM::AbsoluteX(crate::runtime::VRAM_BUF_BASE)); + self.emit(INX, AM::Implied); + // Write `addr_hi`. + if attr { + self.emit(LDA, AM::Immediate(0x23)); + } else { + self.emit(LDA, AM::ZeroPage(y_addr)); + self.emit(LSR, AM::Accumulator); + self.emit(LSR, AM::Accumulator); + self.emit(LSR, AM::Accumulator); + self.emit(CLC, AM::Implied); + self.emit(ADC, AM::Immediate(0x20)); + } + self.emit(STA, AM::AbsoluteX(crate::runtime::VRAM_BUF_BASE)); + self.emit(INX, AM::Implied); + // Write `addr_lo`. + if attr { + // (y / 4) * 8 = (y & ~3) << 1 + self.emit(LDA, AM::ZeroPage(y_addr)); + self.emit(AND, AM::Immediate(0x1C)); + self.emit(ASL, AM::Accumulator); + // + (x >> 2) + self.emit(STA, AM::ZeroPage(0x02)); // safe scratch outside NMI / mul + self.emit(LDA, AM::ZeroPage(x_addr)); + self.emit(LSR, AM::Accumulator); + self.emit(LSR, AM::Accumulator); + self.emit(CLC, AM::Implied); + self.emit(ADC, AM::ZeroPage(0x02)); + // + $C0 + self.emit(CLC, AM::Implied); + self.emit(ADC, AM::Immediate(0xC0)); + } else { + // (y << 5) + x + self.emit(LDA, AM::ZeroPage(y_addr)); + self.emit(ASL, AM::Accumulator); + self.emit(ASL, AM::Accumulator); + self.emit(ASL, AM::Accumulator); + self.emit(ASL, AM::Accumulator); + self.emit(ASL, AM::Accumulator); + self.emit(CLC, AM::Implied); + self.emit(ADC, AM::ZeroPage(x_addr)); + } + self.emit(STA, AM::AbsoluteX(crate::runtime::VRAM_BUF_BASE)); + self.emit(INX, AM::Implied); + // Write the single data byte. + self.emit(LDA, AM::ZeroPage(data_addr)); + self.emit(STA, AM::AbsoluteX(crate::runtime::VRAM_BUF_BASE)); + self.emit(INX, AM::Implied); + // Update head and lay down a fresh sentinel. + self.emit(STX, AM::Absolute(crate::runtime::VRAM_BUF_HEAD)); + self.emit(LDA, AM::Immediate(0)); + self.emit(STA, AM::AbsoluteX(crate::runtime::VRAM_BUF_BASE)); + } + + /// Append a horizontal-fill VRAM-buffer entry. Layout: + /// `[len][addr_hi][addr_lo][tile][tile]...[tile]` where the + /// tile byte is repeated `len` times. The PPU's auto-increment + /// (1 by default) advances the VRAM cursor one cell per byte. + fn gen_nt_buf_append_fill_h(&mut self, x: IrTemp, y: IrTemp, len: IrTemp, tile: IrTemp) { + let x_addr = self.temp_addr(x); + let y_addr = self.temp_addr(y); + let len_addr = self.temp_addr(len); + let tile_addr = self.temp_addr(tile); + self.emit(LDX, AM::Absolute(crate::runtime::VRAM_BUF_HEAD)); + // len + self.emit(LDA, AM::ZeroPage(len_addr)); + self.emit(STA, AM::AbsoluteX(crate::runtime::VRAM_BUF_BASE)); + self.emit(INX, AM::Implied); + // addr_hi = $20 + (y >> 3) + self.emit(LDA, AM::ZeroPage(y_addr)); + self.emit(LSR, AM::Accumulator); + self.emit(LSR, AM::Accumulator); + self.emit(LSR, AM::Accumulator); + self.emit(CLC, AM::Implied); + self.emit(ADC, AM::Immediate(0x20)); + self.emit(STA, AM::AbsoluteX(crate::runtime::VRAM_BUF_BASE)); + self.emit(INX, AM::Implied); + // addr_lo = (y << 5) + x + self.emit(LDA, AM::ZeroPage(y_addr)); + self.emit(ASL, AM::Accumulator); + self.emit(ASL, AM::Accumulator); + self.emit(ASL, AM::Accumulator); + self.emit(ASL, AM::Accumulator); + self.emit(ASL, AM::Accumulator); + self.emit(CLC, AM::Implied); + self.emit(ADC, AM::ZeroPage(x_addr)); + self.emit(STA, AM::AbsoluteX(crate::runtime::VRAM_BUF_BASE)); + self.emit(INX, AM::Implied); + // Inner loop: write `len` copies of `tile`. We use Y as the + // fill counter so X stays as the buffer offset. + let suffix = self.local_label_suffix(); + let fill_label = format!("__ir_nt_fill_{suffix}"); + self.emit(LDY, AM::ZeroPage(len_addr)); + self.emit_label(&fill_label); + self.emit(LDA, AM::ZeroPage(tile_addr)); + self.emit(STA, AM::AbsoluteX(crate::runtime::VRAM_BUF_BASE)); + self.emit(INX, AM::Implied); + self.emit(DEY, AM::Implied); + self.emit(BNE, AM::LabelRelative(fill_label)); + // Update head + sentinel. + self.emit(STX, AM::Absolute(crate::runtime::VRAM_BUF_HEAD)); + self.emit(LDA, AM::Immediate(0)); + self.emit(STA, AM::AbsoluteX(crate::runtime::VRAM_BUF_BASE)); + } + /// Emit the MMC3 `__irq_user` handler that dispatches on the /// `(current_state, scanline_step)` pair. Supports multiple /// `on scanline(N)` handlers per state — they fire in ascending @@ -2942,6 +3105,9 @@ fn function_is_leaf(func: &IrFunction) -> bool { | IrOp::StopMusic | IrOp::ReadInputEdge { .. } | IrOp::Sprite0Split { .. } + | IrOp::NtSet { .. } + | IrOp::NtAttr { .. } + | IrOp::NtFillH { .. } | IrOp::SourceLoc(..) => false, }; if is_jsr_emitting { @@ -3215,6 +3381,9 @@ fn op_source_temps(op: &IrOp) -> Vec { IrOp::SetPaletteBrightness(level) => vec![*level], IrOp::FadeOut(n) | IrOp::FadeIn(n) => vec![*n], IrOp::Sprite0Split { scroll_x, scroll_y } => vec![*scroll_x, *scroll_y], + IrOp::NtSet { x, y, tile } => vec![*x, *y, *tile], + IrOp::NtAttr { x, y, value } => vec![*x, *y, *value], + IrOp::NtFillH { x, y, len, tile } => vec![*x, *y, *len, *tile], } } diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 850ba7a..7284211 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -1188,6 +1188,28 @@ impl LoweringContext { scroll_y: y, }); } + // VRAM update buffer intrinsics. Each call appends + // one entry to the runtime ring at $0400 that the + // NMI handler drains during vblank. + "nt_set" if args.len() == 3 => { + let x = self.lower_expr(&args[0]); + let y = self.lower_expr(&args[1]); + let tile = self.lower_expr(&args[2]); + self.emit(IrOp::NtSet { x, y, tile }); + } + "nt_attr" if args.len() == 3 => { + let x = self.lower_expr(&args[0]); + let y = self.lower_expr(&args[1]); + let value = self.lower_expr(&args[2]); + self.emit(IrOp::NtAttr { x, y, value }); + } + "nt_fill_h" if args.len() == 4 => { + let x = self.lower_expr(&args[0]); + let y = self.lower_expr(&args[1]); + let len = self.lower_expr(&args[2]); + let tile = self.lower_expr(&args[3]); + self.emit(IrOp::NtFillH { x, y, len, tile }); + } // `rand8()` / `rand16()` at statement position — // valid because they have side effects (advancing // the PRNG state). The returned value is discarded diff --git a/src/ir/mod.rs b/src/ir/mod.rs index c54f47d..78dd0b7 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -345,6 +345,39 @@ pub enum IrOp { scroll_y: IrTemp, }, + /// `nt_set(x, y, tile)` — append a single-tile nametable + /// write to the VRAM update buffer. Codegen computes the + /// PPU address `$2000 + y*32 + x` and lays down a + /// `[len=1][addr_hi][addr_lo][tile]` record at the current + /// `VRAM_BUF_HEAD`, then bumps the head by 4 and writes a + /// fresh `0` sentinel. The NMI handler drains the buffer + /// during vblank. + NtSet { + x: IrTemp, + y: IrTemp, + tile: IrTemp, + }, + /// `nt_attr(x, y, value)` — same shape as `NtSet` but the + /// PPU address resolves to the attribute table at + /// `$23C0 + (y/4)*8 + (x/4)`. Useful for changing a 4×4-cell + /// metatile group's sub-palette without rewriting the + /// underlying tiles. + NtAttr { + x: IrTemp, + y: IrTemp, + value: IrTemp, + }, + /// `nt_fill_h(x, y, len, tile)` — append a horizontal run of + /// `len` copies of `tile` starting at nametable cell `(x, y)`. + /// `len` is a runtime byte; the runtime must keep `len < 253` + /// to leave space for the buffer header. + NtFillH { + x: IrTemp, + y: IrTemp, + len: IrTemp, + tile: IrTemp, + }, + /// Edge-triggered input read: `p1.a.pressed` / `p1.a.released`. /// `dest` receives a boolean (0 or the button mask) — set when /// the button is pressed-but-was-not this frame (for diff --git a/src/linker/mod.rs b/src/linker/mod.rs index cafd13d..6e538f4 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -610,6 +610,14 @@ impl Linker { all_instructions.extend(runtime::gen_fade()); } + // VRAM update buffer drain. Splices the `__vram_buf_drain` + // routine when any `nt_set` / `nt_attr` / `nt_fill_h` + // intrinsic was lowered. The NMI handler JSRs it during + // vblank. + if has_label(user_code, "__vram_buf_used") { + all_instructions.extend(runtime::gen_vram_buf_drain()); + } + // Audio subsystem — linked in whenever user code touched // audio (detected via the `__audio_used` marker emitted by // the IR codegen). The driver body, period table, and @@ -740,6 +748,11 @@ impl Linker { // `p1.a.released` site lowers. Tells the NMI to snapshot the // previous-frame input byte before the new strobe. let has_edge_input = has_label(user_code, "__edge_input_used"); + // `__vram_buf_used` is dropped by the IR codegen for any + // `nt_set` / `nt_attr` / `nt_fill_h` call site. Brings in + // both the `__vram_buf_drain` runtime routine and the + // NMI-side JSR that calls it during vblank. + let has_vram_buf = has_label(user_code, "__vram_buf_used"); all_instructions.extend(runtime::gen_nmi(runtime::NmiOptions { has_ppu_updates, has_audio, @@ -749,6 +762,7 @@ impl Linker { has_p2_input, has_p1_input, has_edge_input, + has_vram_buf, })); // IRQ handler diff --git a/src/optimizer/mod.rs b/src/optimizer/mod.rs index e5dbb67..d78c284 100644 --- a/src/optimizer/mod.rs +++ b/src/optimizer/mod.rs @@ -602,6 +602,22 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet) { used.insert(*scroll_x); used.insert(*scroll_y); } + IrOp::NtSet { x, y, tile } => { + used.insert(*x); + used.insert(*y); + used.insert(*tile); + } + IrOp::NtAttr { x, y, value } => { + used.insert(*x); + used.insert(*y); + used.insert(*value); + } + IrOp::NtFillH { x, y, len, tile } => { + used.insert(*x); + used.insert(*y); + used.insert(*len); + used.insert(*tile); + } } } @@ -663,6 +679,9 @@ fn op_dest(op: &IrOp) -> Option { | IrOp::FadeOut(_) | IrOp::FadeIn(_) | IrOp::Sprite0Split { .. } + | IrOp::NtSet { .. } + | IrOp::NtAttr { .. } + | IrOp::NtFillH { .. } | IrOp::SetPalette(_) | IrOp::LoadBackground(_) | IrOp::SourceLoc(_) => None, diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 1cddf72..c7dce9b 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -209,6 +209,31 @@ pub const AUDIO_SFX_PITCH_PTR_HI: u16 = 0x07F7; pub const PREV_INPUT_P1: u16 = 0x07E6; pub const PREV_INPUT_P2: u16 = 0x07E7; +/// VRAM update buffer. +/// +/// User intrinsics (`nt_set`, `nt_attr`, `nt_fill_h`) append entries +/// to a 256-byte ring at `$0400-$04FF` during `on frame`; the NMI +/// handler drains the buffer to PPU `$2007` while it's safe to +/// write VRAM (vblank). Every entry has the form +/// `[len][addr_hi][addr_lo][byte_0]...[byte_(len-1)]`. A `len` +/// byte of zero is the end-of-buffer sentinel: the drain loop +/// stops there and resets the head pointer to zero. +/// +/// The buffer is gated on the `__vram_buf_used` marker label — +/// programs that never call any of the buffer intrinsics keep +/// these 256 bytes free for analyzer allocation. When the marker +/// is present, the analyzer skips `$0400-$04FF` so user globals +/// are pushed into `$0500+`. +/// +/// `VRAM_BUF_HEAD` is a single byte tracking the next-free offset +/// from `VRAM_BUF_BASE`. It lives in main RAM next to the buffer +/// itself rather than in zero page so it doesn't compete with the +/// runtime's other ZP slots; the codegen reads/writes it via +/// absolute addressing. +pub const VRAM_BUF_BASE: u16 = 0x0400; +pub const VRAM_BUF_END: u16 = 0x04FF; +pub const VRAM_BUF_HEAD: u16 = 0x07E5; + /// Fade-helper scratch bytes. `FADE_STEP_FRAMES` holds the /// per-step frame count saved at the top of `__fade_out` / /// `__fade_in`; `FADE_LEVEL` tracks the current brightness level @@ -439,6 +464,11 @@ pub struct NmiOptions { /// reference edge-triggered input leave this off and the /// two snapshot stores disappear. pub has_edge_input: bool, + /// When true, JSR `__vram_buf_drain` from the NMI after the + /// existing palette/background handshake. Programs that don't + /// call any of the buffer intrinsics leave this off and the + /// NMI body is byte-identical to the pre-buffer version. + pub has_vram_buf: bool, } #[must_use] @@ -452,6 +482,7 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec { has_p2_input, has_p1_input, has_edge_input, + has_vram_buf, } = opts; let mut out = Vec::new(); @@ -555,6 +586,16 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec { out.extend(gen_ppu_update_apply()); } + // VRAM update buffer: drain any entries user code appended this + // frame (via `nt_set` / `nt_attr` / `nt_fill_h`). Runs after the + // palette/background handshake so compiler-queued PPU writes win + // priority for vblank cycles. Gated on `has_vram_buf` — programs + // that never touch the buffer skip the JSR + the 256-byte + // reservation at `$0400-$04FF`. + if has_vram_buf { + out.push(Instruction::new(JSR, AM::Label("__vram_buf_drain".into()))); + } + // Controller sampling. The strobe write to $4016 latches both // controller ports on the same clock, so the 8-iteration shift // loop that follows can read whichever of the two the program @@ -1934,6 +1975,80 @@ pub fn gen_fade() -> Vec { out } +/// Generate the `__vram_buf_drain` runtime routine and the matching +/// reset-time clear. The drain is JSR'd from the NMI handler when +/// the `__vram_buf_used` marker is present. +/// +/// On entry the buffer at `VRAM_BUF_BASE` (`$0400`) holds zero or +/// more entries followed by a zero-byte sentinel. Each entry is +/// `[len][addr_hi][addr_lo][byte_0]...[byte_(len-1)]`; the drain +/// walks them in order, programming `$2006` with the PPU address +/// then writing each data byte to `$2007`. When it hits a zero +/// `len` byte it resets `VRAM_BUF_HEAD` to zero (so the next +/// frame starts appending at the front of the buffer) and stores +/// `0` back at `VRAM_BUF_BASE` so an empty buffer continues to +/// drain cleanly without a re-init. +/// +/// Cycle budget: one entry costs ~12 setup cycles + 8 cycles per +/// data byte. The 256-byte buffer can hold ~50 single-tile writes +/// (each 4 bytes: `len=1`, `addr_hi`, `addr_lo`, `byte`) which +/// drains in roughly 1000 cycles, well inside vblank's +/// ~2273-cycle budget. +/// +/// Uses register `X` as the current buffer offset. Caller +/// (the NMI handler) is responsible for save/restore. +#[must_use] +pub fn gen_vram_buf_drain() -> Vec { + let mut out = Vec::new(); + out.push(Instruction::new(NOP, AM::Label("__vram_buf_drain".into()))); + // X = current offset into VRAM_BUF_BASE. + out.push(Instruction::new(LDX, AM::Immediate(0))); + // Top of per-entry loop. A zero `len` byte is the sentinel. + out.push(Instruction::new(NOP, AM::Label("__vram_buf_loop".into()))); + out.push(Instruction::new(LDA, AM::AbsoluteX(VRAM_BUF_BASE))); + out.push(Instruction::new( + BEQ, + AM::LabelRelative("__vram_buf_done".into()), + )); + // A holds `len`. Stash it in Y for the inner data-copy loop. + out.push(Instruction::implied(TAY)); + // PPU address: write addr_hi then addr_lo to $2006. The + // entry layout is [len][addr_hi][addr_lo], so addr_hi is at + // offset+1 and addr_lo at offset+2. + out.push(Instruction::implied(INX)); + out.push(Instruction::new(LDA, AM::AbsoluteX(VRAM_BUF_BASE))); + out.push(Instruction::new(STA, AM::Absolute(0x2006))); + out.push(Instruction::implied(INX)); + out.push(Instruction::new(LDA, AM::AbsoluteX(VRAM_BUF_BASE))); + out.push(Instruction::new(STA, AM::Absolute(0x2006))); + // Inner loop: write `Y` data bytes from offset+3 onward to + // $2007. The PPU's auto-increment (set in $2000 bit 2 — we + // leave it at 1, the default) advances the VRAM pointer one + // cell per write. + out.push(Instruction::new(NOP, AM::Label("__vram_buf_data".into()))); + out.push(Instruction::implied(INX)); + out.push(Instruction::new(LDA, AM::AbsoluteX(VRAM_BUF_BASE))); + out.push(Instruction::new(STA, AM::Absolute(0x2007))); + out.push(Instruction::implied(DEY)); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__vram_buf_data".into()), + )); + // Move past the last data byte's offset before looping. + out.push(Instruction::implied(INX)); + out.push(Instruction::new(JMP, AM::Label("__vram_buf_loop".into()))); + out.push(Instruction::new(NOP, AM::Label("__vram_buf_done".into()))); + // Reset the head pointer so the next frame starts appending + // at offset 0. We don't need to clobber the rest of the + // buffer — the writer always lays down a fresh sentinel + // after each append. + out.push(Instruction::new(LDA, AM::Immediate(0))); + out.push(Instruction::new(STA, AM::Absolute(VRAM_BUF_HEAD))); + out.push(Instruction::new(STA, AM::Absolute(VRAM_BUF_BASE))); + out.push(Instruction::implied(RTS)); + out +} + /// Emit the reset-time PRNG state seed. Spliced into the reset /// path whenever `gen_prng` is linked in, so the first `rand8()` /// call returns a useful value even without an explicit diff --git a/tests/emulator/goldens/vram_buffer_demo.audio.hash b/tests/emulator/goldens/vram_buffer_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/vram_buffer_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/vram_buffer_demo.png b/tests/emulator/goldens/vram_buffer_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..5872a86e99b4775c0fb9542f828c84d4a63b7e8f GIT binary patch literal 37759 zcmeHQ4Ny~87KXAw2gOJ$;D9yG(nXEhG_zLdiafEPpn{4hx-t!h^`8}~yCYIe6t*KI zn~7~!$%tF1;@?tXDAmD^5?x4Gr#K)kTHTf?p}2Ikkb-R@kGwrMFDQnevX&%|Bw~j5#`p}chKv8Y{@33>XjTXfuCJtkC}E^x2H-M#XGHtt(&>|)!pJdHOB<=to^d>OZc0dILPp z(@VHd3MnJEG++?<+uW1w-_N78@csPm^;QPYYhB%?) zfkamC39%Z62Sy2Eb&sJ%+jSLUr_OM%H%kYRO_BZ%1hXT!cECCiJqYA?3#;_)Fa{+s zHY;G5_?HX0!c2lsa@r{w0nEo%l7U1;JH_>b4zIkNKt0oa$mFL+#RDJ3yH?Q|n7^~0O+zG=#z}Dr0 zoe77ULmlj>|GUTXs$`*PSCjBkvMT8Rr(ve^W_RZYSO)=WknH+8CG^ATQ}wA6B<=E~lU5U0j-$WZxerk}B#Wz<@OX zecR2*Z6e*KGV?iQxQ5ZV z3=y4qG&)ZKLPTp6h!)Wr#X(w~fJo^dD07`c1b(Vc9BS6j*XWO1N)Jx~+}gS@5}*yA zrDffK{Js#eOgSUi;YZ4!||ZE@u4+f(2jK}pfQU8?Moa&5w$`-#g) z5w;=b_Q!S;{@Put7))L^3BIGJ1h!J;o}MAu=hjVhXPx8C`sRvw$BN${2yFKcS{HWU z&==dO`TtA;#dsYkBmPhu^aCV_g5?keIrplF%#<678hgL3k8ulD7V+Lb84?#DU!AEf zX6(}tV`n`a#c98!_R!4E7B^N__s0_?T`SsYXVvaw)s z_zGDZPJzWC71iEWgT>(jvN)Utr^E)3Fzz6y#GJAJG0|z{b+&M3+pArV!A4*2O(jPnuST^O9Nw9%x-k9R6=}ER*2et{qszX(H0z~N*_R7A znop23HcR%_q(W_HPMc4KDok)ev7`=aA9n;(c^m42M)}POf{a$3YWYO4TDP+NY{r?d z62cNvIa4|Fd|AE);)CK?DEnC9ZK8)wAOjLA=`br)fd6h0UDwf`;N@L}y)!yB;pOP&s zitMVVki8#@!eeiwUvQZiPr1seBA#<>>mPx!2u?WaTNWX<4jv8)v*be-5pP3u?1SEV zW33D|atLEuAYtRE5`09+zT=RnXnC$`z@X_Sy*Bf5dZXf4W8iegK2_?g!6np1t#u0) zec}X{ufB;=ZoAS-!1dVyTb+ym69=||0U9DQRLiVO9-jwGqNiGB3+~v5j(x^s?pBDx_aPZXnBB+YNRw3$-bwEr645lQ_}6O{&jd=~B^^|Un> z%bV>yE!>SP;2Js@M< zf{6xZvq%%a)kz4*w+Si@Ea(1?Aa#s%q6$C)$}!o_ow2jtK)Q)#N&BmmSe9g{LWyNb zC+Idh5EBQqP{p#O{Z&dVOOBdCX_PGpt@KG%WzWdiDqY|1u{P=bMz61sZQ%=_#ovVBEFn9``4BJ@B zNmeg>KhrJ^=Lzy7YZbbONXJo;UHC{u#-qkbOPCh;AY6JnQ^q%zcYzNj#yXN|fh?x$ zjku=<-R&Rs)G$jKbey|<48s#ga0e9Vk(_4iTxJE=j&r`Z( zs4v@8rUl_SS2^36j3}tHSN3Sihn5@=%aXqg(y=W0{GIZ!ENOq063dcI?Nd3(CU7iE zI>wDQ|DP0`xw2#fbtfk$@ao#xx!DJAg$(ReOT4x!3wAz^?t{E*D)7MmP3T9;sL&rt zC1wfpIGv=In`!85bNy%Rzl{<;cgAkm>9>_gsXHh#8Xlm%JAE1Xi?xJgaEBqw{~8jN zkp)yLv_}V%5#l>W$>%%3 zW0YhLp~PI0iCe-kmvr!ZL0~R92w%lq5_8FZ3H8rD#VCnU5_3t+CG8@5;*1+-++*hU zWEdqeN@A28uKU~A;*xs)`@2^DYT@{oB6j7zky`ZY%;PTw>RZHH-km<}KMTSNewUQ> z&Ii0-c+}PIUmW@I=Mz8NGAFWi{Wzb!w-!Zye7@gH&p#$_D*glKXt@jgi;0ShEM1hG F{Xaj2J-Pq@ literal 0 HcmV?d00001 diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 81e542c..3781695 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -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 );