1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 17:06:04 +00:00
Commit graph

40 commits

Author SHA1 Message Date
Claude
e4751df143
linker: add ca65-compatible --dbg output for source-level debugging
Emit a `.dbg` debug-info file in the same format `ld65` produces, so
Mesen / Mesen2 / fceuX pick it up automatically and enable source-line
stepping, labelled variable inspection, and symbol-based breakpoints
without manual address lookups. Closes #23.

The new `render_dbg` helper stitches together metadata the compiler
already surfaces (linker label table, IR codegen `__src_<N>` markers,
analyzer variable allocations) into the file/mod/seg/scope/span/line/sym
records documented at https://cc65.github.io/doc/debugfile.html. Each
source-loc marker becomes a span that stretches to the next marker
(so breakpoints cover every byte the statement compiled into) plus a
line record pointing into it; `seg.ooffs` tracks the fixed bank's
PRG-relative start so banked MMC1/UxROM/MMC3 ROMs map cleanly too.

Reuses the `.mlb` symbol-name filter so internal skip/block labels
stay out of the debugger's symbol browser. `--dbg` implies the same
`__src_` marker emission as `--source-map` but leaves release builds
byte-identical when neither flag is passed.

https://claude.ai/code/session_01DfN3pKJLryr7vvNFBpcqmC
2026-04-16 22:39:08 +00:00
Claude
0600f5b872
codegen: fuse compare-then-branch to drop boolean materialization
Every NEScript condition (`if x < N`, `while i < end`, etc.)
lowers in two IR ops: `CmpX(d, a, b)` materializes a 0/1
boolean into temp `d`, and the block's terminator
`Branch(d, t, f)` reads `d` and branches on it. The codegen
faithfully emitted both halves — `LDA / CMP / branch-to-true /
LDA #0 / JMP done / true: LDA #1 / done:`, then later
`LDA d_slot / BNE branch_t / JMP branch_f` — about 14 cycles +
13 bytes per condition.

The 6502's natural pattern is one `CMP` + one branch on the
flags it just set: 8 cycles, no register-clobber, no temp slot.
Detect the canonical pattern in `gen_block` (last op is an 8-bit
`CmpX` whose dest temp is what the terminator branches on, with
no other uses) and emit the fused form directly via a new
`gen_cmp_branch` helper. The temp's allocation, store, load, and
the terminator's branch fall away.

Bookkeeping subtlety: the source temps `a`/`b` must be retired
*after* the fused emit, not before — the original `gen_op` order
is "emit body of op, then `retire_op_sources`". Decrementing
their use counts before the CMP would free their slots while
they were still live; `load_temp(a)` would then re-allocate `a`
to whatever stale slot the free list popped next. Got hit by
this on the first attempt — the SHA-256 example dutifully
returned all-zero hashes until the order was fixed.

Updated `ir_codegen_local_label_suffix_is_bank_namespaced`: the
test was relying on `if x == 0` to emit `__ir_cmp_*` labels for
its bank-namespacing check, which the fusion now collapses into
direct branches. Switched the test source to a shift-by-variable
pattern (`x = x << n`), which always emits `__ir_shift_loop_*`
labels regardless of future cmp/branch optimizations.

Cycle savings: ~6 cycles per condition. The SHA-256 rotate
loops alone account for ~9K cycles per block. Across all
examples the cycle drift shows up as audio-tick phase shifts
in five timing-sensitive ROMs (`audio_demo`, `friendly_assets`,
`noise_triangle_sfx`, `platformer`, `sfx_pitch_envelope`); the
goldens for those are refreshed in this commit, plus
`platformer.gif` (the only demo gif whose bytes actually moved).

Verified: cargo test/clippy/fmt clean on rustc 1.95.0;
emulator harness 34/34; reproducibility diff clean; SHA-256 of
"NES" still computes to AE9145DB…4E0D.

https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
2026-04-16 17:10:02 +00:00
Claude
20a244b9e7
examples: regenerate ROMs, gifs, and goldens after codegen local fix
Commit 76d0fd0 moved function-locals from a codegen-minted
`$0300+` absolute range into the analyzer's zero-page
allocations so inline-asm `{param}` substitutions resolve
correctly (compiler-bugs.md #1). Observable semantics are
preserved — the analyzer + codegen now agree, and every
primitive that used to work still does — but the emitted ROM
bytes change whenever a function reads or writes a local,
because zero-page addressing uses a 2-byte instruction and
absolute addressing uses 3.

Consequences that need regenerated artifacts:

- **Twelve committed `.nes` files are stale.** Same source, new
  compiler, different bytes. The `Build Examples` CI job
  rebuilds each example into a tmp path and diffs against the
  committed ROM, so any drift is a hard failure. Rebuilt all
  twelve (arrays_and_functions, bitwise_ops, coin_cavern,
  function_chain, loop_break_continue, mmc1_banked, platformer,
  pong, sprites_and_palettes, state_machine, structs_enums_for,
  war).

- **Three goldens drift by one animation frame.** Zero-page
  addressing shaves a cycle per local access, which over a full
  frame handler shifts timing-sensitive sequences by a cycle or
  two. war's dealing animation and platformer + pong's audio
  tick stream catch the shift at frame 180 — war's card under
  player A's deck is now one frame earlier in its slide, and all
  three programs' captured audio buffers start from slightly
  different envelope positions. The new goldens (`war.png` + the
  three `.audio.hash` files) reflect the same code compiled with
  the cycle-count-corrected primitives.

- **`platformer.gif` and `war.gif` rebuild.** Same one-frame
  timing drift, integrated across 360 frames of captured
  gameplay — the emulator job's gif-reproducibility check
  wouldn't pass without the refresh. `pong.gif` happened to
  byte-match the old capture after rebuild.

All verified:
  - `cargo clippy --all-targets -- -D warnings` clean on both
    rustc 1.94.1 and 1.95.0.
  - `cargo test --all-targets` — 616 + 3 + 75 tests pass.
  - Full emulator harness — 34/34 ROMs match goldens.
  - Committed-ROM reproducibility diff clean — every
    `examples/*.ne` compiles byte-identical to its committed
    `.nes`.
  - `docs/{platformer,war,pong}.gif` byte-match fresh captures.
  - SHA-256 of "NES" still computes to `AE9145DB…4E0D`.

https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
2026-04-16 16:12:46 +00:00
Claude
6d9ebc7d7b
docs: add docs/pong.gif demo to README
Record a 6-second gif of examples/pong.nes running in jsnes and
embed it alongside docs/platformer.gif and docs/war.gif as the
third project demo. The gif opens on Pong's title menu (CPU VS
CPU / 1 PLAYER / 2 PLAYERS) — warmup = 4 frames keeps the menu
as the thumbnail the way war's recording does, and then the
headless autopilot advances to gameplay partway through the
clip.

- docs/pong.gif committed (128 KB)
- README.md links it under the war demo
- scripts/pre-commit rebuilds it when examples/pong* or the
  recorder/harness change
- .github/workflows/ci.yml fails if the committed copy is stale
- CLAUDE.md and tests/emulator/record_gif.mjs reference the new
  gif in the "how to regenerate" sections

https://claude.ai/code/session_0134F5uwDEVTes2Ee9S7JeXy
2026-04-16 10:44:57 +00:00
Claude
318a2f8bef
docs: add docs/war.gif demo to README
Captures the first ~6 s of examples/war.ne via the same
puppeteer + jsnes + gifenc pipeline that powers
docs/platformer.gif: title menu thumbnail, 52-card deal
animation, and a few rounds of CPU vs CPU play. Embedded
in the top-level README right under the platformer demo.

record_gif.mjs gains a 6th positional arg for the warmup
override (defaulting to the existing WARMUP env / 30) so
the war command can keep its title menu as the first frame
while platformer keeps skipping past its own title. The
CI emulator job and the pre-commit hook both rebuild the
gif into a tmp path and fail-with-fix-command if the
committed copy is stale; the war trigger covers war.ne,
war.nes, any examples/war/*.ne include, plus the recorder
and harness.
2026-04-16 00:37:23 +00:00
Claude
548787ac8a
W0110 inline fallback warning + docs refresh
W0110: when a function marked `inline` has a body shape the IR
lowerer can't splice (conditional early return, loops, nested
control flow, empty void body), the analyzer now emits a
warning at the declaration site so the declined hint is
visible instead of silently falling back to a regular JSR.

Implementation:
  - New `W0110` error code in `src/errors/diagnostic.rs` (warning level).
  - New `pub fn can_inline_fun(return_type, body) -> bool` in
    `src/ir/lowering.rs`, extracted from the existing capture
    logic so the analyzer and the IR lowerer share the same
    eligibility rules and can never drift.
  - New `check_inline_declinability` analyzer pass called from
    the tail of `analyze_program`, mirroring the existing
    `check_sprite_scanline_budget` / `check_unreachable_states`
    passes. Emits W0110 with help + note text pointing at the
    two accepted body shapes.
  - `capture_inline_bodies` now defers to `can_inline_fun`
    instead of duplicating the match pattern, so the two sides
    stay in lockstep by construction.

Four regression tests in `src/analyzer/tests.rs` cover the
conditional-return and while-loop declines plus the two
accepted shapes (single-return expression, void sequence).

Example source cleanups: `wrap52` in `examples/war/deck.ne`
and `abs_diff` in both `examples/arrays_and_functions.ne` and
`examples/loop_break_continue.ne` drop the `inline` keyword.
All three were dead hints — the `inline` was being silently
declined before this change, so removing it is source-only;
the three ROMs are byte-identical, all 32 emulator goldens
still match.

Docs refresh
  - `docs/language-guide.md`: rewrote the Inline Functions section
    (real behaviour + W0110), added W0105/W0106/W0107/W0108/W0109/
    W0110 to the warnings table, added the `debug.sprite_overflow*`
    builtins + sprite-per-scanline mitigations section to the
    Debug Mode docs, added a `cycle_sprites` statement entry and
    cross-referenced it from `draw`.
  - `docs/nes-reference.md`: fleshed out the "NEScript Memory
    Usage" block with the full ZP + high-RAM layout, including
    the new `$07EF` / `$07FC` / `$07FD` slots for sprite cycling
    and the debug sprite-overflow telemetry.
  - `docs/future-work.md`: documented all four debug query
    builtins in the "What ships today" block; updated the open
    "OAM allocation strategy" question to reference the shipped
    `cycle_sprites` path and ask about an automatic-flicker
    game attribute as a follow-up.
  - `docs/architecture.md`: updated the `ir/` and `optimizer/`
    module summaries to describe real inline splicing (now
    in lowering, not the optimizer).
  - `README.md`: reframed the `inline` bullet from "hint" to
    "real splicing for single-return / void-body shapes";
    expanded the debug-support bullet to mention the four
    query builtins and their stripping in release builds; added
    a new bullet for the three-layer sprite-per-scanline
    mitigations; bumped the test count from 497 → 694; updated
    the war.ne entry to mention the seven compiler bugs are all
    fixed and point readers at `git log` (instead of the
    deleted COMPILER_BUGS.md).
  - `examples/README.md`: same `git log`-pointing rewrite for
    the war.ne entry.

Deletions
  - `examples/war/COMPILER_BUGS.md` is removed. All seven
    catalogued bugs are fixed; the file's historical value
    lives in `git log` now. Every source-code comment and doc
    reference to the file has been updated to either point at
    `git log` or just describe the bug in place.

Test count: 616 unit + 75 integration + 3 doctests = 694 total.
Clippy / fmt clean. 32/32 emulator goldens match.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 23:19:07 +00:00
Claude
155a0e7096
analyzer: reject functions with more than 4 parameters (E0506)
The v0.1 calling convention passes parameters through four fixed
zero-page slots ($04-$07). Functions declared with 5+ parameters
were silently dropped past the 4th, producing a runtime miscompile
with no compile-time signal — a trap I hit while building the
War example (arm_fly took 6 params and silently corrupted fly_card
and fly_face_up).

Add E0506 to the analyzer so the over-arity case becomes a clear
compile-time error pointing at the user's `fun` declaration with
guidance toward globals or splitting. New tests cover both the 5-
param rejection and the 4-param accept boundary.

Documented in examples/war/COMPILER_BUGS.md §1, language-guide.md
"Restrictions" section, and the error code table.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:28:03 +00:00
Claude
6501f105bd
docs/future-work: prune items shipped on this branch
- PNG-sourced assets: drop the "automatic CHR generation" TODO
  now that `png_to_nametable_with_chr` ships with the resolver.
- User code distribution: drop the banked → banked TODO; only
  greedy size-packing and the MMC3 per-state-handler split remain.
- Language feature gaps: drop the metasprite row from the post-v0.1
  table and add a paragraph describing the new `metasprite` syntax.
  Drop the "nested struct / array struct field" gap; replace it
  with a note about the still-rejected array-of-structs case.
- Audio pipeline: note the new pulse pitch envelope path; replace
  the "pitch latches once" TODO with the triangle/noise extension.
- Debug instrumentation: note `debug.frame_overrun_count()` and
  `debug.frame_overran()`; drop the matching "richer telemetry" TODO.

Items kept (and unchanged) include the WASM build target, register
allocator, fixed-point arithmetic, text/HUD, tilemaps, SRAM, DMC,
multi-channel tracker, NSF/FTM imports, debug.overlay, per-state
background swaps, and the four open design questions.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 03:31:58 +00:00
Claude
359c945c30
docs: update future-work to reflect shipped items
Trims entries that landed on claude/complete-future-work-LQyos
(PNG assets, user code banking, NES 2.0 header, u16 struct fields,
triangle/noise sfx, .mlb/source-map/bounds-check/overrun
instrumentation, --no-opt, bench scaffolding, W0102/W0105-W0107
warnings) and keeps the remaining genuine gaps.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 11:43:59 +00:00
Claude
169a481099
feat(platformer): add stomp-or-die enemy collisions, live HUD, GameOver state
The previous platformer example drew enemies but had almost no
interaction with them: only enemy 1 had a stomp check, the stomp
window was unreachable under the default +1-px-per-frame-plus-a-
jump-every-40-frames autopilot, contact from any other angle was
a silent no-op, and the header comment promised a "title → playing
→ game-over state machine" that didn't actually exist. The README
demo gif and the committed golden both froze that state — a level
the player could walk through indefinitely with no consequence.

Flesh the enemy interaction model out into something real:

- `resolve_enemy_hit(e_sx)`: one helper, called symmetrically for
  both enemies. Computes the player/enemy hitbox overlap (horizontal
  in `e_sx ∈ (72, 96)`, vertical in `player_y ∈ (152, 176)`) and
  branches three ways — falling onto the head is a stomp bounce
  (`rise_count = 6`, `fall_vy = 0`, `stomp_count += 1`, `play Boing`);
  overlap while `rise_count > 0` is a grace pass-through so the
  stomp bounce itself can't retrigger contact on the same enemy;
  anything else (walking into the side, standing on the ground
  against the enemy) is fatal — `alive = 0` and `play hit`.

- New `GameOver` state: draws four enemy tiles across the middle
  of the screen plus a coin row sized to `stomp_count`, stops the
  music, lingers 60 frames then auto-retries, and also honours
  Start for an instant retry.

- Proximity-based autopilot: pre-jump when an enemy is exactly 19 px
  ahead (`e1_sx == 99` or `e2_sx == 99`), capped at two jumps per
  life by `auto_jumps < AUTOPILOT_JUMPS`. Tuning: a JUMP_RISE=12,
  GRAVITY_CAP=4 jump lands the player's feet at enemy-head height
  exactly 21 frames after lift-off, by which point the autopilot
  camera has scrolled the enemy under the player. The first jump
  fires on Playing frame 1 and stomps enemy 1 on frame 22; the
  second fires on Playing frame 101 and stomps enemy 2 on frame
  122. After that the autopilot is exhausted and the third enemy
  encounter (camera wraps back past enemy 1) is fatal — the
  golden harness now sees the full stomp, stomp, die, retry, stomp
  loop instead of a frozen walk.

- Live HUD: up to four coin sprites in the top-left, one per
  stomp, rendered both during `Playing` and on the `GameOver`
  screen so the score is visible in the death frame. `Playing`'s
  player draw is now guarded by `if alive == 1` so the hero
  disappears on the fatal-contact frame and the enemy that killed
  them is visible underneath.

Verified with a per-frame ZP trace through the patched puppeteer
+ jsnes harness: first stomp at emu frame 44 (camera_x=22), second
at emu frame 144 (camera_x=122), death at emu frame 283 (camera_x=5
after a 256-px wrap), `Playing` restart at emu frame 343, third
stomp at emu frame 365. All 22 emulator goldens still match after
the update, and `docs/platformer.gif` regenerated from the new ROM
now shows two clean stomps, a clean side-collision death, the
GameOver screen, and the retry cycle all inside the 6-second demo
window.

Golden updates:
- `tests/emulator/goldens/platformer.png` — the frame-180 capture
  now shows the hero walking forward with a two-coin HUD after
  both autopilot stomps (previously: a frozen bouncing hero).
- `tests/emulator/goldens/platformer.audio.hash` — the track now
  includes two `Boing` stomp bounces, which shifts the hash.
- `examples/platformer.nes` — rebuilt from the rewritten source.

Also updates the platformer rows in `README.md` and
`examples/README.md` to match the new gameplay.

https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
2026-04-13 20:23:07 +00:00
54910f2498
Merge branch 'main' into claude/test-platformer-game-6S3TX 2026-04-13 15:55:50 -04:00
Claude
ad951a835f
examples: adopt the friendly asset syntax
Rewrites every example with non-trivial asset declarations to use
the pleasant QoL syntax introduced in the previous commit. Every
example still compiles to a byte-identical ROM (verified by a
temp-path diff before committing), so the committed `.nes` files
and the 23 emulator goldens are unchanged.

  * platformer.ne — the centerpiece end-to-end demo:
      - `palette Main` goes to grouped form with a shared
        `universal: 0x22` (sky blue), one shared colour per
        sub-palette, and named NES colours throughout; the
        long-standing `$3F10` mirror-trap warning is now handled
        by the parser and the manual pitfall comment is gone.
      - `sprite Tileset` is 15 tiles of ASCII pixel art instead
        of 240 bytes of inline hex.
      - `background Level` uses a `legend { '.': 15, '#': 9, ... }`
        block plus `map:` strings for the 32×30 nametable, and
        `palette_map:` rows for the attribute table. The map
        reads top-down like the rendered screen.
      - SFX latch-once `pitch: 0x30` scalars + `envelope:` alias.
      - `music Theme` uses note names + `tempo: 10` default.
  * audio_demo.ne — scalar sfx pitches, `envelope:` alias, and a
    note-name `C4, E4, G4, ...` music track.
  * palette_and_background.ne — grouped CoolBlues / WarmReds
    palettes with `universal: black` + named colours, plus
    `legend` + `map:` tilemaps for the two backgrounds.
  * sprites_and_palettes.ne — Arrow and Heart sprites rewritten
    as `pixels:` ASCII art.

Along the way, two small parser extensions support the rewrites:

  - `parse_pixel_art` now accepts `a/b/c` as aliases for `#/%/@`,
    matching the vocabulary every NES editor (and our own
    gen_platformer_tiles.rs generator) uses.
  - `palette_map_to_attrs` allows up to 16 metatile rows (the
    full attribute-table coverage, including the off-screen
    bottom half) and auto-replicates row 14 → row 15 when only
    15 rows are supplied so the visible bottom of the screen
    gets consistent sub-palette assignments by default. The old
    15-row cap couldn't match a hand-packed `0xAA` attribute
    table for the last row; the platformer required this to
    stay byte-identical.

`scripts/gen_platformer_tiles.rs` is updated to emit the new
syntax directly (pixel-art `pixels:` block + `legend`/`map:`/
`palette_map:` for the background), so regenerating the
platformer tiles stays a one-liner.

474 lib tests + 64 integration tests pass (3 new parser tests
for `palette_map:` 15/16/17 rows and the `abc` alias). All 23
emulator goldens still match pixel- and sample-for-sample.

https://claude.ai/code/session_01PzaSFj3VahDzxEYTKCESkz
2026-04-13 18:04:21 +00:00
Claude
5e3e68ca11
docs: regenerate platformer.gif and lock it as a CI invariant
The optimizer fix in the previous commit changes the observable
gameplay of `examples/platformer.ne` — pre-fix the player got
spurious enemy-1 stomp bounces every time coin 2 drifted into its
pickup window, so the README demo gif showed the player bouncing
mid-air around emu frames 85-125 instead of walking through the
coin at ground level. Regenerate `docs/platformer.gif` from the
fixed compiler so the README matches reality.

To stop this from drifting again, treat the gif the same way the
repo already treats `examples/*.nes`:

- `gifenc` + `jsnes` + the harness are deterministic, so a fresh
  recording byte-matches a valid commit. Verified across two
  back-to-back runs (identical md5).

- `.github/workflows/ci.yml`'s `emulator` job now renders the gif
  into `/tmp/platformer.gif` and `cmp`s it against `docs/platformer.gif`,
  emitting a `::error` annotation pointing at the exact rerun
  command if the committed copy is stale. This piggybacks on the
  existing puppeteer + node setup, adding ~20s to the job.

- `scripts/pre-commit` runs the same check locally, but only when
  `examples/platformer.{ne,nes}`, `tests/emulator/record_gif.mjs`,
  or `tests/emulator/harness.html` is staged, and only if
  `tests/emulator/node_modules` is already installed. Cold-start
  puppeteer is ~20s — too slow to pay on every commit, but cheap
  enough to pay when something gif-relevant changed.

- The header of `tests/emulator/record_gif.mjs` and the project
  conventions section of `CLAUDE.md` both spell out the rerun
  command and the invariant, so the next agent doesn't have to
  re-derive any of this.

https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
2026-04-13 18:04:17 +00:00
Claude
48832ccb13
language: pleasant asset syntax for palettes, CHR, bg, sfx, music
Adds six NES-friendly authoring shortcuts so programs don't have to
hand-pack hex bytes for every kind of art asset. Every new syntax is
strictly additive — existing examples keep their byte-identical ROMs
and goldens.

  * palette: ~50 named NES colours (`black`, `sky_blue`, `dk_red`, …)
    usable anywhere a colour byte is expected, plus a grouped-form
    `bg0..sp3` + `universal:` shape that auto-fills every sub-
    palette's first byte (fixing the `$3F10` mirror trap).
  * sprite: `pixels:` ASCII-art alternative to 16-byte CHR, supporting
    multi-tile sprites split in row-major reading order.
  * sfx: scalar `pitch:` matching the v1 driver's latch-once behaviour,
    plus `envelope:` as a friendlier alias for `volume:`.
  * music: `tempo:` default duration + note-name notes (`C4, Eb4,
    rest 10`) alongside the existing `pitch, duration` pair form.
  * background: `legend { '.': 0, '#': 1 }` + `map:` string rows,
    plus `palette_map:` grids that auto-pack the 64-byte attribute
    table from 16×15 sub-palette digits.

A new `examples/friendly_assets.ne` exercises every shortcut at once
with a matching pixel + audio golden; the other 22 golden tests still
match byte-for-byte.

https://claude.ai/code/session_01PzaSFj3VahDzxEYTKCESkz
2026-04-13 16:09:53 +00:00
Claude
688d9afcec
platformer: end-to-end side-scroller demo + three runtime bug fixes
Adds `examples/platformer.ne`, a full side-scrolling game that
exercises nearly every subsystem of the compiler in one program:
custom CHR tileset, 32×30 background nametable with per-region
attribute palettes, 2×2 metasprite hero with gravity/jump physics,
wrap-around horizontal scrolling, moving enemies, coin pickups,
user-declared SFX + music, and a Title → Playing state machine
with autopilot so the headless jsnes harness captures real
gameplay at frame 180. Tile art + nametable are generated by
`scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`).

Building this out uncovered three independent runtime bugs that
together made the example render as black-on-black smileys. All
three are fixed in this commit:

1. **`gen_init` enabled sprite rendering before the linker's
   initial palette/background load runs.** The PPU's v-register
   auto-increments on every `$2007` write *during active
   rendering*, so the palette load (32 B) and nametable load
   (1024 B) were scrambled past the first ~72 bytes — every
   existing program with a `background Level { ... }` block was
   silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0`
   at the end of `gen_init` and emit a new `gen_enable_rendering`
   call *after* all initial VRAM writes complete.

2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio
   driver's period-table lookup reused `$02/$03` as a temporary
   indirect pointer with a comment claiming the slots were free
   because the tick doesn't call mul/div. But `$03` is also
   `ZP_CURRENT_STATE` used by the state dispatch loop, so every
   music note silently overwrote the state index with the high
   byte of `__period_table` (`0xC5` in the platformer ROM),
   wedging the state machine forever. Fix: `gen_nmi` now PHAs
   `$02/$03` on entry and PLA-restores them on exit, and the
   audio tick JSR moves inside that save/restore window (it used
   to be spliced by the linker *before* the register saves, so
   even A/X/Y were technically being trashed pre-save). Only
   `audio_demo`'s audio hash shifts (its note timings move a few
   cycles); every other golden is unchanged.

3. **Sub-palette mirroring footgun.** Writing a 32-byte palette
   blob sequentially causes the sprite sub-palettes' "index 0"
   slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background
   universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware
   mirroring. The example's palette sets all eight first bytes
   to `$22` (sky blue) for this reason; `docs/future-work.md`
   picks up a TODO to warn on inconsistent first-byte values in
   the analyzer.

Also:

- `docs/platformer.gif` — 6-second recording of the example
  running in jsnes, generated by the new
  `tests/emulator/record_gif.mjs` puppeteer helper (encodes via
  `gifenc`, committed as a dev-dependency under
  `tests/emulator/package.json`).
- README / examples/README tables and the 497-test count are
  updated to cover the new example.

https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 13:04:26 +00:00
Claude
d98c7f3d82
palette/background: first-class declarations with reset-time load and runtime swaps
Re-adds `palette Name { colors: [...] }` and
`background Name { tiles: [...], attributes: [...] }` as first-class
declarations, plus `set_palette Name` and `load_background Name`
statements for runtime swaps. Unlike the previous iteration that
quietly no-op'd, this one is fully wired through the pipeline and
its behavior is pinned by both unit tests and an emulator golden.

Pipeline:

- Lexer: re-adds `palette`, `background`, `set_palette`,
  `load_background` keywords and tokenizes them.
- AST: `PaletteDecl` (name + 1..=32 colour bytes) and `BackgroundDecl`
  (name + 0..=960 tile bytes + 0..=64 attribute bytes) live in
  `Program`. `Statement::SetPalette` and `Statement::LoadBackground`
  name-reference these declarations.
- Parser: `palette Name { colors: [...] }` / `background Name
  { tiles: [...], attributes: [...] }` blocks and their statement
  forms parse via the existing byte-array helper.
- Analyzer: validates colour indices ($00-$3F), palette length
  (<=32), nametable length (<=960), attribute length (<=64), and
  duplicate decl names. `set_palette` / `load_background` targets
  must reference a declared name (E0502 otherwise). When a program
  declares palette or background, the analyzer bumps the user
  zero-page allocator's starting address from `$10` to `$18` to
  reserve `$11-$17` for the runtime update handshake — programs
  that don't use the feature keep the old layout so their emulator
  goldens stay byte-exact.
- Assets: `PaletteData` and `BackgroundData` resolve declarations
  into zero-padded fixed-size blobs (32 / 960 / 64 bytes) and
  expose `label()` / `tiles_label()` / `attrs_label()` for codegen
  to reference.
- IR: new `IrOp::SetPalette(String)` and
  `IrOp::LoadBackground(String)`; lowering forwards the names
  verbatim.
- Codegen: `gen_set_palette` writes the palette label pointer into
  ZP `$12/$13` and ORs bit 0 into the update flags at `$11`;
  `gen_load_background` does the same for tile/attribute pointers
  at `$14/$15/$16/$17` with bit 1. Both emit a `__ppu_update_used`
  marker so the linker splices in the NMI apply helper only when
  the feature is actually used.
- Runtime: `gen_initial_palette_load` and
  `gen_initial_background_load` write the first declared
  palette/background at reset time (before rendering is enabled,
  where PPU writes are safe). `gen_nmi(has_ppu_updates)` takes a
  new flag; when true it splices `gen_ppu_update_apply` at the top
  of the NMI body, which checks the `$11` flags byte and copies
  pending palette / nametable data to `$3F00` / `$2000` inside
  vblank. All helpers use only ZP $02/$03 as scratch at reset time
  and never clobber ZP slots live across NMI.
- Linker: new `link_banked_with_ppu` takes slice of `PaletteData` /
  `BackgroundData`; splices each blob as a labelled data block in
  PRG ROM, picks the first-declared as the reset-time load target,
  enables background rendering automatically when a background is
  declared, and threads `has_ppu_updates` into `gen_nmi`. Old
  `link_banked` remains as a thin wrapper for callers without
  palette/background data so existing tests don't shift.

Tests:

- Lexer: tokenization of the 4 new keywords (single added test case).
- Parser: 5 new tests for `palette` / `background` decls with and
  without attributes, plus `set_palette` / `load_background`
  statements.
- Analyzer: 9 new tests covering acceptance of declared
  palettes/backgrounds, E0502 for unknown names, E0201 for
  out-of-range NES colors and oversized blobs, E0501 for duplicate
  names, and the zero-page-layout guard (palette/bg decls bump ZP
  start; no decls keeps it at $10).
- Resolver: 3 new tests for zero-padding, truncation of oversized
  decls, and label derivation.
- IR: 2 new lowering tests for `set_palette` and `load_background`.
- Integration: 5 new tests — blob contents spliced verbatim into
  PRG, `STA $12` / `STA $14` emitted by set_palette /
  load_background codegen, and a regression guard that programs
  without palette/background still land user vars at $10.
- Emulator: new `examples/palette_and_background.ne` driven by a
  frame counter that toggles between `CoolBlues` / `WarmReds` and
  `TitleScreen` / `StageOne` every 90 frames. Golden PNG and audio
  hash checked in under `tests/emulator/goldens/` and verified via
  `node run_examples.mjs` — rendered image shows the blue
  `CoolBlues` palette with the nametable populated from
  `TitleScreen`.

Docs:

- `README.md` adds the feature to the headline list and the example
  table.
- `docs/language-guide.md` restores the palette/background sections
  with the full 32-byte layout table and `set_palette` /
  `load_background` statement references.
- `docs/future-work.md` replaces the "removed as dead code" entry
  with the remaining gaps (PNG-sourced palette and nametable
  assets, cross-vblank large background updates, memory-map
  reporting).
- `spec.md` restores the grammar productions and usage examples.
- `examples/README.md` lists the new demo.

All 497 unit + integration tests pass. Clippy clean. All 21
emulator goldens match after the update pass.

https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 11:11:33 +00:00
Claude
fdb1ec7c91
cleanup: fix silent miscompiles and delete dead code exposed by code review
Two correctness bugs were silently producing wrong ROMs:

  - `x << n` / `x >> n` always shifted by 1, regardless of `n`, because
    the IR lowering for `BinOp::ShiftLeft`/`ShiftRight` hardcoded the
    count. Now eval_const the RHS into a compile-time count; fall back
    to a new `IrOp::ShiftLeftVar` / `ShiftRightVar` (runtime loop) when
    the amount isn't constant. Strength reduction folds the variable
    form back to a fixed count once the optimizer knows the value.

  - `x / n` / `x % n` always returned 0, because the lowering emitted
    `LoadImm(t, 0)` for `BinOp::Div`/`Mod` with a comment saying the
    runtime call was "TODO for now". Added real `IrOp::Div` and
    `IrOp::Mod`, wired them through use-counting and DCE, gave codegen
    `__divide`-based implementations, and taught strength reduction to
    rewrite power-of-two divisors into shifts and modulo-by-2ⁿ into
    AND masks. Constant folding now handles `Mul`/`Div`/`Mod`/shifts
    too, which were previously left for the codegen to emit inefficient
    software calls.

Dead code removed (no backward-compat shims kept):

  - `src/debug/` entirely. `DebugSymbols`, `SourceMap`, and the
    Mesen/.sym emitters had no callers outside their own tests;
    `main.rs` never wrote a symbol file. Documented the intent in
    `docs/future-work.md` so it comes back intentionally if needed.
  - `ErrorCode::E0202` (invalid cast) and `E0403` (unreachable state):
    defined, formatted, and marked `#[allow(dead_code)]` but never
    emitted. W0104 now carries the unreachable-state semantics too.
  - `Level::Info`: never constructed.
  - `load_background` / `set_palette` statements and their
    `BackgroundDecl` / `PaletteDecl` parser support: parsed and
    silently dropped by IR lowering (`// TODO: implement in asset
    pipeline`). Removed keywords, AST variants, parser paths, analyzer
    arms, and tests. `docs/future-work.md` documents the runtime
    palette/nametable design for when it comes back.

Doc cleanup:

  - `docs/architecture.md` was describing files that don't exist
    (`analyzer/types.rs`, `optimizer/const_fold.rs`, `codegen/regalloc.rs`,
    `rom/header.rs`, `debug/symbols.rs`, …). Rewrote it to match the
    real flat `mod.rs` + `tests.rs` layout and the real pipeline order.
  - `docs/future-work.md` was a hybrid of open work and "recently
    completed" entries that duplicated the active stubs at the top of
    the file. Collapsed to just the gaps that are actually still open.
  - `README.md` claimed Mesen symbol export and 210 tests; updated both.
  - `docs/language-guide.md` and `spec.md` described `palette` decls,
    `set_palette` / `load_background`, `debug.overlay`, and error codes
    that were never emitted. Trimmed.
  - Stale comments on `Statement::Play`/`StartMusic`/`StopMusic`
    claimed the audio subsystem was "a no-op at codegen time".

Tests:

  - Regression tests for every fix above (`lower_shift_left_with_literal
    _count_uses_that_count`, `lower_shift_right_with_variable_count
    _uses_runtime_variant`, `lower_divide_emits_div_op_not_load_imm
    _zero`, `lower_modulo_emits_mod_op_not_load_imm_zero`,
    `strength_reduce_div_by_power_of_two`, `strength_reduce_mod_by
    _power_of_two`, `strength_reduce_shift_var_with_constant_amount`).
  - Renamed the `program_with_sprites_and_palette` integration test
    (which was exercising the now-removed `load_background`/`set_palette`)
    to `program_with_inline_sprite_chr`.

`examples/sprites_and_palettes.ne` lost its `palette`/`set_palette`
usage. Nothing in the emulator test presses A, so the headless
jsnes render shouldn't move, but the golden may need regeneration
via `UPDATE_GOLDENS=1` if it does.

https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 02:47:37 +00:00
Claude
d42540f45e
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.

This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:

## Asset pipeline (new `src/assets/audio.rs`)

- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
  pulse-1 envelopes. Pitch/volume arrays must match in length; each
  entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
  flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
  1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
  `start_music` references and append builtin fallbacks for any
  name that isn't user-declared — so `play coin` still works
  without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
  and tracks (theme, battle, victory, gameover) synthesize through
  the same compile path as user decls — one data model, one driver.

## Runtime engine (`src/runtime/mod.rs`)

- `gen_audio_tick()` walks both channels every NMI: reads one
  envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
  advances ptr, mutes on zero sentinel. Music decrements the note
  counter, advances to the next `(pitch, dur)` pair on zero, looks
  up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
  (A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
  load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
  user sfx/music data can be spliced into PRG with regular labels
  that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
  (duty/volume/loop/active), `$0C-$0F` sfx and music pointers.

## IR codegen (`src/codegen/ir_codegen.rs`)

- `with_audio(sfx, music)` registers compile-time trigger constants
  per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
  envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
  `__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
  with the active bit OR'd in, seeds both ptr and loop base from
  `__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.

## Linker (`src/linker/mod.rs`)

- New `link_with_all_assets(user_code, sprites, sfx, music)` path
  that splices driver body, period table, and each sfx/music data
  blob into PRG — all guarded on the `__audio_used` marker so
  silent programs pay zero ROM cost.

## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)

- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
  pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
  letting the linker splice ROM data tables into a code section
  and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
  correctly in the same assembly pass.

## Analyzer

- `play` / `start_music` now validate the name against user decls
  and builtin tables. Unknown names emit E0505 with a helpful list
  of builtins — previously a typo would silently compile to no-op.

## Parser

- New `sfx_decl` / `music_decl` grammar with property-style
  configuration. Strict validation: duty 0-3, volume 0-15, pitch
  arrays must match volume length, music notes must come in pairs,
  pitch 0-60, duration ≥ 1.

## Tests

+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
  shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
  declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
  acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
  $4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
  length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
  pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
  to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
  user-declared `sfx` + `music` program that verifies bytes land
  in PRG ROM at the right addresses

## Docs

- New Audio section in `docs/language-guide.md` with syntax
  reference, builtin tables, and an explanation of how the
  driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
  pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
  "status: full subsystem" with a narrower list of follow-up work
  (triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
  `sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
  builtin fallback via `play coin`.

Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.

https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
2026-04-13 01:10:21 +00:00
Claude
9a539ea068
compiler: audio driver, u16 arithmetic, multi-scanline, slot recycling
Five language features and optimizations from the planned-work backlog:

- **Minimal audio driver**: `play`/`start_music`/`stop_music` now generate
  APU pulse-1/pulse-2 writes from a builtin SFX/music name table, and
  the NMI handler gains a `JSR __audio_tick` splice (via the linker's
  `__audio_used` marker lookup) that ages an SFX countdown counter and
  mutes pulse 1 when the tone expires. Programs that never trigger
  audio pay zero ROM cost.

- **u16 arithmetic and comparisons**: new IR ops `LoadVarHi`, `StoreVarHi`,
  `Add16`, `Sub16`, and six `Cmp*16` variants. The lowering context
  tracks variable types via the analyzer's symbol table and routes
  expressions through the 8-bit or 16-bit path based on operand width.
  Add16 emits `CLC;ADC;ADC` with carry propagating naturally into the
  high byte; compares dispatch high-byte-first with a short-circuit
  low-byte fallback. Fixes a silent miscompile where `big += 1` on a
  u16 var only incremented the low byte.

- **Multi-scanline handlers per state**: `gen_scanline_irq` now
  dispatches on `(current_state, ZP_SCANLINE_STEP)` and reloads the
  MMC3 counter with the delta to the next scanline in the same state.
  `gen_scanline_reload` resets the step counter at the top of each
  NMI so a state with multiple handlers fires them in ascending line
  order. Previously only the first handler per state ever fired.

- **IR temp slot recycling**: `build_use_counts` pre-scans each
  function to count per-temp uses; `retire_op_sources` decrements
  the counts after each op and pushes dead slots back onto
  `free_slots` for later allocation. `bitwise_ops.ne` used to crash
  (debug) or miscompile (release) once it hit 128 concurrent temps;
  with recycling the same function now uses ~4 slots instead of 136.

- **INC/DEC peephole fold + improved dead-load elimination**:
  `fold_inc_dec` collapses `LDA addr; CLC; ADC #1; STA addr` into
  a single `INC addr` (and the SEC/SBC variant into `DEC addr`),
  saving 5 bytes and 5 cycles per increment. The fold is suppressed
  when the next instruction reads carry. `remove_dead_loads` now
  walks past INC/DEC/STX/STY (which don't touch A) to find the
  actual next A-use, catching more dead loads.

Tests: 331 unit + 39 integration (up from 313 + 37), including new
guards for audio, u16, multi-scanline, and slot recycling.

https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:21:53 +00:00
Claude
e1e10561b1
docs: scrub remaining stale AST-codegen references
Fallout from removing the `--use-ast` flag and the AST codegen.
Four references in `docs/future-work.md` and one in `plan.md` were
still implying a parallel AST code path existed:

- `future-work.md` "--debug CLI flag wired" entry — said the flag
  threads through `CodeGen::with_debug` (the old AST codegen's
  builder). Updated to `IrCodeGen::with_debug`.
- `future-work.md` IR debug.log/assert entry — had a parenthetical
  "(same behavior as AST codegen)" that no longer makes sense
  without an AST codegen to compare against. Dropped.
- `future-work.md` inline assembly entry — said "Both IR and AST
  codegen splice parsed instructions directly into the output
  stream." There's only the IR codegen now. Updated.
- `future-work.md` `on scanline(N)` entry — described scanline
  handling as "codegen (MMC3 IRQ vector wiring) is still TODO."
  That wiring has been in place for a while: the IR codegen
  emits `__irq_user` + `__ir_mmc3_reload` with per-state
  dispatch, and `examples/scanline_split.ne` and
  `examples/mmc3_per_state_split.ne` both exercise it in the
  jsnes smoke test. Rewrote the entry to match reality.
- `plan.md` M2 "Compiler phases built" list — had "Codegen from
  IR (replacing direct AST codegen)." The present-tense
  parenthetical read as if AST codegen still existed; stripped
  it. M1's "direct AST → 6502, skip IR for this milestone" line
  is left as a historical milestone scope description.

One intentional mention remains: `future-work.md` "Recently
completed" bullet explicitly notes the AST-based path and the
`--use-ast` flag were removed. That's the correct tombstone.

https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:53:36 +00:00
Claude
a757336681
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:

- `arrays_and_functions` — fully black (array init + function
  return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
  all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
  (static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
  `branch offset 153 out of range` (AST's if/else-chain
  desugaring of `match` emits short branches that can't reach
  the far arms in a multi-arm match)

Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).

`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.

What's removed:
- The `CodeGen` struct, its impl block, and every helper in
  `src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
  The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
  tests. Every feature they covered has an equivalent test in
  `src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
  `compile()` now does what it did, so they merged. All 40
  integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
  IR codegen as "not yet implemented" and listed AST codegen
  gaps as priority work.

What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
  run unconditionally from `main.rs`.

Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
  (was 328 + 37; the 15 dropped are the deleted AST-specific
  tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
  above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
  asserting on the old static-slot encoding
  (`A9 01 8D 01 02`). Updated to the cursor-based form
  (`A9 01 99 01 02`, i.e. STA AbsoluteY).

Net diff: 1581 deletions, 62 insertions.

https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
Claude
49317167da
docs: future-work.md — document all new additions
Covers struct literals, match, for loops, semicolons, inline asm
{var} substitution, raw asm, poke/peek intrinsics, and the new
--memory-map / --call-graph flags.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:47:09 +00:00
Claude
6e007774e4
CLI: --call-graph flag
Prints a tree view of every function/handler and its direct
callees, plus the max call depth reached from each state-handler
entry point. Useful for stack-budget investigation since the NES
has only 256 bytes of stack.

    === Call Graph (max depth: 2 / 8) ===
    Main::frame (max depth 2)
      ├── clamp
      ├── clamp
      └── check_collision
    check_collision
      ├── abs_diff
      └── abs_diff
    abs_diff
      └── (leaf)
    clamp
      └── (leaf)

Max-depth labels are only shown on entry points where the analyzer
has computed a depth; transitive callees print without a label so
the output isn't confusing.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:46:13 +00:00
Claude
db234a6ca7
CLI: --memory-map flag
Dumps a human-readable table of variable allocations sorted by
address, separated into zero-page and main RAM sections with a
final byte-usage summary. Struct fields show up as individual
entries under their synthetic \`var.field\` names.

Example output for examples/structs_enums_for.ne:

    === NEScript Memory Map ===
    Zero Page (\$00-\$FF):
      \$00-\$0F  [SYSTEM]  reserved (frame flag, input, state, params, scratch)
      \$0010    [USER]    enemy_y (u8)
      \$0011    [USER]    i (u8)

    RAM (\$0200-\$07FF):
      \$0200-\$02FF  [SYSTEM]  OAM shadow buffer
      \$0300        [USER]    player.x (u8)
      \$0301        [USER]    player.y (u8)
      \$0302        [USER]    player.vx (u8)
      ...

    Zero Page: 2/128 bytes used
    Main RAM:  11/1280 bytes used

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:43:39 +00:00
Claude
496925344d
Language: poke() and peek() hardware intrinsics
Common PPU/APU/mapper access previously required either variable
aliases or inline asm. Now two built-in intrinsics handle the
single-register case directly:

    poke(0x2006, 0x3F)     // STA \$3F, \$2006
    poke(0x2006, 0x00)
    poke(0x2007, 0x0F)
    var status: u8 = peek(0x2002)

- Analyzer: \`poke\` / \`peek\` are recognized as built-in intrinsics
  so they don't require a function declaration. Arity is still
  checked (E0203 on mismatch).
- IR: new \`IrOp::Poke(u16, IrTemp)\` and \`IrOp::Peek(IrTemp, u16)\`
  variants carrying the compile-time constant address.
- IR lowering: recognizes the \`poke\`/\`peek\` call names, evaluates
  the address as a const expression, and emits the intrinsic op.
  Falls back to a regular call if the address isn't a constant.
- IR codegen: emits a single LDA/STA in ZP or absolute mode based
  on whether the address fits in a byte.
- Optimizer: Poke has a source temp (liveness), Peek has a dest
  (new value); both pass through the existing passes.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:40:34 +00:00
Claude
a283f87b79
Inline asm: {var} placeholder substitution
Within \`asm { ... }\` blocks, \`{name}\` is replaced with the
resolved hex address of the variable at codegen time. The lexer's
asm-body capture now balances nested braces so it doesn't cut off
at the first \`{x}\`. Both IR and AST codegen paths preprocess the
body before passing to the inline parser:

    var counter: u8 = 0
    on frame {
        asm {
            LDA {counter}
            CLC
            ADC #\$01
            STA {counter}
        }
    }

Zero-page addresses become \`\$XX\`, absolute addresses become
\`\$XXXX\`. Unknown names pass through unchanged so the asm parser
can surface the "unknown mnemonic" / "unexpected token" error.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:30:21 +00:00
Claude
f0264b124a
Language: match statement
match state {
        Title => { if button.start { state = Playing } }
        Playing => { /* ... */ }
        GameOver => { if button.a { state = Title } }
        _ => {}
    }

- Lexer: \`match\` keyword and \`=>\` (FatArrow) token
- Parser: \`parse_match\` after the existing loop constructs. Each
  arm is \`pattern => { body }\`, with \`_\` as the catch-all. The
  match scrutinee is parsed with struct-literal restriction enabled
  so the following \`{\` is unambiguously the match body, not a
  struct literal.
- The parser desugars match directly into an if/else-if chain so
  the analyzer, IR lowering, and codegen don't need new AST variants
  — each arm becomes \`scrutinee == pattern\` as the condition, and
  the default arm (if any) becomes the final \`else\` block.

Tests cover parse + full pipeline integration for state-style
dispatch using an enum.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:21:00 +00:00
Claude
c8ae433a7c
Language: struct literals
struct Vec2 { x: u8, y: u8 }
    var pos: Vec2 = Vec2 { x: 100, y: 50 }
    on frame {
        pos = Vec2 { x: pos.x + 1, y: pos.y }
    }

- AST: new \`Expr::StructLiteral(name, fields, span)\` variant
- Parser: in expression position, \`Ident {\` enters struct-literal
  mode when the new \`restrict_struct_literals\` flag is off.
  \`if\`/\`while\`/\`for\` conditions set the flag so the \`{\` keeps
  going to the following block. Condition contexts can still use
  struct literals by parenthesizing them.
- Analyzer: validates that the struct type exists, each named field
  belongs to it, and each field value has a compatible type.
- IR lowering: desugars \`var = StructLiteral { ... }\` (both in
  assignments and variable initializers) into per-field StoreVar
  operations against the analyzer-synthesized \`var.field\`
  variables. No IR type for struct values is needed.
- AST codegen: no-op (legacy path).
- examples/structs_enums_for.ne now uses a struct literal for the
  initial \`player\` state instead of per-field assignments.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:15:57 +00:00
Claude
f17f1e7267
docs: future-work.md — document all recent additions
Lists structs, for loops, audio parsing, const folding, on_scanline
codegen, all new peephole passes, and the fixed function call ABI /
local variable allocation. Re-prioritizes remaining work.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:07:54 +00:00
Claude
240da57b54
Language: for i in start..end loops
Adds a \`for NAME in START..END { BODY }\` half-open range loop:

    for i in 0..8 {
        total += arr[i]
    }

- Lexer: \`for\`, \`in\` keywords and the \`..\` range operator
- AST: new \`Statement::For\` variant with var/start/end/body
- Parser: \`parse_for\` after \`while\` / \`loop\`
- Analyzer: registers the loop variable as a u8 symbol for the body
  (restoring any shadowed outer symbol afterwards), allocates it via
  the normal RAM allocator, and tracks it as "used"
- IR lowering: desugars to \`var = start; while var < end { body;
  var = var + 1 }\` using a \`for_step\` continue-edge block so
  \`continue\` properly increments the index
- AST codegen: no-op (legacy path doesn't need for loops)
- Tests: parse + full-pipeline integration

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:55:18 +00:00
Claude
281198cbb9
docs: struct types in the language guide
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:18:29 +00:00
Claude
de49c1a8ba
docs: future-work.md adds enums, peephole, --dump-ir
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:43:01 +00:00
Claude
d4daa6d0a9
docs: enum type + new CLI flags in the language guide
Documents the \`enum Name { Variant, ... }\` syntax and adds
\`--dump-ir\` and \`--use-ast\` to the CLI flag table. Also adds
an integration test covering enum-variant-as-condition and variant
assignment through the full compile pipeline.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:39:12 +00:00
Claude
c49c36b516
Update future-work.md with recently completed items
Documents the analyzer improvements (call arity, return type, W0101,
W0102, W0104, E0301, E0505), the \`on scanline\` parser/analyzer
support, and the inline assembly subsystem. Reorders the remaining
priority list.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:17:19 +00:00
Claude
3007266edf
Make IR codegen the default, fall back to AST via --use-ast
The IR-based codegen now matches all features of the AST codegen
(state dispatch, multi-OAM, P1/P2 input, scroll, debug.log/assert),
so flip the default. The legacy AST codegen is still available via
--use-ast for comparison and fallback during validation.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:46:01 +00:00
Claude
7e94cf37e3
Update future-work.md to reflect IR codegen completion 2026-04-12 10:24:12 +00:00
Claude
5512567349
Asset pipeline: resolve @binary and @chr file paths
resolve_sprites() now handles all three AssetSource variants:
- Inline(bytes): use directly (existing behavior)
- Binary(path): read raw bytes from file relative to source_dir
- Chr(path): convert PNG to CHR via png_to_chr()

Missing files are silently skipped rather than erroring, so
declarations can reference assets that haven't been added yet.
This keeps existing tests that use placeholder file paths working.

Updated future-work.md: moved include directive, P2 controller,
sprite resolution, shift-assign, debug statements, warnings, and
asset wiring to the "completed" section. Remaining work is IR codegen,
audio, on_scanline, and language features (structs/enums).

Tests: 257 total (3 new resolve_sprites tests)

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:13:26 +00:00
Claude
5d2d242520
Update future-work.md: mark completed items
13 items moved from backlog to "recently completed":
state dispatch, function calls, break/continue, return, transition,
array indexing, scroll, multiply/divide/modulo, shifts, multi-OAM,
const assignment error, break-outside-loop error, math in linker.

Remaining priority: IR codegen, sprite resolution, include, asset
pipeline wiring, debug mode, error polish, audio, language features.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 02:07:50 +00:00
Claude
3b1a03981b
Add comprehensive future work document
docs/future-work.md (347 lines) catalogs every known gap, incomplete
feature, and planned improvement with file:line references:

- IR-based codegen (IR exists but output uses AST codegen)
- 11 codegen gaps (calls, returns, transitions, arrays, mul/div, scroll)
- Sprite name resolution and multi-OAM support
- State machine dispatch (only start state generates code)
- Include directive, debug mode, scroll hardware
- Asset pipeline wiring (PNG conversion exists but unused)
- 10 unused error codes, missing validations
- Scanline IRQ, audio, inline assembly
- Language features: structs, enums, fixed-point, metasprites
- Missing shift-assign operators, P2 controller, register allocator
- WASM build target, compiler benchmarks
- Prioritized recommended order of work for contributors

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 01:10:11 +00:00
Claude
d7092f703d
Add comprehensive documentation
docs/language-guide.md (895 lines):
  Complete reference for every language feature with code examples.
  Covers: program structure, types, expressions, statements, assets,
  mappers, error codes, and compiler flags.

docs/architecture.md (130 lines):
  Compiler pipeline overview, module descriptions, testing guide.

docs/nes-reference.md (190 lines):
  NES hardware quick reference: CPU, memory map, PPU, iNES format.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:41:34 +00:00