1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-10 01:37:45 +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:
Claude 2026-04-15 22:07:19 +00:00
parent d6cb84a5bd
commit 5e5bed39a5
No known key found for this signature in database
21 changed files with 739 additions and 24 deletions

View file

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

View 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

Binary file not shown.

View file

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