mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +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
|
|
@ -93,6 +93,7 @@ start Main
|
|||
| [`noise_triangle_sfx.ne`](examples/noise_triangle_sfx.ne) | Noise and triangle channel sfx via `channel: noise` / `channel: triangle` on `sfx` blocks |
|
||||
| [`sfx_pitch_envelope.ne`](examples/sfx_pitch_envelope.ne) | Per-frame pulse `pitch:` arrays — the audio tick walks the pitch envelope in lockstep with the volume envelope and writes `$4002` on every NMI for a frequency-sweeping siren tone |
|
||||
| [`metasprite_demo.ne`](examples/metasprite_demo.ne) | `metasprite Hero { sprite: ..., dx: [...], dy: [...], frame: [...] }` declarative multi-tile groups — `draw Hero at: (x, y)` expands to one OAM slot per tile so 16×16 sprites stop needing four hand-written `draw` statements |
|
||||
| [`sprite_flicker_demo.ne`](examples/sprite_flicker_demo.ne) | `cycle_sprites` — rotates the OAM DMA start offset one slot per frame so scenes with more than 8 sprites on a scanline drop a *different* one each frame. Turns the NES's permanent sprite-dropout hardware symptom into visible flicker, which the eye reconstructs from adjacent frames. Pairs with the compile-time `W0109` warning and the debug-mode `debug.sprite_overflow()` / `debug.sprite_overflow_count()` telemetry for a three-layer defense against the 8-sprites-per-scanline limit. |
|
||||
| [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, stomp-or-die enemy collisions, live stomp-count HUD, pickup coins, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness demonstrates the full gameplay loop (stomp, stomp, die, retry) inside six seconds |
|
||||
| [`war.ne`](examples/war.ne) | **Production-quality card game** — a complete port of War split across `examples/war/*.ne`: title screen with a 0/1/2-player menu, animated deal, sliding face-up cards, deck-count HUD, "WAR!" tie-break with buried cards, victory screen with a fanfare, and a brisk 4/4 march on pulse 2. Pulls in nearly every NEScript subsystem (custom 88-tile sheet, felt nametable, 8-bit LFSR PRNG, queue-based decks, phase machine inside `Playing`, multiple sfx + music tracks). Building it surfaced five compiler bugs / limitations, all catalogued in [`examples/war/COMPILER_BUGS.md`](examples/war/COMPILER_BUGS.md) — two fixed in the same PR. |
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
|
|||
| `metasprite_demo.ne` | declarative multi-tile sprites | A 16×16 hero sprite split into a `metasprite Hero { sprite: Hero16, dx: [...], dy: [...], frame: [...] }` declaration. `draw Hero at: (px, py)` then expands to one `DrawSprite` op per tile in the IR lowering, each with its dx/dy added to the user's anchor point and the frame offset by the underlying sprite's base tile. The codegen needs no metasprite-specific support — it sees N regular draws and the OAM cursor allocator handles the slots. |
|
||||
| `nested_structs.ne` | nested struct fields, array struct fields, chained literals | Two `Hero` instances each carry a `Vec2` position and a `u8[4]` inventory. Exercises `hero.pos.x` chained access, `hero.inv[i]` array-field access, and chained struct-literal initializers (`Hero { pos: Vec2 { x: ..., y: ... }, inv: [...] }`). |
|
||||
| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, stomp-or-die enemy collisions with a live stomp-count HUD, coin pickups, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness cycles through stomp, stomp, die, and retry inside six seconds. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. |
|
||||
| `sprite_flicker_demo.ne` | `cycle_sprites`, 8-per-scanline hardware limit | Twelve sprites packed onto the same 4-pixel band — two more than the NES's 8-sprites-per-scanline hardware budget. The W0109 analyzer warning fires at compile time, and a `cycle_sprites` call at the end of `on frame` rotates the OAM DMA offset one slot per frame so the PPU drops a *different* sprite each frame. The permanent-dropout failure mode becomes visible flicker, which the eye reconstructs across frames. The classic NES technique used by Gradius, Battletoads, and every shmup that ever existed. |
|
||||
| `war.ne` | **production-quality card game**, multi-file source layout | A complete port of the card game War, split across `examples/war/*.ne` files and pulled in via `include` directives. Title screen with a 0/1/2-player menu (cursor sprite, blinking PRESS A, brisk 4/4 march on pulse 2), a 50-frame deal animation, a deep `Playing` state with an inner phase machine (`P_WAIT_A`/`P_FLY_A`/.../`P_WAR_BANNER`/`P_WAR_BURY`/`P_CHECK`), card-conserving queue-based decks built on a 200-iteration random-swap shuffle, a "WAR!" tie-break that buries 3+1 face-down cards per player and plays a noise-channel thump per bury, and a victory screen with the builtin fanfare. The first NEScript example to use a top-level file as a thin shell that `include`s ~12 component files; building it surfaced and fixed two compiler bugs (E0506 too-many-params, and the IR-lowering `wide_hi` leak across functions). The remaining limitations and workarounds are catalogued in [`war/COMPILER_BUGS.md`](war/COMPILER_BUGS.md). |
|
||||
|
||||
## Emulator Controls
|
||||
|
|
|
|||
67
examples/sprite_flicker_demo.ne
Normal file
67
examples/sprite_flicker_demo.ne
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// Sprite-flicker demo — showcases `cycle_sprites`, NEScript's
|
||||
// opt-in mitigation for the NES's 8-sprites-per-scanline
|
||||
// hardware limit.
|
||||
//
|
||||
// The PPU evaluates OAM each scanline and picks the first 8
|
||||
// sprites that cover it; any 9th+ sprite on the same scanline
|
||||
// is silently dropped. Without sprite cycling, the SAME sprite
|
||||
// gets dropped every frame because the draw order is stable
|
||||
// frame-to-frame — you get a permanent dropout that looks like
|
||||
// a game bug.
|
||||
//
|
||||
// `cycle_sprites` rotates where the OAM DMA lands each frame,
|
||||
// so the PPU's "first 8" sweep picks up a different subset
|
||||
// each time. Sprites at the end of the OAM buffer still drop
|
||||
// sometimes, but they drop *different* sprites on adjacent
|
||||
// frames. The human eye reconstructs the missing pixels from
|
||||
// frame persistence, so the failure mode looks like gentle
|
||||
// flicker instead of missing objects. This is the classic NES
|
||||
// technique used by Gradius, Battletoads, and every shmup
|
||||
// that ever existed.
|
||||
//
|
||||
// This demo draws 12 sprites packed onto the same y row (row
|
||||
// 100), two wider than the 8-per-scanline budget. Without the
|
||||
// `cycle_sprites` call you would see sprites 9 through 12
|
||||
// completely invisible forever. With it they flicker, and the
|
||||
// scene is readable even though the hardware can only show 8
|
||||
// of them on any single scanline.
|
||||
//
|
||||
// The W0109 analyzer warning fires at compile time for this
|
||||
// layout because every coordinate is a literal — the three
|
||||
// layers of defense (compile-time W0109, runtime flicker via
|
||||
// `cycle_sprites`, debug-mode `debug.sprite_overflow*` telemetry)
|
||||
// all apply here.
|
||||
//
|
||||
// Build: cargo run -- build examples/sprite_flicker_demo.ne
|
||||
|
||||
game "Sprite Flicker Demo" {
|
||||
mapper: NROM
|
||||
}
|
||||
|
||||
on frame {
|
||||
// Twelve sprites on the same 8-pixel band: nine at y=100
|
||||
// plus three at y=104 (all overlap scanlines 104..107).
|
||||
// The PPU can only render 8 of them per scanline, so
|
||||
// without cycling four would be dropped every frame.
|
||||
draw Star at: (16, 100)
|
||||
draw Star at: (32, 100)
|
||||
draw Star at: (48, 100)
|
||||
draw Star at: (64, 100)
|
||||
draw Star at: (80, 100)
|
||||
draw Star at: (96, 100)
|
||||
draw Star at: (112, 100)
|
||||
draw Star at: (128, 100)
|
||||
draw Star at: (144, 100)
|
||||
draw Star at: (160, 104)
|
||||
draw Star at: (176, 104)
|
||||
draw Star at: (192, 104)
|
||||
|
||||
// Rotate the OAM DMA offset by one slot. Over 12 frames
|
||||
// every sprite gets dropped approximately once, producing
|
||||
// visible flicker rather than permanent dropout.
|
||||
cycle_sprites
|
||||
|
||||
wait_frame
|
||||
}
|
||||
|
||||
start Main
|
||||
BIN
examples/sprite_flicker_demo.nes
Normal file
BIN
examples/sprite_flicker_demo.nes
Normal file
Binary file not shown.
|
|
@ -471,6 +471,99 @@ Five tests in `src/analyzer/tests.rs`:
|
|||
draws inside an `if` block still trip W0109 (conservative
|
||||
over-count).
|
||||
|
||||
### Layer-2: runtime sprite cycling (`cycle_sprites`)
|
||||
|
||||
W0109 only catches the literal-coordinate case — a game with
|
||||
>8 dynamically-positioned sprites (enemies, projectiles,
|
||||
animated NPCs) is invisible to it. The hardware will still
|
||||
drop the 9th+ sprite on every frame, and because draw order
|
||||
is stable frame-to-frame the *same* sprite goes missing every
|
||||
frame, which reads to the developer as a game bug rather
|
||||
than a hardware limit.
|
||||
|
||||
The classic NES mitigation is sprite cycling: rotate the OAM
|
||||
DMA start offset each frame so different sprites land in the
|
||||
PPU's "first 8" on each successive frame. Over N frames (where
|
||||
N is the number of overlapping sprites) each sprite gets
|
||||
dropped exactly once, and the eye reconstructs the missing
|
||||
pixels from frame persistence. Permanent dropout becomes
|
||||
visible flicker — the failure mode every NES player
|
||||
recognises, and vastly better UX than "my bullet disappeared."
|
||||
|
||||
NEScript ships this as the opt-in `cycle_sprites` statement:
|
||||
|
||||
```nescript
|
||||
on frame {
|
||||
draw Enemy0 at: (e0x, e0y)
|
||||
draw Enemy1 at: (e1x, e1y)
|
||||
// ...lots of enemies...
|
||||
cycle_sprites // bump the rotating offset one slot
|
||||
wait_frame
|
||||
}
|
||||
```
|
||||
|
||||
Each call adds 4 to a one-byte runtime counter at `$07EF`
|
||||
(natural u8 wrap at 256 → 0) and emits a `__sprite_cycle_used`
|
||||
marker label. The linker reads the marker and swaps the NMI
|
||||
handler over to a variant that writes the counter to `$2003`
|
||||
before triggering the OAM DMA, so each frame's DMA lands in
|
||||
a different slot of the PPU's OAM buffer. Over 64 frames the
|
||||
rotation completes a full cycle.
|
||||
|
||||
Programs that don't call `cycle_sprites` emit no marker and
|
||||
get the original fixed-offset NMI path, so every existing
|
||||
golden ROM stays byte-identical. Opt-in by design — the
|
||||
tradeoff is "cosmetic HUD elements you pinned to slot 0 lose
|
||||
their pin" — so programs that manage OAM priority manually
|
||||
can keep doing so.
|
||||
|
||||
The [`examples/sprite_flicker_demo.ne`](../sprite_flicker_demo.ne)
|
||||
example drives 12 sprites into a 4-pixel band to exercise
|
||||
both W0109 at compile time and `cycle_sprites` at runtime;
|
||||
the committed emulator golden captures a specific frame of
|
||||
the cycling pattern.
|
||||
|
||||
### Layer-3: debug-mode runtime overflow telemetry
|
||||
|
||||
`debug.sprite_overflow_count()` and `debug.sprite_overflow()`
|
||||
mirror the existing `debug.frame_overrun_count()` /
|
||||
`debug.frame_overran()` pair. In debug builds the NMI handler
|
||||
samples the PPU's sprite-overflow flag (`$2002` bit 5) at the
|
||||
top of vblank — it reflects whether any scanline of the
|
||||
just-finished frame had more than 8 sprites and fired the
|
||||
hardware "give up" pathway. If the bit is set the handler
|
||||
bumps a cumulative counter at `$07FD` and sets a per-frame
|
||||
sticky bit at `$07FC`, which the next `wait_frame` clears.
|
||||
|
||||
User code reads those bytes via the new builtins:
|
||||
|
||||
```nescript
|
||||
debug.assert(not debug.sprite_overflow())
|
||||
```
|
||||
|
||||
…or, in an overlay:
|
||||
|
||||
```nescript
|
||||
var ovf: u8 = 0
|
||||
ovf = debug.sprite_overflow_count()
|
||||
draw Digit at: (8, 8) frame: ovf
|
||||
```
|
||||
|
||||
The PPU hardware flag has well-known quirks (it occasionally
|
||||
misses the 9th sprite or sets the flag when none actually
|
||||
overflowed), but it's correct for the overwhelming majority
|
||||
of cases and is essentially free to sample — one `LDA $2002;
|
||||
AND #$20` at NMI top, ~15 cycles per frame. Release builds
|
||||
never emit the check block, so the four bytes at `$07EF` /
|
||||
`$07FC`-`$07FD` remain free for the analyzer to allocate.
|
||||
|
||||
Combined, the three layers catch the sprite-per-scanline
|
||||
limit at three different lifecycle stages: W0109 at compile
|
||||
time for statically-knowable layouts, `debug.sprite_overflow*`
|
||||
at playtest time for the dynamic cases W0109 can't see, and
|
||||
`cycle_sprites` at runtime as a graceful fallback for the
|
||||
cases the user knows are unavoidable.
|
||||
|
||||
---
|
||||
|
||||
## 5. The `inline` keyword is a hint and is silently ignored for short functions *(FIXED)*
|
||||
|
|
|
|||
|
|
@ -943,7 +943,10 @@ impl Analyzer {
|
|||
// for completeness even though no current method
|
||||
// accepts any.
|
||||
match method.as_str() {
|
||||
"frame_overrun_count" | "frame_overran" => {
|
||||
"frame_overrun_count"
|
||||
| "frame_overran"
|
||||
| "sprite_overflow_count"
|
||||
| "sprite_overflow" => {
|
||||
if !args.is_empty() {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0203,
|
||||
|
|
@ -956,8 +959,8 @@ impl Analyzer {
|
|||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"unknown debug method '{method}' \
|
||||
(expected 'frame_overrun_count' or 'frame_overran')"
|
||||
"unknown debug method '{method}' (expected 'frame_overrun_count', \
|
||||
'frame_overran', 'sprite_overflow_count', or 'sprite_overflow')"
|
||||
),
|
||||
*span,
|
||||
));
|
||||
|
|
@ -1964,6 +1967,7 @@ impl Analyzer {
|
|||
}
|
||||
}
|
||||
Statement::WaitFrame(_) => {}
|
||||
Statement::CycleSprites(_) => {}
|
||||
Statement::SetPalette(name, span) => {
|
||||
if !self.palette_names.contains(name) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
|
|
@ -2364,6 +2368,7 @@ fn collect_calls_stmt(stmt: &Statement, calls: &mut Vec<String>) {
|
|||
Statement::Return(None, _)
|
||||
| Statement::Transition(_, _)
|
||||
| Statement::WaitFrame(_)
|
||||
| Statement::CycleSprites(_)
|
||||
| Statement::Break(_)
|
||||
| Statement::Continue(_)
|
||||
| Statement::InlineAsm(_, _)
|
||||
|
|
|
|||
|
|
@ -2413,3 +2413,67 @@ fn analyze_sprite_scanline_budget_recurses_into_if() {
|
|||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_accepts_debug_sprite_overflow_builtins() {
|
||||
// Both new debug methods should analyze without errors when
|
||||
// called with zero arguments, exactly like
|
||||
// frame_overrun_count / frame_overran.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var a: u8 = 0
|
||||
var b: u8 = 0
|
||||
on frame {
|
||||
a = debug.sprite_overflow_count()
|
||||
b = debug.sprite_overflow()
|
||||
wait_frame
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::E0201));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_rejects_unknown_debug_method_lists_all_four_known_names() {
|
||||
// When the user calls `debug.nope()`, the E0201 message
|
||||
// should list every supported method name so typo fixes are
|
||||
// obvious.
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var a: u8 = 0
|
||||
on frame {
|
||||
a = debug.nope()
|
||||
wait_frame
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::E0201),
|
||||
"expected E0201 for unknown debug method, got: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_accepts_cycle_sprites_statement() {
|
||||
// `cycle_sprites` is a no-arg keyword statement. It should
|
||||
// analyze cleanly in a frame handler without triggering any
|
||||
// errors or warnings.
|
||||
analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame {
|
||||
draw Blip at: (10, 20)
|
||||
cycle_sprites
|
||||
wait_frame
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,6 +149,13 @@ pub struct IrCodeGen<'a> {
|
|||
/// `false` and emit byte-identical ROM bytes for the audio
|
||||
/// subsystem.
|
||||
sfx_pitch_used: bool,
|
||||
/// Set to true the first time the codegen lowers an
|
||||
/// `IrOp::CycleSprites`. Drives the `__sprite_cycle_used`
|
||||
/// marker label, which the linker reads to switch the NMI
|
||||
/// handler over to the rotating-offset OAM DMA variant. The
|
||||
/// flag also guards emitting the marker label *once* even
|
||||
/// when the program calls `cycle_sprites` from many sites.
|
||||
sprite_cycle_used: bool,
|
||||
/// Set to true the first time we emit any PPU update op
|
||||
/// (`set_palette` / `load_background`). The linker uses the
|
||||
/// resulting `__ppu_update_used` marker label to decide whether
|
||||
|
|
@ -291,6 +298,7 @@ impl<'a> IrCodeGen<'a> {
|
|||
sprite_tiles: HashMap::new(),
|
||||
sfx_info: HashMap::new(),
|
||||
sfx_pitch_used: false,
|
||||
sprite_cycle_used: false,
|
||||
music_info: HashMap::new(),
|
||||
state_indices: HashMap::new(),
|
||||
function_names,
|
||||
|
|
@ -711,14 +719,16 @@ impl<'a> IrCodeGen<'a> {
|
|||
self.emit(LDA, AM::Immediate(0));
|
||||
self.emit(STA, AM::ZeroPage(ZP_FRAME_FLAG));
|
||||
// In debug mode, also clear the per-frame "did this frame
|
||||
// overrun" sticky bit at $07FE so user code sees a fresh
|
||||
// value next NMI even when the program has no explicit
|
||||
// `wait_frame` inside its handler. The IR-level WaitFrame
|
||||
// op clears it too, so explicit-wait programs already get
|
||||
// this for free; mirroring it here makes the implicit
|
||||
// main-loop path consistent.
|
||||
// overrun" sticky bit at $07FE and the "did sprite overflow
|
||||
// fire" sticky bit at $07FC so user code sees fresh values
|
||||
// next NMI even when the program has no explicit `wait_frame`
|
||||
// inside its handler. The IR-level WaitFrame op clears
|
||||
// them too, so explicit-wait programs already get this for
|
||||
// free; mirroring it here makes the implicit main-loop
|
||||
// path consistent.
|
||||
if self.debug_mode {
|
||||
self.emit(STA, AM::Absolute(0x07FE));
|
||||
self.emit(STA, AM::Absolute(0x07FC));
|
||||
}
|
||||
|
||||
// Dispatch on current_state using CMP + BNE + JMP trampoline
|
||||
|
|
@ -1247,9 +1257,10 @@ impl<'a> IrCodeGen<'a> {
|
|||
IrOp::WaitFrame => {
|
||||
// Poll frame flag at $00 until nonzero, then clear it.
|
||||
// In debug mode, also clear the per-frame "did the
|
||||
// previous frame overrun" sticky bit so user code
|
||||
// sees a fresh value next NMI. The cumulative
|
||||
// counter at $07FF is intentionally left alone.
|
||||
// previous frame overrun" and "did the previous frame
|
||||
// overflow sprites" sticky bits so user code sees a
|
||||
// fresh value next NMI. The cumulative counters at
|
||||
// $07FF and $07FD are intentionally left alone.
|
||||
let wait_label = format!("__ir_wait_{}", self.local_label_suffix());
|
||||
self.emit_label(&wait_label);
|
||||
self.emit(LDA, AM::ZeroPage(ZP_FRAME_FLAG));
|
||||
|
|
@ -1258,8 +1269,28 @@ impl<'a> IrCodeGen<'a> {
|
|||
self.emit(STA, AM::ZeroPage(ZP_FRAME_FLAG));
|
||||
if self.debug_mode {
|
||||
self.emit(STA, AM::Absolute(0x07FE));
|
||||
self.emit(STA, AM::Absolute(0x07FC));
|
||||
}
|
||||
}
|
||||
IrOp::CycleSprites => {
|
||||
// Emit the `__sprite_cycle_used` marker label exactly
|
||||
// once per program so the linker switches to the
|
||||
// cycling variant of the NMI handler. The label is
|
||||
// zero-length; it only matters as a lookup key in
|
||||
// the assembled label table.
|
||||
if !self.sprite_cycle_used {
|
||||
self.emit_label("__sprite_cycle_used");
|
||||
self.sprite_cycle_used = true;
|
||||
}
|
||||
// Add 4 to the rotating offset byte at $07EF. Four
|
||||
// `INC $07EF`s would also work but cost one extra
|
||||
// byte; `LDA / CLC / ADC #4 / STA` is 10 bytes and
|
||||
// lets us rely on the natural u8 wrap at 256 → 0.
|
||||
self.emit(LDA, AM::Absolute(0x07EF));
|
||||
self.emit(CLC, AM::Implied);
|
||||
self.emit(ADC, AM::Immediate(0x04));
|
||||
self.emit(STA, AM::Absolute(0x07EF));
|
||||
}
|
||||
IrOp::Transition(name) => {
|
||||
// Write the target state's index to current_state, then
|
||||
// call the target state's on_enter handler if it exists,
|
||||
|
|
@ -2274,6 +2305,7 @@ fn op_source_temps(op: &IrOp) -> Vec<IrTemp> {
|
|||
} => vec![*a_lo, *a_hi, *b_lo, *b_hi],
|
||||
IrOp::ReadInput(_, _)
|
||||
| IrOp::WaitFrame
|
||||
| IrOp::CycleSprites
|
||||
| IrOp::Transition(_)
|
||||
| IrOp::InlineAsm(_)
|
||||
| IrOp::Peek(_, _)
|
||||
|
|
@ -2927,6 +2959,185 @@ mod more_tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_debug_sprite_overflow_count_loads_07fd() {
|
||||
let insts = lower_and_gen_debug(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var n: u8 = 0
|
||||
on frame {
|
||||
n = debug.sprite_overflow_count()
|
||||
wait_frame
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let reads_counter = insts
|
||||
.iter()
|
||||
.any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x07FD));
|
||||
assert!(
|
||||
reads_counter,
|
||||
"debug.sprite_overflow_count() should LDA $07FD"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_debug_sprite_overflow_flag_loads_07fc() {
|
||||
let insts = lower_and_gen_debug(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var n: u8 = 0
|
||||
on frame {
|
||||
n = debug.sprite_overflow()
|
||||
wait_frame
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let reads_flag = insts
|
||||
.iter()
|
||||
.any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x07FC));
|
||||
assert!(reads_flag, "debug.sprite_overflow() should LDA $07FC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_wait_frame_clears_sprite_overflow_sticky_in_debug_mode() {
|
||||
// The per-frame sticky bit at $07FC must be cleared by the
|
||||
// wait_frame op in debug builds so user code reads a fresh
|
||||
// value every frame — same pattern as the frame-overrun
|
||||
// sticky at $07FE.
|
||||
let insts = lower_and_gen_debug(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let clears = insts
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x07FC));
|
||||
assert!(
|
||||
clears,
|
||||
"debug-mode wait_frame should clear the $07FC sticky bit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_wait_frame_release_does_not_touch_sprite_overflow_sticky() {
|
||||
// Release builds must not emit a store to $07FC so the
|
||||
// top-of-RAM debug slot stays available for user allocation.
|
||||
let insts = lower_and_gen(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let touches = insts
|
||||
.iter()
|
||||
.any(|i| (i.opcode == STA || i.opcode == LDA) && i.mode == AM::Absolute(0x07FC));
|
||||
assert!(!touches, "release-mode wait_frame must not touch $07FC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_cycle_sprites_emits_marker_and_add4() {
|
||||
// `cycle_sprites` must emit exactly one `__sprite_cycle_used`
|
||||
// label (the linker looks for its presence to switch NMI
|
||||
// variants), a read-modify-write of $07EF that adds 4 to the
|
||||
// rotating offset byte, and nothing else.
|
||||
let insts = lower_and_gen(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame {
|
||||
cycle_sprites
|
||||
wait_frame
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let marker_count = insts
|
||||
.iter()
|
||||
.filter(|i| matches!(&i.mode, AM::Label(l) if l == "__sprite_cycle_used"))
|
||||
.count();
|
||||
assert_eq!(
|
||||
marker_count, 1,
|
||||
"cycle_sprites should emit exactly one __sprite_cycle_used marker label"
|
||||
);
|
||||
|
||||
let has_lda = insts
|
||||
.iter()
|
||||
.any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x07EF));
|
||||
let has_adc = insts
|
||||
.iter()
|
||||
.any(|i| i.opcode == ADC && i.mode == AM::Immediate(0x04));
|
||||
let has_sta = insts
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x07EF));
|
||||
assert!(
|
||||
has_lda && has_adc && has_sta,
|
||||
"cycle_sprites should compile to LDA $07EF / ADC #4 / STA $07EF"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_cycle_sprites_marker_dedup_across_multiple_calls() {
|
||||
// A program with more than one `cycle_sprites` call still
|
||||
// emits the marker exactly once — the flag on the codegen
|
||||
// guards against duplicate labels that would break the
|
||||
// assembler.
|
||||
let insts = lower_and_gen(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame {
|
||||
cycle_sprites
|
||||
cycle_sprites
|
||||
cycle_sprites
|
||||
wait_frame
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let marker_count = insts
|
||||
.iter()
|
||||
.filter(|i| matches!(&i.mode, AM::Label(l) if l == "__sprite_cycle_used"))
|
||||
.count();
|
||||
assert_eq!(
|
||||
marker_count, 1,
|
||||
"multiple cycle_sprites calls should still produce exactly one marker label"
|
||||
);
|
||||
// And all three calls should still emit their ADC.
|
||||
let adc_count = insts
|
||||
.iter()
|
||||
.filter(|i| i.opcode == ADC && i.mode == AM::Immediate(0x04))
|
||||
.count();
|
||||
assert_eq!(adc_count, 3, "each cycle_sprites call should emit an ADC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_program_without_cycle_sprites_emits_no_marker() {
|
||||
// Opt-in: programs that never call `cycle_sprites` must not
|
||||
// emit the marker label, so the linker keeps the original
|
||||
// fixed-offset OAM DMA path and existing goldens stay
|
||||
// byte-identical.
|
||||
let insts = lower_and_gen(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame {
|
||||
draw Blip at: (10, 20)
|
||||
wait_frame
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let has_marker = insts
|
||||
.iter()
|
||||
.any(|i| matches!(&i.mode, AM::Label(l) if l == "__sprite_cycle_used"));
|
||||
assert!(
|
||||
!has_marker,
|
||||
"programs without cycle_sprites must not emit __sprite_cycle_used"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_draw_in_loop_emits_one_cursor_based_draw_not_unrolled() {
|
||||
// Regression test for bug B. A `draw` inside a `while`
|
||||
|
|
|
|||
|
|
@ -957,6 +957,9 @@ impl LoweringContext {
|
|||
Statement::WaitFrame(_) => {
|
||||
self.emit(IrOp::WaitFrame);
|
||||
}
|
||||
Statement::CycleSprites(_) => {
|
||||
self.emit(IrOp::CycleSprites);
|
||||
}
|
||||
Statement::Call(name, args, _) => {
|
||||
match name.as_str() {
|
||||
// Built-in `poke(addr, value)` — write a byte to
|
||||
|
|
@ -1550,7 +1553,7 @@ impl LoweringContext {
|
|||
Expr::DebugCall(method, _args, _) => {
|
||||
// The analyzer already validated the method name and
|
||||
// argument count, so we can dispatch on the method
|
||||
// name directly. Both currently-supported methods
|
||||
// name directly. All currently-supported methods
|
||||
// map to a Peek of a runtime address: the codegen
|
||||
// strips the read out and substitutes a constant
|
||||
// zero in release builds, so the builtin disappears
|
||||
|
|
@ -1559,6 +1562,8 @@ impl LoweringContext {
|
|||
let addr: u16 = match method.as_str() {
|
||||
"frame_overrun_count" => 0x07FF,
|
||||
"frame_overran" => 0x07FE,
|
||||
"sprite_overflow_count" => 0x07FD,
|
||||
"sprite_overflow" => 0x07FC,
|
||||
// Should be unreachable post-analyzer, but emit
|
||||
// a zero rather than panicking so a parser test
|
||||
// that bypasses the analyzer still produces IR.
|
||||
|
|
@ -1821,6 +1826,7 @@ fn is_splicable_void_stmt(stmt: &Statement) -> bool {
|
|||
| Statement::SetPalette(..)
|
||||
| Statement::LoadBackground(..)
|
||||
| Statement::WaitFrame(..)
|
||||
| Statement::CycleSprites(..)
|
||||
| Statement::Play(..)
|
||||
| Statement::StartMusic(..)
|
||||
| Statement::StopMusic(..)
|
||||
|
|
|
|||
|
|
@ -166,6 +166,12 @@ pub enum IrOp {
|
|||
/// 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),
|
||||
|
|
|
|||
|
|
@ -482,6 +482,7 @@ impl<'a> Lexer<'a> {
|
|||
"bank" => TokenKind::KwBank,
|
||||
"loop" => TokenKind::KwLoop,
|
||||
"wait_frame" => TokenKind::KwWaitFrame,
|
||||
"cycle_sprites" => TokenKind::KwCycleSprites,
|
||||
"u8" => TokenKind::KwU8,
|
||||
"i8" => TokenKind::KwI8,
|
||||
"u16" => TokenKind::KwU16,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ pub enum TokenKind {
|
|||
KwBank,
|
||||
KwLoop,
|
||||
KwWaitFrame,
|
||||
KwCycleSprites,
|
||||
KwU8,
|
||||
KwI8,
|
||||
KwU16,
|
||||
|
|
@ -193,6 +194,7 @@ impl std::fmt::Display for TokenKind {
|
|||
Self::KwBank => write!(f, "bank"),
|
||||
Self::KwLoop => write!(f, "loop"),
|
||||
Self::KwWaitFrame => write!(f, "wait_frame"),
|
||||
Self::KwCycleSprites => write!(f, "cycle_sprites"),
|
||||
Self::KwU8 => write!(f, "u8"),
|
||||
Self::KwI8 => write!(f, "i8"),
|
||||
Self::KwU16 => write!(f, "u16"),
|
||||
|
|
|
|||
|
|
@ -579,7 +579,20 @@ impl Linker {
|
|||
// `--debug` is active; that tells the runtime to splice
|
||||
// in the extra frame-overrun check at the top of NMI.
|
||||
let debug_mode = has_label(user_code, "__debug_mode");
|
||||
all_instructions.extend(runtime::gen_nmi(has_ppu_updates, has_audio, debug_mode));
|
||||
// `__sprite_cycle_used` is dropped by the IR codegen
|
||||
// whenever a `cycle_sprites` statement is lowered. When
|
||||
// present, the NMI handler reads the rotating offset byte
|
||||
// at $07EF instead of writing a literal 0 to $2003 before
|
||||
// the OAM DMA, turning the classic "same sprites dropped
|
||||
// every frame" hardware symptom into visible flicker that
|
||||
// the eye reconstructs across frames.
|
||||
let has_sprite_cycle = has_label(user_code, "__sprite_cycle_used");
|
||||
all_instructions.extend(runtime::gen_nmi(runtime::NmiOptions {
|
||||
has_ppu_updates,
|
||||
has_audio,
|
||||
debug_mode,
|
||||
has_sprite_cycle,
|
||||
}));
|
||||
|
||||
// IRQ handler
|
||||
all_instructions.push(Instruction::new(NOP, AM::Label("__irq".into())));
|
||||
|
|
|
|||
13
src/main.rs
13
src/main.rs
|
|
@ -380,6 +380,19 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
|
|||
}
|
||||
})?;
|
||||
|
||||
// Render any analyzer warnings that survived a successful
|
||||
// compile. Errors would have taken the `CompileError::Analyze`
|
||||
// path above and returned before we got here, so everything
|
||||
// left in `out.analysis.diagnostics` is a warning (W01xx).
|
||||
// Without this the CLI would silently swallow every warning
|
||||
// on a successful build, making them effectively invisible
|
||||
// — the warning machinery in the analyzer would still run,
|
||||
// but nobody would ever see its output unless they also
|
||||
// invoked `nescript check`.
|
||||
if !out.analysis.diagnostics.is_empty() {
|
||||
render_diagnostics(&source, &filename, &out.analysis.diagnostics);
|
||||
}
|
||||
|
||||
// Post-link CLI-only side effects: the various `--dump-*`
|
||||
// flags and the two optional file outputs. These are not
|
||||
// part of the pipeline because they're stdout / filesystem
|
||||
|
|
|
|||
|
|
@ -571,6 +571,7 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet<IrTemp>) {
|
|||
IrOp::LoadVarHi(_, _)
|
||||
| IrOp::ReadInput(_, _)
|
||||
| IrOp::WaitFrame
|
||||
| IrOp::CycleSprites
|
||||
| IrOp::Transition(_)
|
||||
| IrOp::InlineAsm(_)
|
||||
| IrOp::Peek(_, _)
|
||||
|
|
@ -617,6 +618,7 @@ fn op_dest(op: &IrOp) -> Option<IrTemp> {
|
|||
| IrOp::ArrayStore(_, _, _)
|
||||
| IrOp::DrawSprite { .. }
|
||||
| IrOp::WaitFrame
|
||||
| IrOp::CycleSprites
|
||||
| IrOp::Transition(_)
|
||||
| IrOp::Scroll(_, _)
|
||||
| IrOp::DebugLog(_)
|
||||
|
|
|
|||
|
|
@ -470,6 +470,15 @@ pub enum Statement {
|
|||
Draw(DrawStmt),
|
||||
Transition(String, Span),
|
||||
WaitFrame(Span),
|
||||
/// `cycle_sprites` — advance the runtime sprite-cycling offset
|
||||
/// by one slot (4 bytes). Each call rotates the start position
|
||||
/// of the next OAM DMA so scenes with more than 8 sprites on a
|
||||
/// scanline drop a different one each frame, turning permanent
|
||||
/// dropout into visible flicker. Compiles to `INC $07EF` (with
|
||||
/// natural u8 wrap at 256→0) plus the `__sprite_cycle_used`
|
||||
/// marker label the linker uses to select the cycling variant
|
||||
/// of the NMI handler.
|
||||
CycleSprites(Span),
|
||||
Call(String, Vec<Expr>, Span),
|
||||
/// `load_background Name` — queue the named background for a
|
||||
/// vblank-safe copy into nametable 0. Lowered to
|
||||
|
|
@ -517,6 +526,7 @@ impl Statement {
|
|||
| Self::Return(_, s)
|
||||
| Self::Transition(_, s)
|
||||
| Self::WaitFrame(s)
|
||||
| Self::CycleSprites(s)
|
||||
| Self::Call(_, _, s)
|
||||
| Self::LoadBackground(_, s)
|
||||
| Self::SetPalette(_, s)
|
||||
|
|
|
|||
|
|
@ -2369,6 +2369,11 @@ impl Parser {
|
|||
self.advance();
|
||||
Ok(Statement::WaitFrame(span))
|
||||
}
|
||||
TokenKind::KwCycleSprites => {
|
||||
let span = self.current_span();
|
||||
self.advance();
|
||||
Ok(Statement::CycleSprites(span))
|
||||
}
|
||||
TokenKind::KwLoadBackground => {
|
||||
let span = self.current_span();
|
||||
self.advance();
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ fn init_assembles_without_error() {
|
|||
|
||||
#[test]
|
||||
fn nmi_saves_and_restores_registers() {
|
||||
let nmi = gen_nmi(false, false, false);
|
||||
let nmi = gen_nmi(NmiOptions::default());
|
||||
// First three instructions should push A, X, Y
|
||||
assert_eq!(nmi[0].opcode, PHA);
|
||||
assert_eq!(nmi[1].opcode, TXA);
|
||||
|
|
@ -86,7 +86,7 @@ fn nmi_saves_and_restores_registers() {
|
|||
|
||||
#[test]
|
||||
fn nmi_triggers_oam_dma() {
|
||||
let nmi = gen_nmi(false, false, false);
|
||||
let nmi = gen_nmi(NmiOptions::default());
|
||||
let has_dma = nmi
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4014));
|
||||
|
|
@ -95,7 +95,7 @@ fn nmi_triggers_oam_dma() {
|
|||
|
||||
#[test]
|
||||
fn nmi_reads_controller() {
|
||||
let nmi = gen_nmi(false, false, false);
|
||||
let nmi = gen_nmi(NmiOptions::default());
|
||||
// Should write strobe to $4016
|
||||
let has_strobe = nmi
|
||||
.iter()
|
||||
|
|
@ -105,7 +105,7 @@ fn nmi_reads_controller() {
|
|||
|
||||
#[test]
|
||||
fn nmi_sets_frame_flag() {
|
||||
let nmi = gen_nmi(false, false, false);
|
||||
let nmi = gen_nmi(NmiOptions::default());
|
||||
let has_flag = nmi
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_FRAME_FLAG));
|
||||
|
|
@ -114,7 +114,7 @@ fn nmi_sets_frame_flag() {
|
|||
|
||||
#[test]
|
||||
fn nmi_assembles_without_error() {
|
||||
let nmi = gen_nmi(false, false, false);
|
||||
let nmi = gen_nmi(NmiOptions::default());
|
||||
let result = asm::assemble(&nmi, 0xF000);
|
||||
assert!(!result.bytes.is_empty());
|
||||
assert!(
|
||||
|
|
@ -132,7 +132,10 @@ fn nmi_debug_mode_bumps_overrun_counter() {
|
|||
// bump when the frame flag was clear. Without `debug_mode`,
|
||||
// neither the `INC` nor the guard label appear so release
|
||||
// builds keep the top byte of RAM free for user allocation.
|
||||
let nmi = gen_nmi(false, false, true);
|
||||
let nmi = gen_nmi(NmiOptions {
|
||||
debug_mode: true,
|
||||
..NmiOptions::default()
|
||||
});
|
||||
let has_inc = nmi.iter().any(|i| {
|
||||
i.opcode == INC && matches!(i.mode, AM::Absolute(a) if a == DEBUG_FRAME_OVERRUN_ADDR)
|
||||
});
|
||||
|
|
@ -141,7 +144,7 @@ fn nmi_debug_mode_bumps_overrun_counter() {
|
|||
"debug-mode NMI should INC the overrun counter at $07FF"
|
||||
);
|
||||
|
||||
let release_nmi = gen_nmi(false, false, false);
|
||||
let release_nmi = gen_nmi(NmiOptions::default());
|
||||
let has_inc_release = release_nmi.iter().any(|i| {
|
||||
i.opcode == INC && matches!(i.mode, AM::Absolute(a) if a == DEBUG_FRAME_OVERRUN_ADDR)
|
||||
});
|
||||
|
|
@ -151,6 +154,77 @@ fn nmi_debug_mode_bumps_overrun_counter() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nmi_debug_mode_samples_sprite_overflow() {
|
||||
// Debug NMI should read $2002 and INC the sprite overflow
|
||||
// counter at DEBUG_SPRITE_OVERFLOW_COUNT_ADDR when bit 5 is
|
||||
// set. Release NMI must not touch either.
|
||||
let nmi = gen_nmi(NmiOptions {
|
||||
debug_mode: true,
|
||||
..NmiOptions::default()
|
||||
});
|
||||
let has_status_read = nmi
|
||||
.iter()
|
||||
.any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x2002));
|
||||
assert!(
|
||||
has_status_read,
|
||||
"debug-mode NMI should read $2002 to sample sprite overflow"
|
||||
);
|
||||
let has_inc = nmi.iter().any(|i| {
|
||||
i.opcode == INC
|
||||
&& matches!(i.mode, AM::Absolute(a) if a == DEBUG_SPRITE_OVERFLOW_COUNT_ADDR)
|
||||
});
|
||||
assert!(
|
||||
has_inc,
|
||||
"debug-mode NMI should INC the sprite overflow counter at $07FD"
|
||||
);
|
||||
let has_sticky = nmi.iter().any(|i| {
|
||||
i.opcode == STA && matches!(i.mode, AM::Absolute(a) if a == DEBUG_SPRITE_OVERFLOW_FLAG_ADDR)
|
||||
});
|
||||
assert!(
|
||||
has_sticky,
|
||||
"debug-mode NMI should set the sprite overflow sticky bit at $07FC"
|
||||
);
|
||||
|
||||
let release_nmi = gen_nmi(NmiOptions::default());
|
||||
let has_inc_release = release_nmi.iter().any(|i| {
|
||||
i.opcode == INC
|
||||
&& matches!(i.mode, AM::Absolute(a) if a == DEBUG_SPRITE_OVERFLOW_COUNT_ADDR)
|
||||
});
|
||||
assert!(
|
||||
!has_inc_release,
|
||||
"release NMI must not touch the sprite overflow counter slot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nmi_sprite_cycle_variant_reads_rotating_offset() {
|
||||
// With `has_sprite_cycle = true`, the NMI handler should
|
||||
// read SPRITE_CYCLE_ADDR and write it to OAM_ADDR ($2003)
|
||||
// before the DMA, instead of the default fixed 0. The
|
||||
// default variant must stay byte-identical to legacy NMI.
|
||||
let cycling = gen_nmi(NmiOptions {
|
||||
has_sprite_cycle: true,
|
||||
..NmiOptions::default()
|
||||
});
|
||||
let reads_cycle = cycling
|
||||
.iter()
|
||||
.any(|i| i.opcode == LDA && i.mode == AM::Absolute(SPRITE_CYCLE_ADDR));
|
||||
assert!(
|
||||
reads_cycle,
|
||||
"sprite-cycling NMI should read SPRITE_CYCLE_ADDR"
|
||||
);
|
||||
|
||||
let default = gen_nmi(NmiOptions::default());
|
||||
let reads_cycle_default = default
|
||||
.iter()
|
||||
.any(|i| i.opcode == LDA && i.mode == AM::Absolute(SPRITE_CYCLE_ADDR));
|
||||
assert!(
|
||||
!reads_cycle_default,
|
||||
"default NMI must not touch SPRITE_CYCLE_ADDR"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn irq_handler_is_just_rti() {
|
||||
let irq = gen_irq();
|
||||
|
|
|
|||
1
tests/emulator/goldens/sprite_flicker_demo.audio.hash
Normal file
1
tests/emulator/goldens/sprite_flicker_demo.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/sprite_flicker_demo.png
Normal file
BIN
tests/emulator/goldens/sprite_flicker_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Loading…
Add table
Add a link
Reference in a new issue