Phase 2 of the post-PR-#31 audit. The codebase had four documented
"silently skip" paths that parsed user intent but produced no code.
Each one was the same shape as the state-local bug: the analyzer
accepted the program, the IR lowered the construct, but somewhere
downstream the emitted code was dropped on the floor — and a pixel
golden that captured the broken behaviour locked it in as correct.
Fix each per the plan, either by implementing the feature or
rejecting the program at the analyzer.
### on_exit handlers now actually run
`IrOp::Transition` used to comment "on_exit of the current state
isn't called here because we don't know from an IR op alone which
state we're leaving." The codegen emitted the exit handler's body
as an IR function but never JSR'd it. Three example programs
(pong, war, state_machine) relied on `stop_music` or mode-flag
translation inside `on exit` that had been silently never running.
Emit a small CMP-chain against `ZP_CURRENT_STATE` before each
transition: for every state that declares an on_exit, compare the
current index, branch past on miss, JSR the exit handler on match,
then JMP to the shared done-label so only the leaving state's
handler fires. The chain is inlined at each transition site
(bounded by the number of states declaring on_exit) rather than
factored into a single trampoline — simpler to reason about, and
transitions are rare enough that the extra bytes don't matter.
Pong / war / state_machine ROMs change because the dispatch code
is now emitted. Video goldens stay byte-identical (no transitions
happen within the 180-frame harness window under no-input). Pong
and war audio hashes shifted from pure code-layout timing and are
regenerated. `docs/pong.gif` and `docs/war.gif` are byte-identical.
### State-local array initializers now refuse to compile (E0601)
`src/ir/lowering.rs:887` had the comment "Array initializers for
state-locals aren't supported yet... Programs that try this should
get a diagnostic from the analyzer; for now, silently skip." The
analyzer never actually emitted that diagnostic. Verified by
compiling `state Main { var buf: u8[4] = [10,20,30,40] ... }`:
the program built a valid ROM with no trace of 10/20/30/40 in PRG.
Add E0601 to the analyzer's state-local pass. The IR lowerer's
defensive `continue` stays in place as a belt-and-braces guard.
### `on scanline` without MMC3 is now E0603
Previously E0203 ("invalid operation for type") which is a
miscategorisation — the feature is unsupported on the current
mapper, not a type error. Dedicated E0603 makes the future-work
shape explicit.
### `slow` variables now actually live outside zero page
`Placement::Slow` was parsed into the AST but `allocate_ram`
ignored it, so `slow var cold: u8` still landed in ZP like any
other u8. Wire `var.placement` through `allocate_ram_with_placement`
and skip the ZP branch when `Slow` is set. `Fast` remains
advisory (the existing default already prefers ZP for u8 vars),
validated by W0107.
### Other address-map silent drops hardened
Alongside the var_addrs hardening from phase 1, three `state_indices`
lookup sites that did `.copied().unwrap_or(0)` or silent `if let`
are now explicit panics: scanline IRQ dispatch, MMC3 reload, and
`IrOp::Transition`. A miss in any of them is a compiler bug, not
valid input — the analyzer catches unknown state names upstream.
### Regression guards
Four new tests would have failed against the old silently-dropping
code paths:
- `analyze_state_local_array_initializer_rejected` — expects E0601.
- `analyze_on_exit_declaration_accepted` — expects no errors.
- `analyze_slow_var_forced_out_of_zero_page` — expects alloc
address >= $0100.
- `transition_dispatches_leaving_states_on_exit_handler` — counts
distinct JSR targets in the PRG before/after adding `on exit` to
a state; the exit-bearing build must have more.
All 720 tests pass. All 34 emulator goldens pass after the pong/war
audio hash refresh.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
|
||
|---|---|---|
| .github/workflows | ||
| benches | ||
| docs | ||
| examples | ||
| fuzz | ||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| compiler-bugs.md | ||
| LICENSE | ||
| plan.md | ||
| README.md | ||
| spec.md | ||
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
Source: examples/war.ne
Source: examples/pong.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, real
inline funsplicing for single-return and void-body shapes, 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.assert, runtime array bounds checks, frame-overrun and sprite-overflow counters, and thedebug.frame_overrun_count()/debug.frame_overran()/debug.sprite_overflow_count()/debug.sprite_overflow()query builtins — all stripped entirely in release builds - Sprite-per-scanline mitigations -- three layers of defense for the NES's 8-sprites-per-scanline hardware limit: compile-time
W0109static check for literal layouts, runtimecycle_spritesflicker intrinsic for dynamic scenes, and debug-mode telemetry viadebug.sprite_overflow()for playtest assertions - 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 seven compiler bugs, all fixed on the same branch — see git log for the details. |
sha256.ne |
Interactive SHA-256 hasher — an on-screen keyboard lets the player type up to 16 ASCII characters, and pressing ↵ runs a full FIPS 180-4 SHA-256 compression on the NES (64 rounds + 48-entry message-schedule expansion, all written in NEScript with inline-asm 32-bit primitives). The 64-character hex digest renders as sprites across eight 8-character rows at the bottom of the screen. Splits across examples/sha256/*.ne with a phased driver that runs four iterations per frame so the full hash finishes in well under a second; the jsnes golden captures SHA-256("NES") = AE9145DB5CABC41FE34B54E34AF8881F462362EA20FD8F861B26532FFBB84E0D. |
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 |
694 tests across the lexer, parser, analyzer, IR, optimizer, codegen, assembler, linker, runtime, ROM, and asset modules, plus a pixel- and audio-exact emulator harness that captures a golden framebuffer + audio hash for every example. CI runs fmt, clippy, test, example compilation, and the emulator harness on every push.


