Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,48 @@ change needs a manual update + review.
- `docs/future-work.md` is the authoritative roadmap. If you finish an
item, delete its section; if you add a new gap, write one.

## New-feature PR checklist (don't ship silent drops)

PR #31 shipped a year-old state-local bug where the analyzer
allocated ZP slots for `state Foo { var x }` but the IR codegen
silently emitted zero bytes for every `LoadVar`/`StoreVar` on them.
The bug survived because pixel/audio goldens captured the broken
behaviour as ground truth and no test asserted a write-wait-read
round-trip. Several other features (uninitialized struct-field
writes, `on exit` handlers, `slow` placement, state-local array
initializers) had the same shape when we audited. To avoid
repeating the pattern, every new language-feature PR must include:

1. **An example program** under `examples/` that exercises the
feature on a path the emulator harness can observe at frame
180. If the feature doesn't produce visible output on
autopilot, rig a frame counter to drive it (see
`examples/palette_and_background.ne` for the pattern).
2. **A runtime behaviour assertion**, not just a shape or
byte-level assertion. Integration tests that only
`rom::validate_ines(&rom)` the output are ROM-*is-valid*
tests, not *feature-works* tests — they pass against a
compiler that silently drops the feature. Either add a
byte-level assertion that the expected instruction sequence
appears (e.g. `LDA #$7B / STA <addr>` for a known-value
write, as
`uninitialized_struct_field_store_emits_sta_to_allocated_address`
does), or — better — a round-trip test that compiles
`write(42) → wait_frame → assert(read == 42)` and fails
against a silently-dropping codegen.
3. **A negative test** that gives the analyzer a program using
the feature invalidly and asserts the right error code fires.
Without this, a future refactor that stops emitting the
diagnostic lets the silent-drop shape back in.

Address-map lookups in the codegen are a specific trap: the
pattern `if let Some(&addr) = self.<some_map>.get(var) { ... }`
with no `else` branch is how PR #31 shipped. If an IR op
references a `VarId` / state name / function name, treat a map
miss as a **compiler bug** and panic — use
`IrCodeGen::var_addr(var)` or an explicit `.unwrap_or_else(||
panic!(...))`. A silent zero-byte emit is worse than a crash.

## Things to avoid

- **Don't add backwards-compat shims.** The repo is pre-1.0; breaking
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ start Main
| [`sprite_flicker_demo.ne`](examples/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`](examples/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`](examples/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. |
| [`feature_canary.ne`](examples/feature_canary.ne) | **Regression canary** — a minimal program that paints a green universal backdrop at frame 180 when every memory-affecting construct round-trips correctly, and flips to red if any check fails. The committed golden is green; any silent-drop regression (state-locals, uninit struct field writes, u16 high byte, array elements, `slow` placement, function return values) turns it red. Built after PR #31 to close the "goldens capture whatever happens, not what should happen" failure mode that let the state-local bug survive for a year. |
| [`sha256.ne`](examples/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
Expand Down
4 changes: 2 additions & 2 deletions docs/language-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ reset_score()

- **No recursion.** Both direct and indirect recursion are compile errors (`E0402`).
- **Call depth limit.** The default maximum call depth is 8. Exceeding it produces error `E0401`.
- **Maximum 4 parameters per function.** The v0.1 calling convention passes parameters via four fixed zero-page slots (`$04`-`$07`). Declaring a function with 5+ parameters produces error `E0506`. Pack additional state into globals or split the function into smaller helpers.
- **Maximum 8 parameters per function.** The calling convention is hybrid: **leaf** functions (no nested `JSR` in their body) receive up to four parameters through fixed zero-page transport slots `$04`-`$07`, while **non-leaf** functions receive up to eight parameters via direct caller writes into per-function RAM spill slots (no transport, no prologue copy). Declaring a function with 9+ parameters produces error `E0506`. Declaring a leaf with 5+ parameters silently promotes it to the non-leaf convention — you pay the direct-write cost rather than the prologue-copy cost, which is still cheaper than the old transport-plus-spill path.

---

Expand Down Expand Up @@ -1349,7 +1349,7 @@ reference NEScript variables.
| E0503 | Undefined function |
| E0504 | Missing start declaration |
| E0505 | Multiple start declarations|
| E0506 | Function has too many parameters (max 4) |
| E0506 | Function has too many parameters (max 8) |

### Warnings (W01xx)

Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
| `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 seven compiler bugs across the analyzer, IR lowerer, and codegen that were all fixed on the same branch (see `git log` for details). |
| `pong.ne` | **production-quality Pong**, powerups, multi-ball, multi-file | A complete Pong game split across `examples/pong/*.ne`. CPU VS CPU / 1 PLAYER / 2 PLAYERS title menu with brisk pulse-2 title march and autopilot, smooth ball physics with wall and paddle bouncing, CPU AI that tracks the ball with a reaction lag and dead zone, three powerup types (LONG paddle for 5 hits, FAST ball on next hit, MULTI-ball on next hit spawning 3 balls) that bounce around the field and are caught by paddle AABB overlap, multi-ball scoring (each ball scores a point, round continues until last ball exits), inner phase machine (`P_SERVE`/`P_PLAY`/`P_POINT`), and a "PLAYER N WINS" victory screen with the builtin fanfare. First-to-7 wins. |
| `feature_canary.ne` | **regression canary**, state-locals, uninitialized struct-field writes, u16, arrays, `slow` placement, function returns | A minimal program whose sole job is to paint a green universal backdrop at frame 180 when every memory-affecting language construct round-trips a write through the compiler correctly, and to flip to red if any check fails. Each check writes a distinctive byte through one construct (state-local, uninit struct field, u8/u16 global, array element, `slow`-placed u8, function call return), reads it back, and clears `all_ok` on mismatch. Because the emulator harness compares pixels at frame 180, any compiler regression that silently drops one of these writes turns the committed golden red — the structural counter to the "goldens capture whatever happens, not what should happen" failure mode that let PR #31 survive for a year. |
| `sha256.ne` | **interactive SHA-256**, inline-asm 32-bit primitives, multi-file | A full FIPS 180-4 SHA-256 hasher split across `examples/sha256/*.ne`. An on-screen 5×8 keyboard grid lets the player type up to 16 ASCII characters (`A`..`Z`, `0`..`9`, space, `.`, backspace, enter), and pressing ↵ runs the 48-entry message-schedule expansion + 64-round compression on the NES itself. Every 32-bit primitive (`copy`, `xor`, `and`, `add`, `not`, rotate-right, shift-right) is hand-tuned inline assembly that walks the four little-endian bytes of a word with `LDA {wk},X` / `ADC {wk},Y` chains, so a whole round costs a few thousand cycles. The phased driver runs four schedule steps or four rounds per frame so the full compression finishes well under a second, and the 64-character hex digest renders as sprites in 8 rows of 8 glyphs at the bottom of the screen. The jsnes golden auto-types `"NES"` after 1 s of keyboard idle and captures its hash `AE9145DB5CABC41FE34B54E34AF8881F462362EA20FD8F861B26532FFBB84E0D`. |

## Emulator Controls
Expand Down
Binary file modified examples/arrays_and_functions.nes
Binary file not shown.
165 changes: 165 additions & 0 deletions examples/feature_canary.ne
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Feature canary — a round-trip smoke test for memory-affecting
// language features. Every check writes a distinctive constant
// through one language construct, then reads it back and compares
// against the written value. A pass leaves the universal palette
// green; any failure flips it to red.
//
// The goal is a single emulator golden that captures the green-
// backdrop "all features round-trip correctly" state at frame 180.
// If any of the following bugs reappear, the canary turns red and
// `tests/emulator/goldens/feature_canary.png` no longer matches:
//
// - PR #31 (state-local variable writes silently dropped)
// - Uninitialized struct-field writes silently dropped (caught
// while hardening `var_addrs` in this audit)
// - `slow` placement ignored (cold var still lands in ZP)
// - u16 high byte not stored
// - Array-element write silently dropped
// - Function return value dropped
//
// Every check cascades into `all_ok` (cleared to 0 on first
// failure), so the final set_palette call picks Pass/Fail from
// one flag. We deliberately do not use `debug.assert` because
// `--debug` builds strip nothing; the palette swap works in
// release and that's what the emulator harness runs.
//
// Build: cargo run -- build examples/feature_canary.ne

game "Feature Canary" { mapper: NROM }

// ── Palettes ────────────────────────────────────────────────
//
// Pass = all-green backdrop; Fail = all-red. The canary starts
// in Pass; if any round-trip check mismatches, `set_palette Fail`
// flips the entire screen red for the rest of the run.
palette Pass {
universal: green
bg0: [dk_green, lt_green, white]
bg1: [dk_green, lt_green, white]
bg2: [dk_green, lt_green, white]
bg3: [dk_green, lt_green, white]
sp0: [black, black, black]
sp1: [black, black, black]
sp2: [black, black, black]
sp3: [black, black, black]
}

palette Fail {
universal: red
bg0: [dk_red, lt_red, white]
bg1: [dk_red, lt_red, white]
bg2: [dk_red, lt_red, white]
bg3: [dk_red, lt_red, white]
sp0: [black, black, black]
sp1: [black, black, black]
sp2: [black, black, black]
sp3: [black, black, black]
}

// ── Types and storage ──────────────────────────────────────

struct Vec2 { x: u8, y: u8 }

// Uninitialized struct global — this is the shape that was
// silently dropping field writes before the `var_addrs` fix.
var pos: Vec2

// Global u8 / u16 / array — classic globals.
var scalar: u8 = 0
var wide: u16 = 0
var row: u8[4] = [0, 0, 0, 0]

// A deliberately-cold u8 placed via `slow` so the analyzer
// keeps it outside zero-page. If `slow` regresses to advisory,
// the allocation address moves into ZP but the round-trip still
// succeeds — so this byte is for memory-map inspection, not the
// backdrop flip.
slow var cold_byte: u8 = 0

fun double_u8(x: u8) -> u8 {
return x + x
}

// A six-parameter non-leaf function. The call site exercises
// the direct-write calling convention — the caller stages each
// arg straight into the callee's per-param RAM slot, no
// transport through `$04-$07`. Returns the sum of all six, so
// a regression that silently drops any one (same shape as PR
// #31 but for params) knocks the result off 21 and flips the
// canary red.
fun sum6(a: u8, b: u8, c: u8, d: u8, e: u8, f: u8) -> u8 {
var tmp: u8 = a + b
tmp = tmp + c
tmp = tmp + d
tmp = tmp + e
tmp = tmp + f
return tmp
}

// ── Main state ─────────────────────────────────────────────

state Main {
// State-local — the PR #31 bug.
var local_counter: u8 = 0
// Per-frame "pass" flag. Starts true each frame; any failed
// round-trip clears it.
var all_ok: u8 = 1

on enter {
set_palette Pass
}

on frame {
all_ok = 1

// Check 1: state-local write-read.
local_counter = 42
if local_counter != 42 { all_ok = 0 }

// Check 2: uninitialized struct-field write-read.
pos.x = 99
pos.y = 77
if pos.x != 99 { all_ok = 0 }
if pos.y != 77 { all_ok = 0 }

// Check 3: global u8.
scalar = 123
if scalar != 123 { all_ok = 0 }

// Check 4: global u16 > 255 (both low and high bytes must
// land — the u16 path splits into StoreVar + StoreVarHi).
wide = 1234
if wide != 1234 { all_ok = 0 }

// Check 5: array element write-read at nonzero index.
row[2] = 55
if row[2] != 55 { all_ok = 0 }

// Check 6: slow-placed global still round-trips.
cold_byte = 200
if cold_byte != 200 { all_ok = 0 }

// Check 7: function call return value survives the
// caller's frame of reference.
var r: u8 = double_u8(21)
if r != 42 { all_ok = 0 }

// Check 8: six-parameter non-leaf function — exercises
// the direct-write calling convention that lifts the
// old 4-param ceiling. 1+2+3+4+5+6 = 21.
var s: u8 = sum6(1, 2, 3, 4, 5, 6)
if s != 21 { all_ok = 0 }

// Drive the backdrop flip. `set_palette` schedules an
// update during the next vblank, so the effect lands on
// the following frame — well before the frame-180 golden
// sample.
if all_ok == 0 {
set_palette Fail
}

wait_frame
}
}

start Main
Binary file added examples/feature_canary.nes
Binary file not shown.
20 changes: 15 additions & 5 deletions examples/function_chain.ne
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@
//
// frame -> compute -> scale -> clamp -> fold -> taper
//
// Each function takes its argument through the zero-page
// parameter slots ($04-$07), computes a small transform, and
// returns a value in A. The chained result is what drives the
// player sprite's X position on screen each frame.
// Each function takes its argument through the calling
// convention and returns a value in A. The chained result is
// what drives the player sprite's X position on screen each
// frame.
//
// Parameters land at a non-uniform address set:
// - The deepest callee (`taper`) is a leaf — it has no nested
// `JSR` and can receive its arg in the `$04` transport slot
// directly. Its body reads `$04` in place of a spill copy.
// - Every other function (`compute`, `scale`, `clamp`, `fold`)
// is non-leaf and uses the direct-write convention: each
// caller stages the arg straight into the callee's
// analyzer-allocated param slot before the `JSR`. No
// transport, no prologue copy.
//
// What this exercises end-to-end:
// - Five levels of nested `JSR` without stack corruption
// - Parameter passing via `$04-$07` between callers
// - The hybrid leaf / non-leaf calling convention
// - Return value propagation through A
// - `fun ... -> u8 { return ... }` — the full typed-function
// shape, including an early `return` inside an `if`
Expand Down
Binary file modified examples/function_chain.nes
Binary file not shown.
Binary file modified examples/mmc1_banked.nes
Binary file not shown.
Binary file modified examples/pong.nes
Binary file not shown.
Binary file modified examples/sha256.nes
Binary file not shown.
Binary file modified examples/state_machine.nes
Binary file not shown.
Binary file modified examples/war.nes
Binary file not shown.
Loading
Loading