mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 17:06:04 +00:00
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
9.2 KiB
9.2 KiB
NEScript
A statically-typed, compiled programming language for NES game development.
NEScript compiles .ne source files directly into playable iNES ROM files, with no external assembler or linker dependencies. The compiler handles everything from source text to a ROM you can run in any NES emulator.
Source: examples/platformer.ne
Quick Start
# Build the compiler
cargo build --release
# Compile an example
cargo run -- build examples/hello_sprite.ne
# Run the output ROM in an emulator
# (produces examples/hello_sprite.nes)
Hello World
game "Hello" {
mapper: NROM
}
var px: u8 = 128
var py: u8 = 120
on frame {
if button.right { px += 2 }
if button.left { px -= 2 }
if button.down { py += 2 }
if button.up { py -= 2 }
draw Smiley at: (px, py)
}
start Main
Features
- Game-aware syntax -- states, sprites, palettes, backgrounds, and input are first-class constructs
- Full type system --
u8,i8,u16,bool, fixed-size arrays (u8[N]),enum,struct - Rich control flow --
if/else,while,for i in 0..N,loop,match - Functions -- with parameters, return types,
inlinehint, recursion detection - State machines --
statewithon enter,on exit,on frame,on scanline(N)handlers - Compile-time safety -- call depth limits, recursion detection, type checking, unused-var warnings
- IR-based optimizer -- constant folding, dead code elimination, strength reduction (incl. div/mod by power-of-two), copy propagation, peephole passes including INC/DEC fold and live-range slot recycling
- Full 16-bit arithmetic -- u16 add/sub/compare lower to carry-propagating paired operations
- Multiple mappers -- NROM, MMC1, UxROM, MMC3 (including multi-scanline IRQ dispatch per state)
- Audio subsystem -- frame-walking pulse driver with user-declared
sfx/musicblocks, builtin effects and tracks, period table, and zero-cost elision when unused - Palette & background pipeline --
paletteandbackgroundblocks, initial values loaded at reset, vblank-safeset_palette/load_backgroundruntime swaps - Asset pipeline -- PNG-to-CHR conversion, inline tile data, sfx envelopes, music note streams
- Inline assembly --
asm { ... }with{var}substitution, plusraw asm { ... }for verbatim blocks - Hardware intrinsics --
poke(addr, value)/peek(addr)for direct register access - Debug support --
--debugflag enablesdebug.log/debug.assertwrites to the emulator debug port - Compile-time diagnostics --
--dump-ir,--memory-map,--call-graphflags - Single binary -- no dependencies on ca65, Python, or any external tools
Documentation
- Language Guide -- complete reference for every language feature
- Architecture -- compiler internals and module overview
- NES Reference -- hardware quick reference for contributors
- Examples README -- how to build and run examples
Examples
| Example | Features demonstrated |
|---|---|
hello_sprite.ne |
D-pad input, sprite drawing |
bouncing_ball.ne |
Automatic movement, edge detection |
coin_cavern.ne |
Multi-state game, functions, constants, gravity |
arrays_and_functions.ne |
Arrays, functions, while loops, inline functions |
state_machine.ne |
State transitions, on enter/exit, timers |
sprites_and_palettes.ne |
Inline CHR data, scroll, type casting |
mmc1_banked.ne |
MMC1 mapper, bank declarations, multiply |
uxrom_user_banked.ne |
UxROM mapper with a bank Foo { fun ... } block — first example to put real user code in a switchable bank, called via a generated cross-bank trampoline |
uxrom_banked_to_banked.ne |
UxROM with two bank Foo { fun ... } blocks — exercises a banked→banked call (step in Logic calls clamp in Helpers) routed through the same trampoline that handles fixed→banked |
palette_and_background.ne |
Palette and background declarations, reset-time load, vblank-safe set_palette / load_background swaps |
auto_chr_background.ne |
background Stage @nametable("file.png") with automatic CHR generation — the resolver dedupes the PNG's 8×8 cells, encodes them as 2-bitplane CHR, and slots them into CHR ROM after the sprite tile range |
friendly_assets.ne |
Pleasant asset syntax — named NES colours, grouped bg0..sp3 palettes with universal:, ASCII pixel-art sprites, legend { } + map: tilemaps, palette_map: attribute grids, scalar sfx pitch:, note-name music with tempo: |
structs_enums_for.ne |
Structs, enums, for loops, struct literals |
nested_structs.ne |
Nested-struct fields (hero.pos.x) and array struct fields (hero.inv[0]) with chained literal initializers |
inline_asm_demo.ne |
Inline asm with {var} substitution, poke/peek |
audio_demo.ne |
Audio subsystem: user sfx/music blocks, builtin effects, play/start_music/stop_music |
noise_triangle_sfx.ne |
Noise and triangle channel sfx via channel: noise / channel: triangle on sfx blocks |
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 |
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 |
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 |
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 |
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 — two fixed in the same PR. |
Compiler Commands
# Compile to ROM
nescript build game.ne
# Compile with custom output path
nescript build game.ne --output my_game.nes
# Type-check only (no ROM output)
nescript check game.ne
# View generated 6502 assembly
nescript build game.ne --asm-dump
# Enable debug mode
nescript build game.ne --debug
Emulator Compatibility
Output ROMs are standard iNES format and work with any NES emulator:
Project Status
NEScript implements all five planned milestones:
| Milestone | Status | Key Features |
|---|---|---|
| M1: Hello Sprite | Done | Full compiler pipeline, assembler, ROM builder |
| M2: Game Loop | Done | Functions, arrays, IR, optimizer, call graph analysis |
| M3: Asset Pipeline | Done | PNG-to-CHR, sprites, debug.log / debug.assert |
| M4: Optimization | Done | Strength reduction, ZP promotion, type casting, asm-dump |
| M5: Bank Switching | Done | MMC1/UxROM/MMC3, bank declarations, software mul/div |
497 tests across the lexer, parser, analyzer, IR, optimizer, codegen, assembler, linker, runtime, ROM, and asset modules, with CI running fmt, clippy, test, and example compilation on every push.
