mirror of
https://github.com/imjasonh/nescript
synced 2026-07-10 01:37:45 +00:00
80 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b6e4c368e1
|
peephole: step past non-A ops in remove_dead_loads
`remove_dead_loads` now scans past opcodes that touch neither A nor
the flags an LDA sets, so a redundant LDA gets caught by its
successor's overwrite even when an index load or counter bump sits
between them. The extension covers LDX/LDY/INX/INY/DEX/DEY and the
flag ops (CLC/SEC/CLI/SEI/CLD/SED/CLV) alongside the INC/DEC/STX/STY
opcodes the pass already stepped past.
The highest-leverage case is the shape every single-tile `draw`
emits. After copy propagation and dead-store elimination do their
work, the stream reads:
LDA #<y> ; stray producer, value never consumed
LDY oam_cursor
LDA #<y> ; real load before STA
STA $0200,Y
The first LDA was surviving because the pass bailed on the LDY.
With the step-past, it drops. One LDA gone per draw, 2 bytes each.
Measured LDA-count reduction on committed examples:
platformer 242 → 221 (-21, -8.7 %)
war 785 → 754 (-31, -4.0 %)
pong 843 → 827 (-16, -1.9 %)
**Audio goldens.** The cycle savings shift the main-loop/NMI boundary
in audio-emitting programs, which re-times which frame each SFX
trigger lands in. Six audio hashes re-baseline as a result:
audio_demo, friendly_assets, noise_triangle_sfx, platformer, pong,
war. All 50 PNG goldens, the platformer/war/pong demo gifs, and
every non-audio program stay byte-identical. The re-baselined
output is still sample-accurate; what changed is the first-SFX
offset within the captured 132 084-sample window. This is the
audio-shift tradeoff documented in future-work.
Two new peephole unit tests lock in the behaviour:
- `dead_load_elim_steps_past_ldx_ldy` — the DrawSprite shape folds.
- `dead_load_elim_preserves_lda_when_used_by_shift` — a subsequent
ASL on A keeps the LDA alive across an intervening LDY.
Also updates future-work.md to reflect the shipped change and the
remaining register-allocator wins worth chasing next.
|
||
|
|
82b3d0d20a
|
metatiles + collision: metatileset, room, paint_room, collides_at
Closes §H. 2×2 metatiles and a parallel collision map are now a
first-class construct. `metatileset Name { metatiles: [{ id, tiles,
collide }, ...] }` declares a library of 2×2 tile bundles. `room Name
{ metatileset: M, layout: [...] }` lays them out on a 16×15 grid. The
compiler expands each room at compile time into:
- a 960-byte nametable (`__room_tiles_<name>`)
- a 64-byte attribute table (`__room_attrs_<name>`)
- a 240-byte collision bitmap (`__room_col_<name>`)
`paint_room Name` reuses the vblank-safe `load_background` update
machinery for the nametable blit and installs the collision bitmap
pointer into `ZP_ROOM_COL_LO`/`ZP_ROOM_COL_HI` (ZP $18/$19).
`collides_at(x, y)` JSRs into a small runtime helper that reads
`(room_col),Y` with `Y = (y & 0xF0) | (x >> 4)` and returns 0/1.
The helper links in only when the `__collides_at_used` marker is
emitted, so programs that declare a room but never query it pay
zero bytes for the subroutine.
`parse_byte_array` grows a `[value; count]` shortcut — 240-entry
`layout` arrays are unwieldy to spell out a byte at a time.
See `examples/metatiles_demo.ne` for the end-to-end flow: a probe
sprite bounces off walls via `collides_at` and lands on the left
side of the playfield at frame 180 — direct evidence that the
collision query works.
Also defers the register-allocator work from §"Code quality /
tooling" and documents the audio-goldens constraint in future-work
so the next agent sees it.
|
||
|
|
9719dc4111
|
ir/codegen: signed comparison lowering for i8/i16
Closes the §A follow-up gap: ordering compares (`<`, `<=`, `>`, `>=`) on signed integer types now use the canonical 6502 `CMP / SBC / BVC / EOR #$80` overflow-correction idiom so the N flag reflects the true sign of the difference, instead of the previous BCC/BCS-based path that always treated `$FFxx` as greater than `$00yy`. The same change also fixes narrow-to-wide widening: assigning a runtime `i8` expression to an `i16` variable now sign-extends the high byte via a new `IrOp::SignExtend` op instead of zero-extending it, so `var w: i16 = some_i8_neg` round-trips negative values. The lowerer tracks signedness on each IR temp (analogous to the existing `wide_hi` map) and threads it onto the new `Signedness` field of `CmpLt`/`CmpGt`/`CmpLtEq`/`CmpGtEq` and their 16-bit variants. The optimizer's constant-folder uses the same flag to fold compares correctly under either signedness. Casts to `u8`/`u16` strip the signed flag so an explicit `as` opt-out stays unsigned. `examples/signed_compare.ne` exercises both bit widths through the emulator harness — the four pip sprites at the top of the screen show three lit (signed-correct) and one dark (would only light if the compare regressed to unsigned semantics). |
||
|
|
807c9c7318
|
compiler: VRAM update buffer (nt_set / nt_attr / nt_fill_h)
Closes the highest-priority remaining catalogue item (§G). User code queues PPU writes during `on frame` via three new intrinsics; the NMI drains the 256-byte ring at `$0400-$04FF` to `$2007` during vblank. Programs that never touch the buffer pay zero bytes and zero cycles for the feature — verified by the existing 46 ROMs all matching their goldens with no drift. Also fixes the failing CI Format check from |
||
|
|
7b4570eee5
|
compiler: i16 / SRAM saves / inline-asm dot labels / docs
Another batch from the cc65/nesdoug catalogue. All gated on
parser-level opt-in or default-false attributes so existing
programs produce byte-identical ROMs (no committed .nes file
changed).
**§A — `i16` signed 16-bit type:**
- New `KwI16` lexer token, `NesType::I16` AST variant, parser
case in `parse_type`. Type-size and integer-type tables
treat `i16` like `u16` (2 bytes, integer).
- IR lowering accepts `i16` everywhere it accepts `u16` for
wide-load / wide-store / widen-narrow paths.
- New constant fold for `UnaryOp::Negate(IntLiteral(v))` that
emits the wide two's-complement form. Without it, `var vy:
i16 = -10` would zero-extend to `$00F6` (= 246) instead of
sign-extending to `$FFF6` (= -10). Negative literals now
store the right bytes.
- Comparisons reuse the existing unsigned 16-bit compare ops
(matching the existing `i8` behaviour). Documented in the
`NesType::I16` doc comment and in `future-work.md` §A.
- Example `examples/i16_demo.ne` with committed golden.
- Tests cover the literal-fold sign-extension and end-to-end
compile of the example.
**§S — SRAM / battery-backed saves:**
- New `save { var ... }` top-level block. Lexer + parser opt
into a dedicated `KwSave` token. Analyzer allocates save
vars from a separate `next_sram_addr` bump pointer starting
at `$6000`, capped at `$8000` (8 KB cartridge SRAM window).
- Linker reads `analysis.has_battery_saves` and flips iNES
byte-6 bit-1 via the new `RomBuilder::set_battery` /
`Linker::with_battery` chain.
- New `W0111` warning for save-var initializers — SRAM is
preserved across power cycles, so an init expression would
either silently never run or clobber persisted data on
every boot. The warning teaches the user about the
magic-byte sentinel pattern.
- Struct fields in save blocks are explicitly rejected for now
(the field-flattening path uses the main-RAM allocator).
- Example `examples/sram_demo.ne` with committed golden, plus
4 integration tests.
**§D (partial) — inline-asm `.label:` syntax:**
- Codegen-side mangler rewrites `.IDENT` → `__ilab_<N>_IDENT`
per inline-asm block, where `<N>` is the call site's
monotonic suffix. Two `asm { .loop: ... }` blocks in the
same function now coexist without colliding in the linker's
label table.
- Bounds checks on `.` placement: `$2002` and `name.field`
are unaffected; only `.IDENT` in label / branch context
triggers the rewrite. Two integration tests pin the
uniqueness and dollar-vs-dot disambiguation.
**§X follow-up — Mesen trace-log docs:**
- New "Debugger-assisted workflows" section in
`docs/nes-reference.md` walking through the Mesen / FCEUX
log workflows alongside the new `debug_port:` attribute.
**Misc:**
- `future-work.md` updated to mark the shipped items out of
the catalogue and reshuffle the priority ranking. Remaining
niche follow-ups (signedness on Cmp16, struct save fields,
inline-asm format specifiers) documented inline so future
passes know the design.
All 757 tests pass. Clippy clean. 46/46 emulator goldens match.
|
||
|
|
e0b268eea9
|
compiler: GNROM / debug port / sprite flicker / fade / sprite-0 split + docs
Another batch from the cc65/nesdoug gap catalogue. All six items
gated on marker labels (or default-false attributes) so existing
programs produce byte-identical ROMs — every pre-existing .nes
file round-trips unchanged.
**Language / runtime additions:**
- `mapper: GNROM` (iNES 66). Combines AxROM's 32 KB PRG pages with
CNROM's 8 KB CHR banks in a single `$8000` register. Linker
pads single-page ROMs to 32 KB to match mapper-66 expectations.
- `game { debug_port: fceux | mesen | 0xXXXX }`. `debug.log`,
`debug.assert`, and the `__debug_halt` sentinel now target a
user-selected address. `fceux` (default, $4800) and `mesen`
($4018) are named aliases; custom hex addresses are accepted
for unusual debuggers.
- `game { sprite_flicker: true }`. IR lowerer injects an
`IrOp::CycleSprites` at the top of every `on frame` handler,
which flips on the rotating-OAM NMI variant with no per-site
boilerplate. Default false so existing ROMs keep their layout.
- `fade_out(step_frames)` / `fade_in(step_frames)` builtins.
Blocking helpers that walk brightness 4 → 0 or 0 → 4 with
`step_frames` frames between each step. Runtime splices
`__fade_out`, `__fade_in`, and a callable `__wait_frame_rt`
helper when the builtin is used. Zero-guard on step_frames
prevents a pathological 256-frame spin when the caller
accidentally passes 0.
- `sprite_0_split(scroll_x, scroll_y)` intrinsic. Emits a
two-phase busy-wait on `$2002` bit 6 (wait-for-clear,
wait-for-set) then writes the new scroll values to `$2005`.
Works on any mapper — unlike `on_scanline(N)` which requires
MMC3. Enables HUD-over-playfield scrolling on NROM/UxROM/MMC1.
**Docs:**
- New paragraph in the language guide explaining the no-recursion
design choice and the explicit-stack workaround pattern.
- `future-work.md` updated to mark the shipped items out of the
catalogue; remaining items reshuffled in the priority ranking.
- README + examples/README updated with the new mapper and
builtins.
**Tests:**
- 12 new integration tests covering: GNROM header emission,
debug-port targeting (fceux/mesen/custom), unknown-alias
rejection, sprite_flicker on/off/bad-value, fade_out JSR + marker
coupling, fade omitted-when-unused, fade-in-expression rejected,
sprite_0_split byte-level busy-wait verification, sprite_0_split
arity enforcement, sprite_0_split omitted-when-unused, and an
extended void-intrinsic-in-expression-position test covering the
three new void builtins.
- `nes2_mapper_high_nibble_in_byte_8_is_zero_for_small_mappers`
extended to include GNROM.
- Four new examples with committed .nes ROMs + pixel/audio
goldens: `gnrom_simple`, `auto_sprite_flicker`, `fade_demo`,
`sprite_0_split_demo`.
All 752 tests pass. Clippy clean. 44/44 emulator goldens match.
|
||
|
|
7507459787
|
compiler: PRNG / edge input / palette fade / AxROM / CNROM / FCEUX labels
Closes seven of the cc65/nesdoug parity gaps catalogued in docs/future-work.md in a single pass. All of the new features are gated on marker labels so programs that don't use them produce byte-identical ROM output (every pre-existing committed .nes file round-trips unchanged). Language / runtime additions: - `rand8()` / `rand16()` / `seed_rand(u16)` intrinsics backed by a 16-bit Galois LFSR (~30 bytes of runtime, ~40 cycles per draw). Reset path seeds state to 0xACE1 so the first draw is useful even without explicit seeding. - `p1.button.a.pressed` / `.released` edge-triggered input via a new ReadInputEdge IR op plus an NMI-side prev-frame snapshot into $07E6/$07E7, gated on the `__edge_input_used` marker. - `set_palette_brightness(level)` builtin mapping levels 0..8 to PPU mask emphasis bytes (`$2001`) for neslib-style screen fades. - `mapper: AxROM` (iNES 7) with automatic 32 KB PRG padding so emulators that enforce mapper-7's 32 KB page size boot cleanly. - `mapper: CNROM` (iNES 3) with a reset-time CHR bank 0 select. - `--fceux-labels <prefix>` CLI flag emitting per-bank `.nl` label files and a `.ram.nl` file for FCEUX's debugger. Tests + examples: - Five new example programs with committed .nes ROMs and pixel+audio goldens: prng_demo, edge_input_demo, palette_brightness_demo, axrom_simple, cnrom_simple. - Seven integration tests covering JSR emission, the omitted-when-unused invariant, the NMI prev-input snapshot, the correct mapper numbers for AxROM/CNROM, and negative tests for unknown button names and bad rand8 arity. - `is_intrinsic()` now runs in expression-position Call paths too, so `var x = rand8(1, 2)` errors at compile time instead of silently dropping the extra arguments. |
||
|
|
0602fd9590
|
analyzer+codegen: lift the 4-param ceiling via a direct-write calling convention
Follow-up to the silent-drop audit. The old ABI passed every parameter through four fixed zero-page transport slots `$04-$07`, imposing a hard 4-param cap (E0506) that didn't compose with structs/arrays/u16s and fell back to "pack args into a global" workarounds whenever a function needed five things. The transport scheme also cost every non-leaf call a 4-LDA/STA spill prologue (~28 cycles, 16 bytes) to copy args out of ZP before the next nested `JSR` could clobber them. Replace it with a hybrid convention keyed on leaf-ness: - **Leaf callees** (no nested `JSR` in body, ≤4 params): unchanged. Caller stages args into `$04-$07`; body reads those slots directly for its entire lifetime. No prologue copy. Fastest path, 3-cycle ZP stores + 3-cycle ZP loads, preserves the SHA-256 leaf-primitive optimisation that motivated the original fast path. - **Non-leaf callees** (body contains a nested `JSR`, OR ≥5 params): direct-write. Caller stages each argument straight into the callee's analyzer-allocated parameter RAM slot, bypassing the transport slots entirely. No prologue copy on the callee side. Saves ~24 cycles and ~16 bytes per call vs the old transport-then-spill path, and — crucially — scales past 4 params because the per-param slots live wherever the analyzer put them rather than in a fixed ZP window. The analyzer's ceiling moves from 4 to 8. Functions with 5–8 params are silently promoted to the non-leaf convention (even if their body has no nested `JSR`), which pays the direct-write cost rather than the prologue-copy cost — still cheaper than the old ABI. Declarations with 9+ params still emit E0506. ### Implementation - `function_is_leaf` now also requires `param_count <= 4`. - `IrCodeGen::new` populates `non_leaf_param_addrs: HashMap<String, Vec<u16>>` — for every non-leaf function, the ordered list of addresses its parameters occupy. Callers use this to route each arg directly to the right slot. - `IrOp::Call` branches on presence in the map: non-leaf → direct- write, leaf (or absent — 0-arg case) → ZP transport. - `gen_function` no longer emits a prologue. Leaves didn't have one; non-leaves had a 4-LDA/STA copy that is now unnecessary because args arrive pre-written to the slot. - The previous `leaf_functions: HashSet<String>` field is removed; leaf-ness is now inferred from absence-in- `non_leaf_param_addrs` at the call site. ### Tests and regressions - `eight_param_non_leaf_function_stages_every_arg_at_its_allocated_slot` compiles an 8-param function, scans PRG for a distinct `LDA #\$NN / STA <addr>` per arg (immediates `0x11..0x88`), and asserts that STAs to the `$04-$07` range are strictly fewer than 8 — proof the old transport path is gone for this call. - `non_leaf_call_direct_writes_args_to_callee_param_slots` replaces the old `gen_function_prologue_spills_params_to_local_ram` test with a dual assertion: (a) no `LDA \$04` prologue at the callee entry, and (b) the caller-side STA lands at the analyzer-allocated param slot, not at `\$04-\$07`. - `analyze_rejects_function_with_more_than_4_params` renamed and rewritten for the new 8-param cap. - `feature_canary.ne` gains a 6-param `sum6` call (1+2+3+4+5+6 = 21) as check 8. The canary stays green (all eight checks pass), so the committed golden is unchanged. ### Blast radius - Six example ROMs change bytes (arrays_and_functions, function_chain, mmc1_banked, pong, sha256, war) because their non-leaf call sites pick up the shorter staging sequence. - Pong and war audio hashes refresh (pure layout-timing shift; no behavioural change in the 180-frame no-input window). docs/pong.gif and docs/war.gif stay byte-identical. - `examples/function_chain.ne`'s header comment updated to document the leaf vs non-leaf split it exercises. - `docs/language-guide.md` parameter-count section and E0506 entry updated to reflect the new rule. All 720 Rust tests pass; all 35 emulator goldens pass. https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC |
||
|
|
00f1305564
|
codegen+CLAUDE: harden Call arity and document the silent-drop review checklist
Phase 3/4 of the post-PR-#31 audit.
### Call args > 4 is now an assert
`IrOp::Call` silently `.take(4)`-d the arg list with a comment
claiming the analyzer's E0506 check made the extras unreachable.
Replace with an explicit `assert!(args.len() <= 4, ...)` so if
the analyzer ever regresses, the codegen crashes loudly instead
of miscompiling the call. Iterate over all args (not just the
first 4) since the assert guarantees correctness.
### CLAUDE.md: new-feature PR checklist
Document the lesson the audit taught: every new language-feature
PR must include (1) an example exercising it, (2) a runtime
*behaviour* assertion (not just a "ROM validates" shape check),
(3) a negative test for invalid use. Call out the specific
address-map lookup pattern (`if let Some(&addr) = map.get(..)`
with no else) that shipped the state-local bug, and recommend
the `IrCodeGen::var_addr` / explicit `.unwrap_or_else(|| panic!)`
idiom instead.
Chose not to add a regex-based CI tripwire for "silently" /
"for now" comments because the false-positive rate against
legitimate design decisions ("silently truncate to 8 bits per
the cast spec", etc.) would train contributors to ignore it.
The durable checklist in CLAUDE.md is what next agents need.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
|
||
|
|
48bae97c51
|
analyzer+codegen: turn silently-dropped feature paths into hard failures (or fix them)
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
|
||
|
|
f7012c6533
|
codegen: make var_addrs misses panic loudly and fix latent struct-field silent drop
Phase 1 of the post-PR-#31 audit. The PR #31 state-local bug had a specific shape: analyzer allocated a slot, codegen looked it up by VarId, silently emitted nothing on miss. Six sites in gen_op plus the global-initializer loop and the parameter-shuffle prologue all used the same `if let Some(&addr) = self.var_addrs.get(var) { ... }` pattern with no else branch. Any future allocation-map desync would slip through the same crack. Replace every site with a new `IrCodeGen::var_addr(VarId) -> u16` helper that panics with an explicit "compiler bug" message on miss. An IR op referencing an unmapped VarId is not valid input — it means the analyzer and lowerer disagreed on what to allocate, and we want that crash to surface in CI rather than be absorbed by whatever zero-filled RAM happened to sit at the read. Running cargo test against the hardened lookup surfaced exactly the bug shape the plan predicted: uninitialized struct globals (e.g. `var p: Point` with no literal initializer) never had their flattened field VarIds (`"p.x"`, `"p.y"`) registered in var_addrs. The IR lowerer's `get_or_create_var("p.x")` minted a VarId, the analyzer's `flatten_struct_fields` allocated an address for it, but IrCodeGen::new only populated var_addrs from `ir.globals`, which doesn't contain synthesized field entries for uninitialized structs. Every `p.x = N` silently compiled to nothing. Fix by exposing the IR lowerer's name→VarId map on IrProgram and joining it with the analyzer's allocations in IrCodeGen::new. Every allocated name that the lowerer knows about now gets a var_addrs entry. Example ROMs are byte-identical (no example relied on the dropped writes), but the bug was reachable — any user program with a plain `var pos: Point` declaration and field writes would have hit it silently. Add `uninitialized_struct_field_store_emits_sta_to_allocated_address` as a byte-level regression guard: compile `p.x = 123` and scan PRG for `LDA #\$7B / STA <addr>`. Fails against the old silently-dropping codegen. https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC |
||
|
|
c09f9c0caa
|
codegen: emit gate markers at end of generate() to protect peephole
Move the six gate-marker label emissions (__mul_used, __div_used, __oam_used, __default_sprite_used, __p1_input_used, __p2_input_used) out of the inline IR-op lowering paths and into a new `emit_trailing_markers()` helper that runs once at the end of `generate()`. The IR walk now just flips a bool per marker; the label emit happens after every instruction has been lowered, so the marker never lands in the middle of a peephole-sensitive sequence. Fixes a real peephole interaction that surfaced after rebasing on main's `codegen: skip parameter-spill prologue for leaf functions` + `peephole: drop dead LDA #imm before mem-INC/DEC + JMP` improvements: an inline `__oam_used:` label inside `IrOp::DrawSprite` split the dead-load-elimination block, leaving the `STA $130 / LDA $130` redundant store+load pair that main's peephole would otherwise have collapsed to a plain `LDA #imm`. The stale bytes shifted the NMI handler by a few bytes, which shifted `on frame` execution enough that `examples/palette_and_background.ne` captured phase 1 (WarmReds) at frame 180 instead of phase 2 (CoolBlues). Regenerates every example ROM against the new codegen (all gate behaviour is unchanged — the linker still sees the same markers, just at the tail of the user stream instead of interleaved) and updates the goldens that shifted: seven audio-hash drifts (all audio-bearing programs, same cycle-accurate-APU-timing story as every prior NMI layout change) and two pixel goldens — the one- pixel sprite-position drift in `comparisons.png` that we already tolerate, plus the phase-capture flip in `palette_and_background.png`. https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN |
||
|
|
0de1d60c33
|
runtime: gate controller-2 reads in NMI on __p2_input_used
Drop the three-instruction JOY2 shift block (`LDA $4017 / LSR A / ROL ZP_INPUT_P2`) from inside the NMI's 8-iteration input loop when user code never reads controller 2. IR codegen emits the `__p2_input_used` marker from `IrOp::ReadInput(_, 1)`; the linker threads the flag through a new `NmiOptions::has_p2_input` bool, and `gen_nmi` writes the shift block only when the flag is set. Savings for single-player programs: - ~6 bytes of NMI code. - ~30 cycles per frame (3 instructions × 8 loop iterations, each 6-8 cycles depending on addressing — LDA abs is 4, LSR A is 2, ROL zp is 5, so ~11 cycles × 8 = ~88 cycles; rounded down for the page-crossing penalty landing differently in the new layout). This commit also fixes the IR codegen to drop the matching `__p1_input_used` marker from `IrOp::ReadInput(_, 0)`, even though the next commit is the one that actually consumes it. Landing the two markers together keeps the IR codegen's per-op bookkeeping coherent. Six audio goldens flip (every program that reads input + plays audio) with the expected NMI-layout-shift cycle drift. https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN |
||
|
|
6561daff35
|
linker: gate default smiley CHR tile on __default_sprite_used marker
Drop the built-in smiley from CHR tile 0 unless something in the
program actually references it. The marker fires when either:
1. `IrOp::DrawSprite` lowering falls back to tile 0 because the
sprite name doesn't resolve to a user declaration, or
2. The same lowering sees a runtime `frame:` override (which
could index any tile, including 0).
A third source of dependency — a background nametable entry of 0 —
is detected in the linker by scanning `bg.tiles` for zeros. This
preserves the smiley for programs like `examples/friendly_assets`
that use tile 0 as a background placeholder, even though their
draws resolve to user-declared sprites.
Programs whose draws all resolve to explicitly-declared sprites
with static frames AND whose backgrounds reference tiles 1+ now
leave CHR tile 0 as an all-zero blank, freeing 16 CHR bytes that
the user can treat as an always-transparent background tile.
Verified against the current example set: `sprites_and_palettes`
and `auto_chr_background` reclaim tile 0; every other example
keeps it (either they fall back to tile 0 via an undeclared draw
name or their background tilemap references tile 0).
All 33 emulator goldens still pass — removing an unreferenced CHR
tile can't change observable output.
https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
|
||
|
|
7533ac281e
|
linker: skip default palette + rendering enable for non-visual ROMs
Add an `__oam_used` marker dropped by IrOp::DrawSprite codegen, and compute a `has_visual_output` flag in the linker from the marker plus the presence of any user palette / sprite / background. When that flag is false — i.e. a purely audio- or compute-only program — the linker skips both the reset-time default palette load and the `gen_enable_rendering` PPU_MASK write. `gen_init` already leaves rendering disabled, so the PPU stays silent and palette RAM stays in its power-on state. ~72 bytes reclaimed for non-visual programs. Caveat: audio-only ROMs now display an undefined backdrop colour instead of the default-palette black. jsnes renders that as a mid-grey; Mesen/real hardware may vary. Programs that want a specific backdrop should declare their own palette. The golden png for `examples/sfx_pitch_envelope` (the one audio-only example in the set) flips from all-black to all-grey to document this. `__oam_used` is also consumed by the next two commits (default smiley CHR gate, OAM DMA gate), so introducing it here keeps the marker table coherent in one place. Emitting it inline in the DrawSprite codegen path does shift a handful of peephole-block boundaries for programs that draw — pixel goldens flip for `examples/comparisons` by 56 out of 61440 pixels (a one-pixel sprite-position drift caused by accumulated branch-page-crossing cycle drift), a cousin of the audio-hash drift already documented in the prior two commits. https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN |
||
|
|
033d399565
|
runtime: gate __multiply / __divide on usage markers
Drop __mul_used from IrOp::Mul codegen and __div_used from IrOp::Div / IrOp::Mod codegen (modulo reuses the same routine). The linker skips gen_multiply / gen_divide for programs that never emit the markers, following the same pattern already used by __audio_used / __ppu_update_used / __sprite_cycle_used. The optimizer already rewrites multiplies and divides by constant powers of two into shifts (and modulo by constant powers of two into masks), so the markers only fire for genuinely runtime math. A program like `examples/comparisons.ne` that never multiplies or divides now reclaims ~56 bytes of PRG; programs that use only one of the two reclaim the other's share. Audio goldens flip for every example that uses audio. The .ne sources are unchanged and the pixel goldens are byte-identical — the audio stream differs only because removing the math routines shifts the audio tick's absolute address in PRG by 56 bytes, which changes which of its internal branches cross 6502 page boundaries and therefore the per-frame cycle count of a single NMI by 1-5 clocks. Over 180 frames the accumulated drift shifts APU register write timing enough to render a different digital sample stream at the same logical wave shape. Expected consequence of ROM-layout change under cycle-accurate emulation; documented path per CLAUDE.md "Updating goldens". https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN |
||
|
|
6696d790bb
|
codegen+ir: code-review followups (UTF-8 safety, leaf exhaustiveness, tests)
Three follow-ups from a fresh review of the perf milestone:
1. **UTF-8 safety in `substitute_asm_vars` and
`substitute_inline_const_params`.** Both walked the asm body
byte-by-byte and emitted each non-substituted byte via
`out.push(bytes[i] as char)` — a Latin-1 reinterpretation that
mangles non-ASCII characters in inline-asm comments. The brace-
level scan stays byte-based (braces can't appear inside a UTF-8
continuation), but the verbatim copy now uses
`out.push_str(&body[i..i + ch_len])` with `ch_len` derived from
the lead byte. Pre-existing latent bug in `substitute_asm_vars`,
freshly introduced in `substitute_inline_const_params` —
fixed in both, with a shared lead-byte length helper.
2. **`function_is_leaf` is now exhaustive on `IrOp`.** The match
used to be selective: `Call`/`Mul`/`Div`/`Mod`/`Transition`/
`InlineAsm` were checked, everything else fell through with
`_ => {}`. A new variant added later that secretly emitted a
JSR (e.g. a future `Mul16` calling `__multiply16`) would have
silently broken any leaf function that touched it. Listed
every current variant explicitly so the compiler errors at
the match arm if a new variant ships, and added a
`function_is_leaf_detects_jsr_emitting_ops` test that walks
the known JSR-emitting constructs (Call, *, /, %, asm with
JSR token) and asserts each disqualifies leafness.
3. **Cleanups.** `gen_block` now binds the fused-cmp dest temp
inside the original tuple instead of re-matching
`block.ops.last().unwrap()` to retire it. New
`inline_fun_with_asm_param_cascades_through_nested_inline`
test exercises the eval_const → const_args_stack path that
lets the inner of two nested inline funs see its outer's
parameter as the constant the top-level call passed. Defensive
comment on `body_has_inline_asm` explaining why it deliberately
doesn't recurse (relies on `is_splicable_void_stmt`'s
no-control-flow guarantee).
ROMs and goldens unchanged — all the changes are non-observable
through the existing example surface. Verified: cargo
test/clippy/fmt clean on rustc 1.95.0; emulator harness 34/34;
reproducibility diff clean; demo gifs byte-match fresh captures.
https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
|
||
|
|
df71c2bf50
|
peephole: drop dead LDA #imm before mem-INC/DEC + JMP
The IR codegen lowers `i -= 1` (and friends) into a `LoadImm temp,
1; Sub d, i, temp; StoreVar i, d` triple, and the optimizer
strength-reduces the Sub+StoreVar pair into `DEC i`. The
constant-load-into-A that used to feed the Sub stays around as a
dead `LDA #1`:
LDA #1
DEC ZeroPage(rem)
JMP Label("__ir_blk_while_cond_…")
`remove_dead_loads` was set up to drop exactly this pattern but
gave up at the trailing `JMP` because it couldn't reason about
flow. Extend it to follow one unconditional `JMP <label>` to its
target and resume the dead-store scan from the next instruction.
The first instruction past the loop-condition label is reliably an
`LDA loop_var`, which overwrites A without reading it — so the
`LDA #1` is correctly identified as dead.
Conditional branches still end the scan (their not-taken path is
unconstrained) and only one JMP is followed (to keep the analysis
local). For SHA-256 specifically this drops two `LDA #1`s per
iteration of the rotate/shift bit-loops — about 1K cycles per
block. The same pattern fires across most examples' loop tails.
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. The cycle drift refreshes the four
audio hashes / golden frames timing-sensitive examples already
tracked.
https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
|
||
|
|
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 |
||
|
|
0b5470b054
|
codegen: skip parameter-spill prologue for leaf functions
Leaf functions — those that never JSR another routine from inside
their body — don't need to spill the `$04..$07` parameter
transport slots into per-function RAM, because nothing inside the
body clobbers those slots. Detect them in `IrCodeGen::new` via a
linear scan over each function's IR ops, point their parameters
at `$04..$07` directly in `var_addrs` (and in a parallel
`leaf_param_overrides` map for inline-asm `{name}` substitution),
and have `gen_function` skip the spill prologue.
The "leaf" predicate is conservative: any of `IrOp::Call`, `Mul`,
`Div`, `Mod`, `Transition`, or an inline-asm body containing a
`JSR` token disqualifies the function. SetPalette /
LoadBackground / PlaySfx / StartMusic / DebugLog / DebugAssert
were verified by inspection to not emit JSRs.
Per call to a leaf primitive: `LDA $04 / STA <local> / LDA $05 /
STA <local+1>` is now omitted — saves 12 cycles and 12 bytes of
code per call. Across the SHA-256 example's ~5500 leaf-primitive
calls per block, that's ~66K cycles saved per compression — about
2.2 frames at NTSC.
The fix also touches every committed `examples/*.nes` (the leaf
prologue was emitted by every fun with params, not just the SHA
ones), so 9 ROMs and the same three timing-sensitive goldens
(war.png + platformer/pong/war audio hashes) get refreshed; the
two committed gifs that drifted do too.
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
|
||
|
|
76d0fd0d28
|
codegen: reuse analyzer's local allocations so inline asm {param} works
Fixes compiler-bugs.md #1 — the inline-asm `{name}` resolver looks parameters up in the analyzer's `VarAllocation` table (because that's the only address map it has), but `IrCodeGen::new` was minting a parallel `$0300+` range for every function-local and ignoring what the analyzer had picked. The spill prologue wrote the param to the codegen's private address, the inline asm read from the analyzer's zero-page address, and nothing ever bridged the two — `LDA {param}` would silently load whatever the RAM clear left at the stale slot (always `0`). Fix: drop the `local_ram_next` loop and just look each local up in `allocations` by the analyzer's qualified name (`__local__{scope}__{local}`). The scope string that `gen_function` already computed for `substitute_asm_vars` is now shared with the new address-seeding loop via a `scope_prefix_for_fn(&str)` helper, so the two call sites can't drift. The analyzer's layout already satisfies the "no overlapping live locals" invariant the codegen was relying on — it scopes every local under `__local__<scope>__<name>` so two functions with a parameter named `x` land in different slots. Updated `gen_function_prologue_spills_params_to_local_ram`: the regression test for the War-era param clobbering bug was asserting the spill's destination specifically had to be an absolute address at `$0300+`. That's no longer the mechanism — the spill lands in whatever slot the analyzer assigned, which is zero page when there's room. The test now asserts the destination is *any* address outside `$04-$07`, which is the actual invariant. Reverted the `LDX $04` / `LDY $05` workaround in `examples/sha256/sha_core.ne` — every primitive there now uses `{dst}` / `{src}` / `{w_ofs}` / `{h_ofs}` / `{k_ofs}` substitution as originally intended. The "Parameter convention" comment that documented the workaround is gone. Regenerated `tests/emulator/goldens/inline_asm_demo.png`: that example's `times_four(input)` was previously returning `input` verbatim because the inline asm's `LDA {result}` / `ASL A` / `ASL A` / `STA {result}` operated on a zero-page byte that was disconnected from the NEScript-level `result` variable. With the fix, `times_four` correctly returns `input * 4`, so the smiley-tracker's frame-180 position shifts by the expected `(frame_count * 4) mod 256` delta. The other 33 ROMs remain byte-identical. 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. - `cargo fmt --check` clean. - Full emulator harness: 34/34 ROMs match goldens. - SHA-256 of "NES" still computes to `AE9145DB5CABC41FE34B54E34AF8881F462362EA20FD8F861B26532FFBB84E0D`. - `--memory-map` output now reflects what the generated code actually reads and writes (previously the codegen's $0300+ override was invisible to the dump). https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v |
||
|
|
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
|
||
|
|
5e5bed39a5
|
sprite-per-scanline: add cycle_sprites runtime flicker + debug telemetry
W0109 (shipped last commit) catches the 8-sprites-per-scanline
hardware limit at compile time for static layouts, but the
dynamic case — enemy formations, projectile clusters, animated
NPCs where coordinates come from variables — was still silent.
This change adds two layers of defense on top of W0109:
Layer 2: `cycle_sprites` runtime flicker intrinsic
New keyword statement that rotates the OAM DMA start offset
one slot per call. When called once per `on frame`, the PPU's
sprite evaluation picks up a different subset of the 12+
overlapping sprites each frame, so the permanent-dropout
failure mode becomes visible flicker — the classic NES
technique used by Gradius, Battletoads, and every shmup.
Implementation:
- Lexer keyword `KwCycleSprites` and parser production.
- AST `Statement::CycleSprites(Span)`.
- `IrOp::CycleSprites` lowered by the IR pass.
- Codegen emits `LDA $07EF / CLC / ADC #4 / STA $07EF` with
natural u8 wrap, plus a one-shot `__sprite_cycle_used`
marker label the first time it fires.
- Linker detects the marker and switches `gen_nmi` to the
cycling variant, which reads the rotating offset from
`$07EF` into OAM_ADDR before the DMA instead of writing
a literal 0. Programs that don't call `cycle_sprites`
skip the marker and get byte-identical ROM output.
Layer 3: debug-mode sprite overflow telemetry
Mirrors the frame-overrun pair (`debug.frame_overrun_count` /
`debug.frame_overran`). In debug builds the NMI handler reads
`$2002` at the top of vblank, masks bit 5 (the PPU's sprite
overflow flag), and if set bumps a cumulative counter at
`$07FD` plus a sticky bit at `$07FC`. The sticky bit clears
on every `wait_frame`.
New debug builtins:
- `debug.sprite_overflow_count()` → u8 peek of $07FD
- `debug.sprite_overflow()` → u8 peek of $07FC (sticky bit)
The hardware flag has well-known quirks but is correct for
the overwhelming majority of cases and costs ~15 cycles per
frame to sample. Release builds emit no overflow-check code
at all, so the four bytes at `$07EF` / `$07FC`-`$07FD` stay
free for user allocation.
Related changes:
- `gen_nmi` now takes an `NmiOptions` struct. Four bool
parameters tripped clippy's `fn_params_excessive_bools`.
- CLI `build` now renders analyzer warnings on a successful
build. Previously warnings were silently dropped unless
the user also ran `nescript check`, which made W0109
effectively invisible to CI and local dev alike. Existing
pre-existing W0103 / W0106 warnings on `coin_cavern`,
`mmc3_per_state_split`, `sprites_and_palettes` surface
too — not regressions, just now visible.
New example: `examples/sprite_flicker_demo.ne`
Draws 12 sprites into a 4-pixel band, W0109 fires at compile
time with nine labels pointing at the offenders, and a
`cycle_sprites` call at the end of `on frame` turns the
hardware dropout into flicker. The committed emulator golden
captures one frame of the cycling pattern (deterministic).
Tests:
- `runtime::tests::nmi_debug_mode_samples_sprite_overflow`
- `runtime::tests::nmi_sprite_cycle_variant_reads_rotating_offset`
- `ir_codegen::*::debug_sprite_overflow_count_loads_07fd`
- `ir_codegen::*::debug_sprite_overflow_flag_loads_07fc`
- `ir_codegen::*::wait_frame_clears_sprite_overflow_sticky_in_debug_mode`
- `ir_codegen::*::wait_frame_release_does_not_touch_sprite_overflow_sticky`
- `ir_codegen::*::cycle_sprites_emits_marker_and_add4`
- `ir_codegen::*::cycle_sprites_marker_dedup_across_multiple_calls`
- `ir_codegen::*::program_without_cycle_sprites_emits_no_marker`
- `analyzer::*::accepts_debug_sprite_overflow_builtins`
- `analyzer::*::rejects_unknown_debug_method_lists_all_four_known_names`
- `analyzer::*::accepts_cycle_sprites_statement`
Docs: `examples/war/COMPILER_BUGS.md` §4 now describes all three
layers (W0109, `cycle_sprites`, debug telemetry) with reasoning
for when each applies. `README.md` and `examples/README.md` add
the new example to their tables.
All 32 emulator goldens still match — the cycling is opt-in
and programs that don't call `cycle_sprites` or enable debug
mode are byte-identical to the pre-change output.
https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
|
||
|
|
d6cb84a5bd
|
compiler: close out bug #4 (W0109 sprite-per-scanline) and bug #5 (real inlining)
Fixes the last two deferred compiler bugs catalogued in examples/war/COMPILER_BUGS.md, finishing the bug-cleanup arc on the War branch. Bug #5 — `inline fun` inliner Previously the `inline` keyword was parsed into `FunDecl.is_inline` and then dropped on the floor: every call site emitted a regular `JSR` through the $04-$07 transport slots. Now the IR lowerer captures inline function bodies up front in `LoweringContext::capture_inline_bodies` and rewrites call sites at lowering time. Two body shapes are supported: 1. Single-return expression — the body is re-lowered in place of the `Call` op with the parameter names substituted to fresh IR temps for each argument. 2. Void multi-statement body whose every statement is one of Assign/Call/Draw/Scroll/SetPalette/LoadBackground/WaitFrame/ Play/StartMusic/StopMusic/InlineAsm/RawAsm/DebugLog/DebugAssert — the statements are spliced into the caller's block with the same parameter substitution machinery. Control-flow-heavy inline bodies (conditional early returns, loops, transitions) fall back to a regular out-of-line call with no diagnostic. That's predictable and documented in the bug-tracking doc. Nested inline expansion uses a substitution-frame stack so an inline calling another inline sees the right arguments. A codegen follow-up was needed because bug #3's scope-qualified local names broke `{result}` substitution in inline asm. The codegen now tracks `current_fn_scope_prefix` per function and the InlineAsm op tries the qualified name first before falling back to the bare name. Bug #4 — W0109 sprite-per-scanline static check Adds a new warning code W0109 and an analyzer pass `check_sprite_scanline_budget` that walks each state's `on_frame` handler, collects literal-coordinate `draw` statements (including metasprite expansion via dx/dy offsets), and iterates scanlines 0..240 to count how many 8x8 sprites overlap each line. When a scanline has > 8, the analyzer emits W0109 with labels pointing at each offending draw site plus a help message about staggering y-rows and a note explaining the hardware dropout. Non-literal coordinates are skipped (static analysis can't resolve them). Nested `if`/`while`/`for`/`loop` blocks are unioned conservatively. Tests added src/ir/tests.rs - inline_fun_expression_body_emits_no_call_at_use_site - inline_fun_void_body_statements_are_spliced - inline_fun_with_conditional_return_compiles_as_regular_call - inline_fun_nested_inlines_substitute_correctly src/analyzer/tests.rs - analyze_sprite_scanline_budget_warns_over_eight - analyze_sprite_scanline_budget_ok_when_staggered - analyze_sprite_scanline_budget_skips_dynamic_coords - analyze_sprite_scanline_budget_expands_metasprites - analyze_sprite_scanline_budget_recurses_into_if COMPILER_BUGS.md Bugs #4 and #5 marked **FIXED** in the status table, with full reproduction/root-cause/fix/regression-test write-ups updated in place. All seven catalogued bugs now have shipped fixes. Artifact churn - examples/war.nes and examples/inline_asm_demo.nes rebuild byte-shifted (different JSR targets post-inliner). - tests/emulator/goldens/war.audio.hash shifts from 143660f to 13443e28 — the inliner removes JSRs to set_phase, which nudges NMI sampling timing. No pixel diff; behavior is unchanged. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z |
||
|
|
76dd8eacb0
|
compiler: fix three scoping bugs; war: revert all local/param workarounds
Three related scoping bugs from examples/war/COMPILER_BUGS.md,
all fixed in one pass because they're different layer
manifestations of the same "flat global namespace" problem:
## §3: function-local `var` declarations lived in one namespace
`src/analyzer/mod.rs::register_var` inserted every `var` it
saw — top-level, state-local, AND function-body local — into
the same `self.symbols: HashMap<String, Symbol>`. Two different
functions declaring `var i` collided on E0501, which is why
every local in war/*.ne had a function-prefix like `dfa_card`
or `dwp_px`.
Fix: add a `current_scope_prefix: Option<String>` to the
Analyzer, set it to `Some("<fn_name>")` when checking a
function body (or `Some("Title__frame")` for state handler
bodies), and have `register_var` store the declaration under
an internal key `"__local__{prefix}__{name}"`. New
`resolve_symbol` / `resolve_key` helpers try the
scope-qualified key first and fall back to the bare key for
globals / consts / enum variants / state-level vars / function
names. Every existing `self.symbols.get(name)` inside
body-checking code was swapped over.
Two `var i` declarations inside the SAME function body still
collide with E0501 — we scoped per function body, not per
nested block. Per-block scoping would require live-range
analysis to reuse RAM slots.
## §1b: same-named params across functions shared VarIds
`src/ir/lowering.rs::get_or_create_var` looked up names in a
single global `var_map`, so two functions both with a `card:
u8` parameter resolved to the same `VarId`. Whichever function
was lowered last won the zero-page slot mapping, silently
rerouting the other function's param reads to the wrong slot.
Fix: the IR lowerer now mirrors the analyzer's scope logic.
`LoweringContext` gains a `current_scope_prefix` field that
gets set in `lower_function` / `lower_handler`, and
`get_or_create_var` uses a new `scoped_key` helper that
prepends `"__local__{prefix}__"` when the qualified key exists
in `var_map` or `var_types`. Each function's parameters and
locals therefore get distinct VarIds, and the codegen's
`var_addrs` map naturally has no collisions.
## §2: param transport slots $04-$07 clobbered across nested JSRs
Parameters were passed AND kept in `$04-$07` for the lifetime
of a function. Any nested call overwrote those slots with its
own arguments, so the caller's params were silently corrupted
as soon as it invoked anything. Every war helper that took
params and called other helpers (draw_card_face, push_back_a,
etc) snapshotted its params into fresh locals at the top of
the body.
Fix: in `codegen/ir_codegen.rs::IrCodeGen::new`, every
function-local — including parameters — now gets a dedicated
per-function RAM slot at `$0300+`. Parameters are still passed
via the zero-page transport slots `$04-$07` as the calling
convention, but `gen_function` now emits a **prologue** at
every function entry:
LDA $04
STA <param_0_addr>
LDA $05
STA <param_1_addr>
... etc, up to 4 ...
By the time the body runs, every parameter lives in the
function's dedicated RAM slot, so any nested call can freely
clobber $04-$07 (writing its own arguments there) without
corrupting the caller's saved parameters. Costs 4 LDA/STA
pairs (≈ 20 bytes of ROM, 16 cycles) at every function entry
— worth it to make the calling convention sound.
## War cleanup
With all three fixes in place, every workaround prefix in
`examples/war/*.ne` is gone:
- `card_rank(card)` instead of `card_rank(crk_c)` — bug #1b
- `compare_cards(a, b)` instead of `compare_cards(cmp_a, cmp_b)`
- `push_back_a(card)` instead of `push_back_a(pba_in)` — bug #1b
- `var card: u8 = draw_front_a()` in bury_from_* — bug #3
- `var i: u8 = 0` freely in multiple functions — bug #3
- `fun push_back_a(card)` body no longer snapshots `card` into
`pba_card` before calling wrap52 — bug #2
- `fun draw_card_face` body no longer snapshots x/y/card into
locals before calling card_rank/card_suit — bug #2
- `draw_word_player` steps its own x without needing a
`dwp_px` accumulator to avoid the `x + N` arg compilation
quirk — that quirk was a downstream symptom of bug #2 and
is also gone
The source is now about 300 lines shorter and significantly
more readable.
## Regression tests
Seven new tests nail these bugs down:
- `analyzer::tests::analyze_allows_same_local_name_in_two_functions`
- `analyzer::tests::analyze_allows_same_param_name_in_two_functions`
- `analyzer::tests::analyze_allows_same_local_name_in_two_state_handlers`
- `analyzer::tests::analyze_still_rejects_duplicate_local_in_same_function`
- `codegen::ir_codegen::gen_function_prologue_spills_params_to_local_ram`
Plus the four param-arity tests from the earlier E0506 fix
and the wide_hi-leak regression test from the previous
compiler fix. Total suite: 591 unit tests, all passing.
## Golden drift
The prologue change adds a few cycles to every function entry,
which shifts NMI sampling by a handful of cycles and flips
the audio-hash of any example that plays sfx or music
(platformer, war). `arrays_and_functions.png` also picks up a
1-pixel shift in its enemy positions due to the same timing
drift. All three golden updates are pure "compiler produces
different but functionally-identical output" — no game
behavior changed.
## What's still open in COMPILER_BUGS.md
- §4: 8-sprites-per-scanline hardware limit is invisible to
user code. A static analyzer hint could help; deferred.
- §5: `inline` keyword is silently declined for short
functions that the optimizer's inliner doesn't recognize
(it only removes empty functions). Deferred pending a real
single-return-expression inlining pass.
https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
|
||
|
|
e8d602c1bc
|
codereview: address six findings from a fresh review pass
A focused review of the branch surfaced two correctness bugs and
four important polish items in the new features. None of the
existing example goldens shift — every fix is gated on conditions
that don't fire in the committed examples.
**debug.frame_overran() never reset between frames in implicit
wait_frame programs.** The IR-level WaitFrame op cleared $07FE,
but the implicit main-loop flag-clear that runs between dispatch
iterations only cleared ZP_FRAME_FLAG. A program whose
`on frame { ... }` body had no explicit `wait_frame` would latch
$07FE to 1 on the first miss and never reset, breaking
`debug.assert(not debug.frame_overran())` guards. The dispatch
loop now also clears $07FE in debug builds, mirroring the
WaitFrame path. New regression test asserts the main loop emits
exactly one STA $07FE in a no-wait_frame debug build.
**Metasprite base-tile resolution silently miscompiled for
`@chr` / `@binary` sprites.** The IR lowering walks
`program.sprites` to compute base tile indices but assumes
1 tile per non-Inline source, while the real asset resolver
reads the file. The analyzer now hard-rejects the combination
with a clear "use inline pixels" hint instead of letting it
compile to a visual glitch. New analyzer test
`analyze_metasprite_with_external_chr_sprite_errors` covers it.
**next_sprite_tile capping silently allowed CHR overlap.** The
pipeline used `.min(255)` which would let a background tile
overwrite a sprite tile when the sprite range filled the
pattern table. Now hard-errors via CompileError::AssetResolution
when the sprite range >= 256 *and* the program declares any
`@nametable(...)` background. Inline backgrounds aren't affected.
**Linker silently truncated background CHR overflow.** The
`if end <= chr.len()` guard at the CHR copy site dropped any
auto-CHR bytes that would have run past the pattern table.
Replaced with a debug assertion since the resolver should
have caught it upstream — defense in depth.
**Stale comment in nested_structs.ne** said struct literals
"don't accept array fields yet" while the example itself
demonstrates inline array fields working through
`expand_struct_literal_init`. Comment updated.
**Misleading sentinel comment in audio.rs** described the pitch
envelope's trailing zero as a runtime sentinel; in practice the
volume tick `JMP`s to `__audio_sfx_done` first and the pitch
update block never reads the trailing byte. Rewrote the comment
to clarify it's padding for predictable blob length.
Also tidies up two minor items the reviewer flagged:
- `flatten_struct_fields` rebuilt the `struct_sizes` HashMap on
every leaf field; hoisted the snapshot to the function entry.
- Integration tests called `resolve_backgrounds(..., 0)` (the new
`next_sprite_tile` parameter); changed to `1` so a future
PNG-nametable test fixture won't accidentally overwrite the
runtime smiley at tile 0.
https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
|
||
|
|
9878b7d87d
|
audio: per-frame pitch envelopes for pulse SFX
Pulse-channel sfx with a multi-byte `pitch:` array used to silently ignore everything past the first byte — the runtime audio tick latched the period at trigger time and never updated it. Programs that wanted a frequency sweep had no way to express it. The compiler now compiles a per-frame pitch envelope blob alongside the existing volume envelope when `decl.pitch` has more than one distinct value. The blob is padded (or truncated) to the volume envelope's length and ends in a zero sentinel so the runtime walker stops both pointers on the same NMI. Sfx with a single scalar pitch (or an array where every byte is the same) keep their historical "no pitch blob, latch once" path and emit byte-identical ROM bytes. The runtime gains two new pieces, both gated on a new `__sfx_pitch_used` codegen marker so programs without varying-pitch sfx pay zero bytes: 1. `gen_audio_tick` emits a per-frame pitch update block inside the SFX tick: read a byte through `(AUDIO_SFX_PITCH_PTR),Y`, write it to `$4002` (pulse-1 period low), advance the pointer. The block bails on a zero high-byte pointer so a single program can mix scalar-pitch and varying-pitch sfx without one clobbering the other. 2. `emit_play_pulse` seeds `AUDIO_SFX_PITCH_PTR_LO/HI` with the pitch-blob label for varying-pitch sfx and zeros it for scalar-pitch sfx. The per-call branch is skipped entirely when the program has no varying-pitch sfx anywhere. The new `examples/sfx_pitch_envelope.ne` exercises the path with a 16-frame siren sweep. Triangle and noise per-frame pitch are deferred — they share the same data shape but the runtime ticks for those channels still write only their volume registers, see docs/future-work.md for the gap. https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB |
||
|
|
db3a4adc57
|
codegen: support banked → banked cross-bank function calls
Programs that put functions in switchable banks can now call across
bank boundaries — `bank A { fun step() { helper() } }` where
`helper` lives in `bank B` used to panic in the IR codegen. Three
small pieces unblock it:
1. **Generic trampoline.** `runtime/gen_bank_trampoline` no longer
takes a `fixed_bank_index` argument. Instead it reads the
caller's current bank from `ZP_BANK_CURRENT`, pushes it on the
hardware stack, switches to the target, JSRs the entry, then
pulls and restores the saved bank. The same per-callee stub
works for fixed→banked and banked→banked direction; nested
trampolines compose because each PHA/PLA pair sits inside its
own JSR/RTS frame. `gen_mapper_init` seeds `ZP_BANK_CURRENT`
with the fixed bank index for any banked mapper so the very
first cross-bank call from the fixed bank still restores to
the fixed bank (matching pre-banked-banked semantics).
2. **Codegen drops the panic.** The `Some(from), Some(to)` arm in
the call-resolution switch now emits `JSR __tramp_<name>` like
the fixed→banked case instead of panicking. Banked→fixed calls
still go direct (the fixed bank is always mapped at $C000).
3. **Bank-namespaced local labels.** Two banks emitting the same
`__ir_cmp_e_8` would trip the linker's discovery-pass duplicate-
label check the moment any banked code generated a comparison.
The new `local_label_suffix` helper prefixes the suffix with the
current bank name when banked code is being emitted, leaving
fixed-bank label generation untouched (so existing examples are
byte-identical apart from the trampoline / init bytes
themselves).
The new `examples/uxrom_banked_to_banked.ne` demonstrates the path
end-to-end: `bank Logic { fun step() { ... clamp() } }` calls
`bank Helpers { fun clamp() { ... } }` once per frame. The harness
golden is committed alongside it. The five existing banked example
ROMs change byte-for-byte because of the new trampoline shape and
the seed-ZP_BANK_CURRENT init, but their emulator goldens still
match exactly — observable behaviour is unchanged.
https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
|
||
|
|
d4e613fb7c
|
debug: add debug.frame_overrun_count() and debug.frame_overran() builtins
The frame-overrun counter at $07FF was previously only readable via `peek(0x07FF)`, which forces every program that wants to guard against missed frames to know the magic address. This adds two named query expressions: - `debug.frame_overrun_count()` — cumulative miss count since reset - `debug.frame_overran()` — sticky bit cleared by the next wait_frame, so `debug.assert(not debug.frame_overran())` catches a miss in the previous window without waiting for the counter to roll over. The sticky bit lives at $07FE alongside the existing counter and is set inside the same NMI-time overrun branch. Release builds emit none of the runtime side: the NMI handler still skips both writes, the codegen `wait_frame` only clears $07FE in debug mode, and committed example ROMs stay byte-identical. The new expression form parses through `parse_primary`'s `KwDebug` arm, so the existing `debug.log(...)` / `debug.assert(...)` *statement* parser stays untouched. The analyzer rejects unknown methods with E0201 and stray arguments with E0203 so typos don't silently compile to a zero load. https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB |
||
|
|
c5297567d2
|
codereview: address four residual concerns from the hardware review
- Analyzer: new `W0108` warning when an array's byte size exceeds 256. The codegen lowers `arr[i]` to `LDA base,X` and the 6502's X register is 8 bits, so elements past byte 255 are unreachable. The old debug bounds check silently skipped arrays in that range; it now clamps the compare to 255 and the analyzer diagnoses the declaration up front. - UxROM `__bank_select`: the routine previously wrote the bank number to a fixed `$FFF0`, which works on emulators that don't simulate bus conflicts (jsnes, Mesen permissive) but is broken on real hardware because a single ROM byte can't match every possible bank number. Fixed by `TAX; STA __bank_select_table,X` — the store lands at `table + bank_num`, whose ROM byte is exactly `bank_num`, so CPU bus = A = ROM = no conflict. New `LabelAbsoluteX` addressing-mode variant in the assembler resolves the table's base address through the existing fixup pass. The two existing UxROM example ROMs shift a few bytes but their goldens still match (jsnes is bus-conflict-permissive). - Source maps: new `source_map_survives_aggressive_peephole_folding` regression test. The reviewer was worried peephole could drop `__src_<N>` labels and silently leave stale source-map entries. Peephole actually treats labels as block boundaries and never deletes them — the test pins that down by compiling a program tailored to trip every peephole fold and asserting every codegen-recorded source marker survives into the final linker label table. - Frame-overrun counter: new `debug_frame_overrun_counter_reads_back_from_user_code` end-to-end test that proves the contract works: NMI emits `INC $07FF`, user `peek(0x07FF)` lowers to `LDA $07FF`, and the RAM allocator doesn't hand out `$07FF` to a user variable. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM |
||
|
|
9b54ff83c0
|
audio: always enable all four tone channels on sfx trigger
Writing $07 in `emit_play_triangle` and $0B in `emit_play_noise` meant that a noise play following an in-progress triangle note would clear bit 2 of $4015 and cut the triangle off mid-envelope (and vice versa). Write $0F from both paths so every trigger keeps pulse1, pulse2, triangle, and noise enabled; channels with no active envelope stay silent via the runtime's per-channel counter gating. Also fixes the attribute-byte packing comment in `png_to_nametable` — the code was correct, the doc string had the quadrant order backwards. The only observable ROM change is `examples/noise_triangle_sfx.nes` (two immediate operands shift) and its audio hash golden; the committed PNG golden is byte-identical. Found in independent code review after the section landed. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM |
||
|
|
2fe943b056
|
codegen: user code in switchable banks via cross-bank trampolines
Adds a `bank Foo { fun bar() { ... } }` parser form so user functions
can opt into living in a switchable PRG bank instead of the fixed
bank, plus the IR codegen, runtime, and linker work to make calls
across the bank boundary actually run. Programs that don't use the
new syntax produce byte-identical ROMs to before — verified by
rebuilding every existing example and diffing.
Pipeline shape:
* Parser accepts both `bank Foo: prg` (legacy reserved slot) and
`bank Foo { fun ... }` (functions land in the named bank). Nested
functions get tagged `bank: Some("Foo")` on the FunDecl + IrFunction.
* Analyzer bumps the user zero-page start past `$10` whenever the
program declares any banked function, so `__bank_select`'s STA into
ZP_BANK_CURRENT can't clobber a user variable. Programs without
banked functions keep the legacy `$10` start.
* IrCodeGen emits each banked function into its own per-bank
instruction stream (`banked_streams: HashMap<String, Vec<Instruction>>`)
while the fixed-bank stream gets the dispatcher loop + state
handlers + top-level functions, exactly like before. Cross-bank
calls from the fixed bank rewrite `JSR __ir_fn_<name>` to
`JSR __tramp_<name>`; in-bank calls stay direct. Banked → fixed
calls are direct (the fixed bank is always mapped at $C000-$FFFF).
Banked → other-banked calls aren't supported in this pass and
panic loudly during codegen.
* Runtime's `gen_bank_trampoline` takes the trampoline label and
entry label as parameters now (one trampoline per banked function,
not one per bank) so the linker can request any number of stubs.
* Linker assembles banked banks twice: a discovery pass to learn
each bank's labels, then a final pass that seeds the merged label
table so banked code can JSR into the fixed bank's runtime helpers
(math, audio, etc.). The fixed-bank assembler is also seeded with
the cross-bank labels so the trampolines' `JSR __ir_fn_<name>`
resolves into the bank's $8000 window. New `asm::assemble_with_labels`
/ `asm::assemble_discover_labels` helpers wire this up.
* PrgBank carries `Vec<Instruction>` + a list of `BankTrampoline`
requests now, replacing the old `data: Vec<u8>` + single
`entry_label: Option<String>` shape. The compiler populates both
from the codegen output; the linker's two-pass assembly handles
the rest.
New example: `examples/uxrom_user_banked.ne` puts a sprite-stepping
helper inside `bank Extras { fun step_animation() { ... } }`. The
fixed-bank state handler calls it via the generated trampoline, and
the harness golden locks in pixel + audio output at frame 180.
UxROM is the only mapper exercised by the new example. MMC1 and
MMC3 also work through the same path (the linker emits the right
mapper-specific bank-select code), but no example uses them yet —
the existing `mmc1_banked.ne` / `mmc3_per_state_split.ne` keep
their fixed-bank-only layout.
Limitations carried forward:
* No banked → banked cross-bank calls (panics in codegen).
* No greedy size-packing; placement is explicit-only.
* MMC3 state handlers don't get banked (the per-state split path
is untouched).
|
||
|
|
201664ea04
|
audio: triangle and noise sfx channels
Adds `channel: triangle` / `channel: noise` to the `sfx` declaration form. The existing pulse-1 / pulse-2 driver is unchanged (and is still byte-identical for programs that don't use the new channels) — when a program declares a triangle or noise sfx the runtime splices in an additional per-channel slot that writes to $4008- $400B (triangle) or $400C-$400F (noise) on play. Includes a new `examples/noise_triangle_sfx.ne` demo with committed golden PNG + audio hash. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM |
||
|
|
b575921c8e
|
debug: add symbol export, source maps, bounds checks, overrun counter
Implements four items from docs/future-work.md's "Debug instrumentation"
section so debugging on real ROMs is no longer a guessing game:
1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now
returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct
from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs`
renders that plus the analyzer's var allocations into a Mesen-
compatible label listing (function entry points get `P:` entries
at PRG-relative offsets; user vars get `R:` entries).
2. Source maps via `--source-map <path>`. IR lowering now emits a
`SourceLoc(span)` op before every statement; the codegen turns each
one into a `__src_<N>` label-definition pseudo-op and records the
span in a side table. Source-marker emission is opt-in
(`with_source_map(true)`) because labels become peephole block
boundaries — leaving the markers off preserves byte-identical
release ROMs.
3. Array bounds checking under `--debug`. Every `ArrayLoad` /
`ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:`
guard, and the codegen emits one shared `__debug_halt` trap at the
end of the fixed bank (writes $BC to the debug port then wedges in
a tight `JMP $`). Release builds skip the whole thing.
4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a
`debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of
the handler and increments a counter at `$07FF`
(`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning
the main loop didn't reach `wait_frame` before the next vblank.
User code can read the counter via `peek(0x07FF)`. This is the
abbreviated form the future-work doc suggested: a bump-a-counter
hook rather than a full cycle-budget tracker, which would need a
new builtin. The codegen emits a `__debug_mode` marker label in
debug mode so the linker can select the overrun-aware NMI variant.
Release ROMs for every committed example are byte-identical before
and after this change (verified with `git diff examples/` after a
full rebuild). All 512 lib tests and 71 integration tests pass;
`cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
54acb9ee38
|
Bug B: runtime OAM cursor so draw inside loops actually works
`IrCodeGen::next_oam_slot` incremented at *compile time*: one
`draw` statement = one fixed OAM slot, baked into absolute-mode
stores at codegen. A `draw` inside a `while`/`for`/`loop` body
was lowered once and then always wrote to the same four OAM
bytes every iteration, so only the last iteration was ever
visible. The writeup in the earlier PR called this "bug B".
Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the
top of every frame handler (right after the existing OAM clear
loop), and lower each `DrawSprite` IR op to:
LDY $09 ; load cursor
LDA <y_temp>
STA $0200,Y ; sprite Y
LDA #tile
STA $0201,Y ; tile
LDA #0
STA $0202,Y ; attr
LDA <x_temp>
STA $0203,Y ; sprite X
INC $09 x4 ; bump cursor by 4
Cost is ~+6 bytes per `draw` over the old static form. At 64
slots the u8 cursor wraps naturally, giving classic NES
"too many sprites" flicker instead of a silent compile-time
drop. `next_oam_slot` and its resets are gone from the IR
codegen entirely.
Secondary fix: `for i in 0..N` counters are now registered as
handler locals. `lower_statement` created a `VarId` for the
counter via `get_or_create_var` but never pushed it onto
`current_locals`, so the IR codegen's `var_addrs` lookup
returned `None` for every `StoreVar(i)` / `LoadVar(i)` and
silently emitted nothing. The counter stayed at 0 forever,
the loop spun indefinitely, and every iteration wrote the
first array element into OAM — turning all 64 sprites into
the same smiley. Same class as the handler-local `var` decl
bug from the earlier PR, just for for-loop variables.
Smoke-test deltas (all 14/14 still pass):
- arrays_and_functions: 104 -> 260 (player + 4 enemies)
- bitwise_ops: 104 -> 416 (player + flag sprites + pips)
- loop_break_continue: 208 -> 208 (already fixed by the earlier pass)
- structs_enums_for: 104 -> 260 (player + 4 enemies)
Regression tests:
- `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a
single `draw` emits `LDY cursor`, four `STA $020N,Y`, and
four `INC cursor`.
- `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots`
— rewritten for the new form: each draw gets its own
`LDY cursor` + 4 `INC cursor`.
- `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` —
proves a `draw` inside a `while` compiles to ONE cursor-based
draw (not N unrolled statics and not zero), and asserts no
stray `STA $0204`/`$0208`/... absolute stores — those would
indicate bug B has regressed.
- `ir::tests::for_loop_counter_is_registered_as_handler_local`
— verifies `for i in 0..N` pushes `i` onto `current_locals`
so the IR codegen allocates it.
Smoke-test tightening: `tests/emulator/run_examples.mjs` now
has per-example `minNonBlack` floors. `arrays_and_functions`,
`structs_enums_for`, `loop_break_continue`, and `bitwise_ops`
all require multi-sprite rendering — if the OAM cursor bug
comes back, the smoke test fails loudly instead of passing on
the default `nonBlack > 0` check.
The legacy AST codegen in `src/codegen/mod.rs` still uses the
compile-time `next_oam_slot` approach. It's only reachable via
`--use-ast`, none of the examples use it, and its integration
tests only check iNES structure — left alone on purpose.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
|
||
|
|
f49dbce686
|
Fix three compiler bugs exposed by array-using examples
Landing bug A from the previous writeup plus two adjacent bugs that the fix exposed. All three miscompile anything that uses a u8[N] global with a literal initializer. 1. Array-literal globals are now actually initialized. `lower_program` only expanded `Expr::StructLiteral` into per- field synthetic globals — `Expr::ArrayLiteral` hit `eval_const`, returned `None`, and the array boot-cleared to zero. `IrGlobal` now carries an `init_array: Vec<u8>` populated by lowering, and the IR codegen startup loop emits one `LDA #byte; STA base+i` pair per element. 2. Local variables no longer overlap array globals. `IrCodeGen::new` advanced `local_ram_next` past `max_global_base + 1` — for an array at `$0300-$0303` it placed the first handler-local at `$0301`, inside the array. The frame handler's stores through the local then corrupted the array mid-frame. The allocator now walks the analyzer's `VarAllocation` list and advances past `address + size` for every RAM global, not just the base. 3. Peephole `remove_redundant_loads` honors indexed LDAs. The pass tracked `LDA Immediate/ZeroPage/Absolute` but let `LDA AbsoluteX/AbsoluteY/ZeroPageX/IndirectX/IndirectY` fall through the match, leaving the A-equivalence tracker unchanged. A later `LDA #v` that happened to match a stale entry from BEFORE the indexed load would then be dropped as "already in A" — a silent miscompile that turned every `draw Sprite at: (arr[i], arr[j])` pattern into garbage (the second array index would be computed from `arr[i]`'s value, reading way out of bounds). Indexed LDAs now clear the tracker. Regression tests: - `src/codegen/peephole.rs`: a synthetic `LDA #0; TAX; LDA AbsX(arr1); STA temp; LDA #0; TAX; LDA AbsX(arr2); ...` sequence asserts both `LDA #0`s survive. - `src/ir/tests.rs`: verifies `var xs: u8[4] = [1,2,3,4]` populates `IrGlobal::init_array` with `[1,2,3,4]`. - `tests/integration_test.rs`: two IR-codegen tests — one checks the startup instructions contain `LDA #v; STA base+i` for every element, the other compiles a handler-local var alongside an array global and asserts no post-init stores land inside the array. Smoke test impact (14/14 still passing, now more visible): - arrays_and_functions: 56 -> 104 nonBlack, now animated - loop_break_continue: 52 -> 208 (player + 3 hazards visible) - structs_enums_for: 52 -> 104 (player + enemy visible) Existing examples unchanged; no remaining work for bug B (static OAM slot allocation in loops) — that's the next PR. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V |
||
|
|
81f3fd7de0
|
Add jsnes emulator harness and fix four codegen bugs it surfaced
Running the compiled example ROMs through a headless puppeteer +
local jsnes harness exposed four latent bugs that the
header-structure-only integration tests couldn't catch:
- src/asm/mod.rs: the first pass treated ANY instruction with
`AddressingMode::Label` as a label definition, silently dropping
every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label
def; other opcodes emit the opcode byte plus a 2-byte absolute
fixup resolved in pass two. Without this, every example crashed
with "invalid opcode at $1xxx" once the CPU fell through into
the math runtime and hit an unbalanced `RTS`.
- src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s
(e.g. `var i: u8 = 0` inside a `while`) were pushed onto
`current_locals` but the handler built its own throwaway
`locals` list, so those var ids never got RAM addresses and
every `LoadVar`/`StoreVar` for them silently emitted nothing.
Seed `current_locals` with the state's declared locals and
reuse it so `lower_statement`'s appends flow through to the
`IrFunction`. Fixes the black screen in `arrays_and_functions`.
- src/ir/lowering.rs (global init): struct-literal initializers
on globals (`var player: Player = Player { x: 120, ... }`) fell
through to `eval_const`, which returned `None` for a
non-literal, so no init code was emitted. Now the per-field
synthetic globals each get their own `init_value`. Fixes the
black screen in `structs_enums_for`.
- src/codegen/mod.rs: the legacy AST codegen was emitting
`JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware
intrinsics. It only "worked" before because the broken
assembler swallowed the bogus JSR. Handle `poke`/`peek` as
direct STA/LDA to a compile-time-constant absolute address,
matching the IR codegen's intrinsic path.
The harness lives in `tests/emulator/`: a tiny HTML page that
wraps the `jsnes` npm package, driven by a puppeteer script that
loads each ROM, runs ~180 frames, snapshots the canvas, and
records a smoke-test verdict (booted without a CPU crash, non-zero
pixels rendered, frames differ over time). `npm install && node
run_examples.mjs` from `tests/emulator/` runs the full sweep.
9/9 example ROMs now load, render, and animate where expected.
All 324 unit + 35 integration tests still pass.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
|
||
|
|
629e596ec7
|
Codegen: skip OAM clear loop in handlers that don't draw
The OAM clear at frame-handler entry is only needed if the handler actually modifies OAM — handlers that never call \`draw\` don't need to reset the shadow buffer at all, since the previous handler's contents are still correct (or the startup zero-fill still applies). Adds a \`function_draws_sprites\` helper that walks the IR ops once and emits the clear loop only when at least one \`DrawSprite\` exists. Handlers like a title screen that doesn't render sprites pay zero cycles for the OAM clear now. https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3 |
||
|
|
2fd3a6e545
|
IR codegen: clear OAM shadow at frame handler entry
Each frame handler now begins with a short 64-slot loop that writes \$FE to the Y-position byte of every OAM entry, hiding any sprites the previous frame drew that the current frame doesn't. Without this, stopping a \`draw\` call one frame leaves the sprite lingering at its last position forever. Cost: ~384 cycles per frame (2.3% of the ~16 666 cycles available between vblanks). The per-frame \`draw\` calls overwrite the slots they actually use, so the clear is free at runtime for sprites that ARE drawn. https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3 |
||
|
|
d2075bc8f9
|
Codegen: richer A-value tracking via equivalence classes
Previously \`remove_redundant_loads\` tracked only a single value
for A at a time, so \`STA addr\` would overwrite the prior
\`Imm(v)\` with \`Zp(addr)\`, losing the ability to eliminate a
following \`LDA #v\`. Now A's known value is an equivalence class
(\`Vec<AValue>\`): every \`STA addr\` *adds* the address to the
class instead of replacing it, so
LDA #5
STA \$10
LDA #5 ; eliminated — A still equals 5
collapses to just two instructions. Adding this also catches the
earlier \`STA \$10; LDA \$10\` idiom because \`Zp(\$10)\` is now part
of the class after the STA.
Any op that clobbers A clears the whole class; writes via STX/STY
or INC/DEC to an address in the class invalidate just that entry.
Instruction counts tighten on every example, most visibly on
bouncing_ball (123→120) and arrays_and_functions (247→245).
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
|
||
|
|
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
|
||
|
|
a45944e0f9
|
Language: raw asm { ... } blocks
raw asm {
LDA #\$42
STA \$00
}
Unlike regular \`asm\`, \`raw asm\` does not perform \`{var}\`
substitution — the body is passed to the inline parser verbatim.
Useful for writing completely unmanaged bytes that don't rely on
the analyzer's variable allocations, e.g. mapper init snippets.
Implementation:
- Parser: \`KwRaw\` followed by \`KwAsm\` emits
\`Statement::RawAsm(body, span)\`.
- IR lowering: prepends a \`\\0RAW\\0\` marker to the body when
emitting \`IrOp::InlineAsm\` so the codegen can distinguish raw
from regular without adding a second op variant.
- IR codegen: strips the marker and skips substitution when present.
- AST codegen: same, handling \`Statement::RawAsm\` directly.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
|
||
|
|
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
|
||
|
|
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
|
||
|
|
2e5933ccca
|
Codegen: extend A-tracking and copy-propagation to absolute addresses
Both peephole passes now track \`Absolute\` addresses in addition to
zero-page. This lets function-local variables (which live in RAM at
\$0300+) benefit from the same redundant-load elimination and
operand-rewriting that globals at ZP addresses already enjoyed.
Before:
LDA \$04 ; load param
STA \$0300 ; store to t
LDA \$0300 ; reload t
STA \$81 ; store to temp
LDA \$0300 ; reload t again
STA \$82 ; store to temp
LDA \$81
CLC
ADC \$82
STA \$0300
LDA \$0300
RTS
After:
LDA \$04
STA \$0300
CLC
ADC \$0300
STA \$0300
RTS
Renames the A-tracking enum's \`Addr\` variant to \`Zp\` alongside a
new \`Abs\` variant, matching the \`Source\` type used by copy prop.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
|