mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 17:06:04 +00:00
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
This commit is contained in:
parent
d6cb84a5bd
commit
5e5bed39a5
21 changed files with 739 additions and 24 deletions
|
|
@ -110,6 +110,59 @@ pub const DEBUG_FRAME_OVERRUN_ADDR: u16 = 0x07FF;
|
|||
/// two can be inspected together in a Mesen memory viewer.
|
||||
pub const DEBUG_FRAME_OVERRUN_FLAG_ADDR: u16 = 0x07FE;
|
||||
|
||||
/// Debug-mode cumulative sprite-per-scanline overflow counter.
|
||||
/// Incremented by the NMI handler once per frame in which the
|
||||
/// PPU's sprite overflow flag ($2002 bit 5) was set, i.e. any
|
||||
/// scanline of the just-finished frame had more than 8 sprites
|
||||
/// on it and the PPU silently dropped the excess. Read with
|
||||
/// `peek(0x07FD)` or `debug.sprite_overflow_count()`.
|
||||
///
|
||||
/// The PPU hardware flag has two well-known quirks — it can
|
||||
/// occasionally miss the 9th sprite or flag when none actually
|
||||
/// overflowed — but it's right for the overwhelming majority of
|
||||
/// cases and is essentially free to sample (one `LDA $2002; AND
|
||||
/// #$20` at the top of NMI). Pairs with the compile-time W0109
|
||||
/// warning: W0109 catches layouts knowable at compile time (text,
|
||||
/// HUD, title screens) and this counter catches the dynamic
|
||||
/// cases (enemy formations, projectile clusters) during
|
||||
/// playtesting in debug builds. Release-mode ROMs never touch
|
||||
/// this slot, so the analyzer is free to allocate over it.
|
||||
pub const DEBUG_SPRITE_OVERFLOW_COUNT_ADDR: u16 = 0x07FD;
|
||||
|
||||
/// Debug-mode "did the previous frame hit the 8-sprites-per-
|
||||
/// scanline limit" sticky bit. Set by the NMI handler together
|
||||
/// with [`DEBUG_SPRITE_OVERFLOW_COUNT_ADDR`], and cleared to 0
|
||||
/// by every `wait_frame` IR op (or the implicit main-loop
|
||||
/// clear) so user code sees a fresh value every frame.
|
||||
/// Exposed to user code as `debug.sprite_overflow()`, a
|
||||
/// per-frame boolean suited for
|
||||
/// `debug.assert(not debug.sprite_overflow())` guards during
|
||||
/// playtesting.
|
||||
pub const DEBUG_SPRITE_OVERFLOW_FLAG_ADDR: u16 = 0x07FC;
|
||||
|
||||
/// Runtime sprite-cycling offset. When any program statement
|
||||
/// emits a `cycle_sprites` call the codegen drops the
|
||||
/// `__sprite_cycle_used` marker, and the linker builds the
|
||||
/// cycling variant of the NMI handler: instead of writing 0
|
||||
/// to `OAM_ADDR` before the OAM DMA, it writes the current value
|
||||
/// of this byte, which rotates the destination slot of the DMA
|
||||
/// copy around the 64-slot OAM buffer. `cycle_sprites` adds 4
|
||||
/// to this byte each call (naturally wrapping at 256 back to 0),
|
||||
/// moving the copy start by one OAM slot per tick.
|
||||
///
|
||||
/// The result is the classic NES "sprite flicker" pattern: a
|
||||
/// scene with >8 sprites on a scanline drops a different one
|
||||
/// each frame rather than the same one every frame, so users
|
||||
/// perceive flicker instead of permanent dropout — vastly
|
||||
/// better UX because the eye reconstructs the missing pixels
|
||||
/// from adjacent frames.
|
||||
///
|
||||
/// Programs that never use `cycle_sprites` leave this byte at
|
||||
/// 0 forever and the NMI handler emits the original `LDA #0;
|
||||
/// STA $2003` sequence, preserving byte-for-byte compatibility
|
||||
/// with every existing golden ROM.
|
||||
pub const SPRITE_CYCLE_ADDR: u16 = 0x07EF;
|
||||
|
||||
// ── Extra channel state ──
|
||||
//
|
||||
// The pulse-1 sfx and pulse-2 music channels live in zero page
|
||||
|
|
@ -283,8 +336,41 @@ pub fn gen_enable_rendering(show_background: bool) -> Vec<Instruction> {
|
|||
/// [`DEBUG_FRAME_OVERRUN_ADDR`]. Release-mode ROMs never call
|
||||
/// this with `debug_mode=true`, so the counter slot stays free
|
||||
/// for user allocation.
|
||||
///
|
||||
/// `has_sprite_cycle` selects between two OAM DMA setup paths:
|
||||
/// when false the NMI writes a literal 0 to `$2003` before
|
||||
/// triggering the DMA (classic behaviour, byte-identical to
|
||||
/// every pre-cycling ROM), and when true it reads the rotating
|
||||
/// offset byte from [`SPRITE_CYCLE_ADDR`] so each frame's DMA
|
||||
/// lands in a different slot of the PPU's OAM buffer. The
|
||||
/// per-frame increment is emitted at the `cycle_sprites` call
|
||||
/// site, not here, so programs can choose to cycle every frame
|
||||
/// (one call in `on frame`) or every Nth frame.
|
||||
/// Compile-time switches that pick which NMI-handler variant
|
||||
/// the runtime emits. Each bool either inlines or skips a
|
||||
/// self-contained block inside [`gen_nmi`]; programs that don't
|
||||
/// opt into a feature pay zero ROM/cycle cost for it. Grouped
|
||||
/// into a struct rather than passed as individual parameters
|
||||
/// to avoid tripping the clippy `fn_params_excessive_bools`
|
||||
/// lint and to give future additions (another marker-label-
|
||||
/// triggered NMI block) an obvious extension point.
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NmiOptions {
|
||||
pub has_ppu_updates: bool,
|
||||
pub has_audio: bool,
|
||||
pub debug_mode: bool,
|
||||
pub has_sprite_cycle: bool,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool, debug_mode: bool) -> Vec<Instruction> {
|
||||
pub fn gen_nmi(opts: NmiOptions) -> Vec<Instruction> {
|
||||
let NmiOptions {
|
||||
has_ppu_updates,
|
||||
has_audio,
|
||||
debug_mode,
|
||||
has_sprite_cycle,
|
||||
} = opts;
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Save registers
|
||||
|
|
@ -304,6 +390,52 @@ pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool, debug_mode: bool) -> Vec<
|
|||
out.push(Instruction::new(LDA, AM::ZeroPage(0x03)));
|
||||
out.push(Instruction::implied(PHA));
|
||||
|
||||
// Debug-mode sprite overflow sampling. The PPU sets bit 5 of
|
||||
// $2002 when its sprite evaluation hits more than 8 in-range
|
||||
// sprites on any scanline of the frame it just finished
|
||||
// rendering. NMI fires at the start of vblank, right after
|
||||
// that rendering ends, so this is the exact moment the flag
|
||||
// is valid for "did the just-finished frame overflow". The
|
||||
// flag is cleared by the PPU at dot 1 of the pre-render line
|
||||
// (261), which is *before* the next NMI, so each NMI sees a
|
||||
// flag that reflects only the frame it follows.
|
||||
//
|
||||
// Reading $2002 has the side effects of (a) clearing the
|
||||
// vblank latch in bit 7 and (b) resetting the $2005/$2006
|
||||
// write-toggle. Both are harmless here: NMI was already
|
||||
// taken, and `gen_ppu_update_apply` below always opens its
|
||||
// own $2006 address with a fresh pair of writes so the reset
|
||||
// toggle doesn't confuse it.
|
||||
//
|
||||
// The counter/sticky pair mirrors the frame-overrun pattern
|
||||
// at $07FE/$07FF. Release builds don't emit this block at
|
||||
// all, so the two bytes at $07FC/$07FD stay free for the
|
||||
// analyzer to allocate over.
|
||||
if debug_mode {
|
||||
out.push(Instruction::new(LDA, AM::Absolute(PPU_STATUS)));
|
||||
out.push(Instruction::new(AND, AM::Immediate(0x20)));
|
||||
out.push(Instruction::new(
|
||||
BEQ,
|
||||
AM::LabelRelative("__debug_no_sprite_ovf".into()),
|
||||
));
|
||||
out.push(Instruction::new(
|
||||
INC,
|
||||
AM::Absolute(DEBUG_SPRITE_OVERFLOW_COUNT_ADDR),
|
||||
));
|
||||
// Reuse A, which still holds 0x20. Nonzero is enough for
|
||||
// the sticky bit; the exact value doesn't matter because
|
||||
// user code reads it as a boolean via
|
||||
// `debug.sprite_overflow()`.
|
||||
out.push(Instruction::new(
|
||||
STA,
|
||||
AM::Absolute(DEBUG_SPRITE_OVERFLOW_FLAG_ADDR),
|
||||
));
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__debug_no_sprite_ovf".into()),
|
||||
));
|
||||
}
|
||||
|
||||
// Run the audio driver's per-frame tick *after* the saves so it
|
||||
// can freely reuse A/X/Y and the $02/$03 scratch slots without
|
||||
// corrupting anything the main loop cares about. Programs that
|
||||
|
|
@ -312,8 +444,16 @@ pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool, debug_mode: bool) -> Vec<
|
|||
out.push(Instruction::new(JSR, AM::Label("__audio_tick".into())));
|
||||
}
|
||||
|
||||
// OAM DMA — transfer sprite data from $0200
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
|
||||
// OAM DMA — transfer sprite data from $0200. Programs that
|
||||
// don't use `cycle_sprites` get the classic fixed-offset
|
||||
// path (LDA #0); programs that opt in get the rotating
|
||||
// offset read from SPRITE_CYCLE_ADDR. Both variants write
|
||||
// the same low byte ($02) for the DMA source page.
|
||||
if has_sprite_cycle {
|
||||
out.push(Instruction::new(LDA, AM::Absolute(SPRITE_CYCLE_ADDR)));
|
||||
} else {
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
|
||||
}
|
||||
out.push(Instruction::new(STA, AM::Absolute(OAM_ADDR)));
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x02)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(OAM_DMA)));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue