mirror of
https://github.com/imjasonh/nescript
synced 2026-07-09 09:18:01 +00:00
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
367 lines
12 KiB
Rust
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()
|
|
}
|
|
}
|