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

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

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

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

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

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

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

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

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

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

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

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

67 lines
2.5 KiB
Text

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