1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 09:18:01 +00:00
nescript/src/ir/mod.rs
Claude 5e5bed39a5
sprite-per-scanline: add cycle_sprites runtime flicker + debug telemetry
W0109 (shipped last commit) catches the 8-sprites-per-scanline
hardware limit at compile time for static layouts, but the
dynamic case — enemy formations, projectile clusters, animated
NPCs where coordinates come from variables — was still silent.
This change adds two layers of defense on top of W0109:

Layer 2: `cycle_sprites` runtime flicker intrinsic
  New keyword statement that rotates the OAM DMA start offset
  one slot per call. When called once per `on frame`, the PPU's
  sprite evaluation picks up a different subset of the 12+
  overlapping sprites each frame, so the permanent-dropout
  failure mode becomes visible flicker — the classic NES
  technique used by Gradius, Battletoads, and every shmup.

  Implementation:
    - Lexer keyword `KwCycleSprites` and parser production.
    - AST `Statement::CycleSprites(Span)`.
    - `IrOp::CycleSprites` lowered by the IR pass.
    - Codegen emits `LDA $07EF / CLC / ADC #4 / STA $07EF` with
      natural u8 wrap, plus a one-shot `__sprite_cycle_used`
      marker label the first time it fires.
    - Linker detects the marker and switches `gen_nmi` to the
      cycling variant, which reads the rotating offset from
      `$07EF` into OAM_ADDR before the DMA instead of writing
      a literal 0. Programs that don't call `cycle_sprites`
      skip the marker and get byte-identical ROM output.

Layer 3: debug-mode sprite overflow telemetry
  Mirrors the frame-overrun pair (`debug.frame_overrun_count` /
  `debug.frame_overran`). In debug builds the NMI handler reads
  `$2002` at the top of vblank, masks bit 5 (the PPU's sprite
  overflow flag), and if set bumps a cumulative counter at
  `$07FD` plus a sticky bit at `$07FC`. The sticky bit clears
  on every `wait_frame`.

  New debug builtins:
    - `debug.sprite_overflow_count()` → u8 peek of $07FD
    - `debug.sprite_overflow()` → u8 peek of $07FC (sticky bit)

  The hardware flag has well-known quirks but is correct for
  the overwhelming majority of cases and costs ~15 cycles per
  frame to sample. Release builds emit no overflow-check code
  at all, so the four bytes at `$07EF` / `$07FC`-`$07FD` stay
  free for user allocation.

Related changes:
  - `gen_nmi` now takes an `NmiOptions` struct. Four bool
    parameters tripped clippy's `fn_params_excessive_bools`.
  - CLI `build` now renders analyzer warnings on a successful
    build. Previously warnings were silently dropped unless
    the user also ran `nescript check`, which made W0109
    effectively invisible to CI and local dev alike. Existing
    pre-existing W0103 / W0106 warnings on `coin_cavern`,
    `mmc3_per_state_split`, `sprites_and_palettes` surface
    too — not regressions, just now visible.

New example: `examples/sprite_flicker_demo.ne`
  Draws 12 sprites into a 4-pixel band, W0109 fires at compile
  time with nine labels pointing at the offenders, and a
  `cycle_sprites` call at the end of `on frame` turns the
  hardware dropout into flicker. The committed emulator golden
  captures one frame of the cycling pattern (deterministic).

Tests:
  - `runtime::tests::nmi_debug_mode_samples_sprite_overflow`
  - `runtime::tests::nmi_sprite_cycle_variant_reads_rotating_offset`
  - `ir_codegen::*::debug_sprite_overflow_count_loads_07fd`
  - `ir_codegen::*::debug_sprite_overflow_flag_loads_07fc`
  - `ir_codegen::*::wait_frame_clears_sprite_overflow_sticky_in_debug_mode`
  - `ir_codegen::*::wait_frame_release_does_not_touch_sprite_overflow_sticky`
  - `ir_codegen::*::cycle_sprites_emits_marker_and_add4`
  - `ir_codegen::*::cycle_sprites_marker_dedup_across_multiple_calls`
  - `ir_codegen::*::program_without_cycle_sprites_emits_no_marker`
  - `analyzer::*::accepts_debug_sprite_overflow_builtins`
  - `analyzer::*::rejects_unknown_debug_method_lists_all_four_known_names`
  - `analyzer::*::accepts_cycle_sprites_statement`

Docs: `examples/war/COMPILER_BUGS.md` §4 now describes all three
layers (W0109, `cycle_sprites`, debug telemetry) with reasoning
for when each applies. `README.md` and `examples/README.md` add
the new example to their tables.

All 32 emulator goldens still match — the cycling is opt-in
and programs that don't call `cycle_sprites` or enable debug
mode are byte-identical to the pre-change output.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 22:07:19 +00:00

367 lines
12 KiB
Rust

mod lowering;
#[cfg(test)]
mod tests;
pub use lowering::{lower, RAW_ASM_PREFIX};
use crate::lexer::Span;
use std::fmt;
/// A unique identifier for a variable across the program.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct VarId(pub u32);
/// A virtual register — unlimited supply, resolved during codegen.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct IrTemp(pub u32);
impl fmt::Display for IrTemp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "t{}", self.0)
}
}
impl fmt::Display for VarId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "v{}", self.0)
}
}
/// The top-level IR program.
#[derive(Debug, Clone)]
pub struct IrProgram {
pub functions: Vec<IrFunction>,
pub globals: Vec<IrGlobal>,
pub rom_data: Vec<IrRomBlock>,
/// Ordered list of state names (index = state dispatch number).
pub states: Vec<String>,
/// Name of the initial state when the ROM boots.
pub start_state: String,
}
/// A global variable in the IR.
#[derive(Debug, Clone)]
pub struct IrGlobal {
pub var_id: VarId,
pub name: String,
pub size: u16,
/// Scalar initial value for single-byte globals. `None` means the
/// RAM-clear at reset leaves this global at 0.
pub init_value: Option<u16>,
/// Per-byte initial contents for array-literal globals
/// (e.g. `var xs: u8[4] = [1,2,3,4]`). Empty for scalars or
/// uninitialized arrays. Each entry is the initial byte at offset
/// `i` from the global's base address; trailing bytes not covered
/// by the literal stay zero-filled by the hardware init's RAM
/// clear. Mutually exclusive with a meaningful `init_value` in
/// practice: `lower_program` takes one path for scalars and
/// another for array literals.
pub init_array: Vec<u8>,
}
/// A block of constant data to be placed in ROM.
#[derive(Debug, Clone)]
pub struct IrRomBlock {
pub label: String,
pub data: Vec<u8>,
}
/// An IR function (includes state handlers, user functions, etc.)
#[derive(Debug, Clone)]
pub struct IrFunction {
pub name: String,
pub blocks: Vec<IrBasicBlock>,
pub locals: Vec<IrLocal>,
pub param_count: usize,
pub has_return: bool,
/// When `Some(bank_name)`, this function was declared inside a
/// `bank Foo { fun ... }` block in the source and its compiled
/// bytes belong in the named switchable PRG bank instead of the
/// fixed bank. The codegen separates the per-bank instruction
/// streams during [`IrCodeGen::generate`] and the linker assembles
/// each bank into its own 16 KB slot. State handlers and any
/// top-level functions leave this `None` and live in the fixed
/// bank alongside the runtime — the only mode prior to user-banked
/// codegen.
pub bank: Option<String>,
pub source_span: Span,
}
/// A local variable within a function.
#[derive(Debug, Clone)]
pub struct IrLocal {
pub var_id: VarId,
pub name: String,
pub size: u16,
}
/// A basic block — a straight-line sequence of ops ending with a terminator.
#[derive(Debug, Clone)]
pub struct IrBasicBlock {
pub label: String,
pub ops: Vec<IrOp>,
pub terminator: IrTerminator,
}
/// An IR operation.
#[derive(Debug, Clone)]
pub enum IrOp {
// Load/Store
LoadImm(IrTemp, u8),
LoadVar(IrTemp, VarId),
StoreVar(VarId, IrTemp),
// Arithmetic (8-bit)
Add(IrTemp, IrTemp, IrTemp),
Sub(IrTemp, IrTemp, IrTemp),
Mul(IrTemp, IrTemp, IrTemp),
And(IrTemp, IrTemp, IrTemp),
Or(IrTemp, IrTemp, IrTemp),
Xor(IrTemp, IrTemp, IrTemp),
/// Shift `src` left by a compile-time-known count. Lowering
/// extracts the count from constant RHS expressions; non-constant
/// shifts lower to [`IrOp::ShiftLeftVar`].
ShiftLeft(IrTemp, IrTemp, u8),
/// Shift `src` right by a compile-time-known count.
ShiftRight(IrTemp, IrTemp, u8),
/// Shift `src` left by a runtime-variable count held in `amt`.
/// Codegen emits a short loop. Never produced by the lowering
/// when the RHS constant-folds; the optimizer may also turn this
/// back into [`IrOp::ShiftLeft`] once `amt` is known.
ShiftLeftVar(IrTemp, IrTemp, IrTemp),
/// Shift `src` right by a runtime-variable count held in `amt`.
ShiftRightVar(IrTemp, IrTemp, IrTemp),
/// Software 8/8 divide: `dest = a / b`. Lowered to a `__divide`
/// call in codegen; the strength reducer folds constant divisors
/// into shifts / AND masks where possible before this runs.
Div(IrTemp, IrTemp, IrTemp),
/// Software 8/8 modulo: `dest = a % b`.
Mod(IrTemp, IrTemp, IrTemp),
Negate(IrTemp, IrTemp),
Complement(IrTemp, IrTemp),
// Comparison (sets a boolean temp)
CmpEq(IrTemp, IrTemp, IrTemp),
CmpNe(IrTemp, IrTemp, IrTemp),
CmpLt(IrTemp, IrTemp, IrTemp),
CmpGt(IrTemp, IrTemp, IrTemp),
CmpLtEq(IrTemp, IrTemp, IrTemp),
CmpGtEq(IrTemp, IrTemp, IrTemp),
// Array access
ArrayLoad(IrTemp, VarId, IrTemp),
ArrayStore(VarId, IrTemp, IrTemp),
// Function call
Call(Option<IrTemp>, String, Vec<IrTemp>),
// Hardware operations
DrawSprite {
sprite_name: String,
x: IrTemp,
y: IrTemp,
frame: Option<IrTemp>,
},
/// Read a controller input byte into a temp.
/// Second arg: 0 for player 1, 1 for player 2.
ReadInput(IrTemp, u8),
WaitFrame,
/// `cycle_sprites` — bump the runtime sprite-cycling offset
/// byte at `$07EF` by 4, with natural u8 wrap. Paired with
/// the cycling variant of the NMI handler that reads this
/// byte into `OAM_ADDR` before the OAM DMA so each frame's DMA
/// lands in a different slot of the PPU OAM buffer.
CycleSprites,
Transition(String),
/// Write PPU scroll registers (two writes to $2005: X then Y).
Scroll(IrTemp, IrTemp),
/// Debug: write a list of temps to the emulator debug port ($4800).
/// Stripped in release mode by the codegen.
DebugLog(Vec<IrTemp>),
/// Debug: runtime assertion — if `cond` is zero, halt with debug marker.
/// Stripped in release mode by the codegen.
DebugAssert(IrTemp),
/// Raw 6502 assembly text; parsed and emitted by the codegen.
InlineAsm(String),
/// `poke(addr, value)` — STA value to a fixed absolute address.
Poke(u16, IrTemp),
/// `peek(addr)` — LDA from a fixed absolute address into a temp.
Peek(IrTemp, u16),
// 16-bit operations — emitted for u16-typed expressions and
// assignments. Each wide value is carried as a pair of 8-bit
// temps `(lo, hi)`, so the existing temp-slot allocator still
// works without modification.
/// Load the high byte of a u16 variable (var address + 1).
/// The existing `LoadVar` is repurposed as "load the low byte"
/// because it loads from the var's base address — which is the
/// low byte of a little-endian u16.
LoadVarHi(IrTemp, VarId),
/// Store the high byte of a u16 variable (var address + 1).
StoreVarHi(VarId, IrTemp),
/// 16-bit add: `(d_lo, d_hi) = (a_lo, a_hi) + (b_lo, b_hi)`.
/// Codegen emits `CLC; LDA a_lo; ADC b_lo; STA d_lo; LDA a_hi;
/// ADC b_hi; STA d_hi` — the ADC for the high byte propagates
/// the carry flag set by the low-byte addition.
Add16 {
d_lo: IrTemp,
d_hi: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit subtract: `(d_lo, d_hi) = (a_lo, a_hi) - (b_lo, b_hi)`.
/// Uses SEC; SBC to propagate borrow through the high byte.
Sub16 {
d_lo: IrTemp,
d_hi: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit equality comparison; `dest = (a == b) ? 1 : 0`.
/// Lowered as two CMPs with a short-circuit on the low byte.
CmpEq16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit not-equal comparison.
CmpNe16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit unsigned less-than. `dest = (a < b) ? 1 : 0`.
/// Codegen compares high bytes first; falls through to compare
/// low bytes only when the high bytes are equal.
CmpLt16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit unsigned greater-than.
CmpGt16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit unsigned less-or-equal.
CmpLtEq16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit unsigned greater-or-equal.
CmpGtEq16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// `set_palette Name` — queues a palette update for the next
/// vblank. Codegen writes the palette's ROM label pointer into
/// the runtime-reserved ZP slot and sets a pending bit; the NMI
/// handler applies the write at the next vblank.
SetPalette(String),
/// `load_background Name` — queues a nametable update for the
/// next vblank. Codegen writes both the tiles and attributes
/// label pointers into their ZP slots and sets a pending bit;
/// the NMI handler applies the writes at the next vblank.
LoadBackground(String),
// Audio ops — map to the minimal APU driver emitted by the linker.
/// `play SfxName` — trigger a one-shot sound effect on pulse 1.
/// The sfx name is looked up in a builtin table; unrecognized names
/// play a generic beep.
PlaySfx(String),
/// `start_music TrackName` — play a sustained tone on pulse 2 until
/// `stop_music`. The track name is hashed into a tone parameter.
StartMusic(String),
/// `stop_music` — silence the music channel (pulse 2) and any
/// currently-playing SFX tail.
StopMusic,
// Source mapping
SourceLoc(Span),
}
/// A basic block terminator.
#[derive(Debug, Clone)]
pub enum IrTerminator {
/// Unconditional jump to a label.
Jump(String),
/// Conditional branch: if temp != 0 goto `true_label` else goto `false_label`.
Branch(IrTemp, String, String),
/// Return from function, optionally with a value.
Return(Option<IrTemp>),
/// Unreachable (after infinite loops, etc.)
Unreachable,
}
impl IrProgram {
/// Count total number of IR operations across all functions.
pub fn op_count(&self) -> usize {
self.functions
.iter()
.flat_map(|f| &f.blocks)
.map(|b| b.ops.len())
.sum()
}
/// Human-readable pretty-print of the entire program — used by
/// the `--dump-ir` CLI flag and by debugging sessions.
pub fn pretty(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
out.push_str("# IR Program\n");
let _ = writeln!(out, "# start_state = {}", self.start_state);
let _ = writeln!(out, "# states = {:?}", self.states);
if !self.globals.is_empty() {
out.push_str("\n# Globals\n");
for g in &self.globals {
let _ = writeln!(
out,
" {} {} (size={}) = {:?}",
g.var_id, g.name, g.size, g.init_value
);
}
}
for func in &self.functions {
let _ = writeln!(
out,
"\nfn {}({} params, has_return={}):",
func.name, func.param_count, func.has_return
);
for block in &func.blocks {
let _ = writeln!(out, " {}:", block.label);
for op in &block.ops {
let _ = writeln!(out, " {op:?}");
}
let _ = writeln!(out, " -> {:?}", block.terminator);
}
}
out
}
}
impl IrFunction {
/// Count total number of IR operations in this function.
pub fn op_count(&self) -> usize {
self.blocks.iter().map(|b| b.ops.len()).sum()
}
}