mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +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 from7b4570eby 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:
parent
7b4570eee5
commit
807c9c7318
15 changed files with 699 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
57
examples/vram_buffer_demo.ne
Normal file
57
examples/vram_buffer_demo.ne
Normal file
|
|
@ -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
|
||||
BIN
examples/vram_buffer_demo.nes
Normal file
BIN
examples/vram_buffer_demo.nes
Normal file
Binary file not shown.
|
|
@ -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<String>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IrTemp> {
|
|||
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],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -602,6 +602,22 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet<IrTemp>) {
|
|||
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<IrTemp> {
|
|||
| IrOp::FadeOut(_)
|
||||
| IrOp::FadeIn(_)
|
||||
| IrOp::Sprite0Split { .. }
|
||||
| IrOp::NtSet { .. }
|
||||
| IrOp::NtAttr { .. }
|
||||
| IrOp::NtFillH { .. }
|
||||
| IrOp::SetPalette(_)
|
||||
| IrOp::LoadBackground(_)
|
||||
| IrOp::SourceLoc(_) => None,
|
||||
|
|
|
|||
|
|
@ -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<Instruction> {
|
|||
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<Instruction> {
|
|||
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<Instruction> {
|
|||
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<Instruction> {
|
||||
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
|
||||
|
|
|
|||
1
tests/emulator/goldens/vram_buffer_demo.audio.hash
Normal file
1
tests/emulator/goldens/vram_buffer_demo.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/vram_buffer_demo.png
Normal file
BIN
tests/emulator/goldens/vram_buffer_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue