1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 17:28:00 +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

@ -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,
));
}
_ => {}
}
}

View file

@ -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],
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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