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:
parent
d6cb84a5bd
commit
5e5bed39a5
21 changed files with 739 additions and 24 deletions
|
|
@ -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)*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue