mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 17:06:04 +00:00
16 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
20a244b9e7
|
examples: regenerate ROMs, gifs, and goldens after codegen local fix
Commit
|
||
|
|
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
|
||
|
|
e10d09db76
|
examples/war: redesign card art — opaque bodies, 16x16 pips, checkerboard back
The first-pass card tiles were riddled with palette-0 transparent pixels that let the felt background bleed through every rank glyph, small suit, and big pip. At arm's length the cards looked like they had green specks eating them. This commit rewrites all of the card art from scratch: - **Opaque card bodies.** Every pixel inside a card tile is now either white (palette 2), red (1), or black (3). No `.` values anywhere on a face or back tile. The green felt only shows outside the card rectangle, where it should. - **Readable rank glyphs.** The 13 rank tiles (A, 2-9, 10, J, Q, K) are now drawn as bold black strokes on a solid white body. The "holes" of the letters (e.g. the triangle inside an "A") are white, not transparent. - **16x16 big pips.** The big centre pip is now a 4-tile (16x16) shape split into TL/TR/BL/BR quadrants per suit. Previously it was a 2-tile (16x8) half-height strip that looked cramped. The TL/TR quadrants kept their existing tile indices (28-35) so the shift is local; the new BL/BR quadrants are appended after the BIG WAR letters at tiles 88-95 to avoid renumbering the entire alphabet / digits / UI tile range. - **Distinct suit shapes.** Spade is a smooth teardrop with a short stem and base; heart is two symmetric lobes with a V bottom; diamond is a clean rhombus; club is three circles joined over a stem. Side-by-side they are unmistakable. - **Clean checkerboard card back.** The old card back was a diamond lattice that had the same transparent-bleed problem and looked noisy anyway. Replaced with a crisp 2-pixel black- and-white checkerboard that tiles seamlessly across the card back's 16x24 footprint. - **draw_card_face now emits 6 sprites in a rank/suit + 4-tile big-pip layout.** The previous 6-sprite layout was `[rank][ssuit] / [pipL][pipR] / [blankL][blankR]`; the new one is `[rank][ssuit] / [pipTL][pipTR] / [pipBL][pipBR]` with the bottom row carrying the bottom half of the big pip instead of being wasted blank tiles. Also: - New constants TILE_PIP_TL_BASE / TR / BL / BR replace the old TILE_PIP_L_BASE / TILE_PIP_R_BASE in constants.ne. - Refreshed war.nes and the goldens. Every emulator harness test still passes (31/31). https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z |
||
|
|
9137b1f713
|
examples/war: polish pass + README entry + plan close-out
End-of-implementation polish for the War example after the compiler bugs were fixed: - Title state now calls draw_big_war_banner instead of inlining 12 draws — same pixel output, fewer lines. - P_WAR_BURY redraws the previous round's face-up cards while the noise thumps fire so the table doesn't look empty for 24 frames between the WAR banner and the new face-ups. - Drop draw_word_war from render.ne (orphaned by the BIG WAR metasprite). - Refresh comments in background.ne (now references the real felt tile) and deal_state.ne (drop the stale FRAMES_DEAL_STEP reference now that the deal pace is hard-coded at 2 frames). - README.md and examples/README.md gain a war row. - PLAN.md marks every implementation step complete and records the design revisions made along the way. - Refresh the war audio hash to match the new ROM (the title screen helper change shifts one frame of pulse-2 timing enough to flip the FNV-1a). The frame-180 PNG is unchanged. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z |
||
|
|
4e8e349d7c
|
ir: clear wide_hi between functions to fix 16-bit op aliasing
The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z |
||
|
|
8ababdcec4
|
examples/war: working end-to-end War card game
A complete, playable port of the card game War: title screen with 0/1/2 player menu, animated deal, sliding cards, deck-count HUD, a "WAR!" tie-break with buried cards, and a victory screen with a fanfare. Source split across examples/war/*.ne (constants, assets, audio, deck/queue logic, RNG, render helpers, and one state file per game state) and pulled in via examples/war.ne. Drives nearly every NEScript subsystem at once: custom 88-tile sprite sheet (card frames, ranks, suits, font, BIG WAR letters); felt background nametable; pulse-1 / pulse-2 / noise sfx; looping march on pulse 2; an 8-bit Galois LFSR PRNG; queue-based decks that conserve cards across rounds; a phase machine inside the Playing state that handles draw/reveal/win/war/check; and an autopilot that boots straight into 0-PLAYERS mode so the headless jsnes harness captures real gameplay at frame 180. While building this I uncovered five compiler bugs / limitations in the v0.1 implementation; each is documented with a minimal reproduction, root cause, current workaround, and proposed fix in examples/war/COMPILER_BUGS.md. The most painful was the parameter-VarId aliasing one (#1b) — two functions sharing a parameter NAME end up sharing a single zero-page slot mapping across the whole program. Once those compiler bugs are fixed, the workarounds in war/*.ne should be reverted in the same PR. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z |