mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 17:06:04 +00:00
8 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
169a481099
|
feat(platformer): add stomp-or-die enemy collisions, live HUD, GameOver state
The previous platformer example drew enemies but had almost no interaction with them: only enemy 1 had a stomp check, the stomp window was unreachable under the default +1-px-per-frame-plus-a- jump-every-40-frames autopilot, contact from any other angle was a silent no-op, and the header comment promised a "title → playing → game-over state machine" that didn't actually exist. The README demo gif and the committed golden both froze that state — a level the player could walk through indefinitely with no consequence. Flesh the enemy interaction model out into something real: - `resolve_enemy_hit(e_sx)`: one helper, called symmetrically for both enemies. Computes the player/enemy hitbox overlap (horizontal in `e_sx ∈ (72, 96)`, vertical in `player_y ∈ (152, 176)`) and branches three ways — falling onto the head is a stomp bounce (`rise_count = 6`, `fall_vy = 0`, `stomp_count += 1`, `play Boing`); overlap while `rise_count > 0` is a grace pass-through so the stomp bounce itself can't retrigger contact on the same enemy; anything else (walking into the side, standing on the ground against the enemy) is fatal — `alive = 0` and `play hit`. - New `GameOver` state: draws four enemy tiles across the middle of the screen plus a coin row sized to `stomp_count`, stops the music, lingers 60 frames then auto-retries, and also honours Start for an instant retry. - Proximity-based autopilot: pre-jump when an enemy is exactly 19 px ahead (`e1_sx == 99` or `e2_sx == 99`), capped at two jumps per life by `auto_jumps < AUTOPILOT_JUMPS`. Tuning: a JUMP_RISE=12, GRAVITY_CAP=4 jump lands the player's feet at enemy-head height exactly 21 frames after lift-off, by which point the autopilot camera has scrolled the enemy under the player. The first jump fires on Playing frame 1 and stomps enemy 1 on frame 22; the second fires on Playing frame 101 and stomps enemy 2 on frame 122. After that the autopilot is exhausted and the third enemy encounter (camera wraps back past enemy 1) is fatal — the golden harness now sees the full stomp, stomp, die, retry, stomp loop instead of a frozen walk. - Live HUD: up to four coin sprites in the top-left, one per stomp, rendered both during `Playing` and on the `GameOver` screen so the score is visible in the death frame. `Playing`'s player draw is now guarded by `if alive == 1` so the hero disappears on the fatal-contact frame and the enemy that killed them is visible underneath. Verified with a per-frame ZP trace through the patched puppeteer + jsnes harness: first stomp at emu frame 44 (camera_x=22), second at emu frame 144 (camera_x=122), death at emu frame 283 (camera_x=5 after a 256-px wrap), `Playing` restart at emu frame 343, third stomp at emu frame 365. All 22 emulator goldens still match after the update, and `docs/platformer.gif` regenerated from the new ROM now shows two clean stomps, a clean side-collision death, the GameOver screen, and the retry cycle all inside the 6-second demo window. Golden updates: - `tests/emulator/goldens/platformer.png` — the frame-180 capture now shows the hero walking forward with a two-coin HUD after both autopilot stomps (previously: a frozen bouncing hero). - `tests/emulator/goldens/platformer.audio.hash` — the track now includes two `Boing` stomp bounces, which shifts the hash. - `examples/platformer.nes` — rebuilt from the rewritten source. Also updates the platformer rows in `README.md` and `examples/README.md` to match the new gameplay. https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi |
||
|
|
629fdcfce0
|
fix(optimizer): preserve cross-block LoadImm uses in const_fold DCE
`const_fold_block`'s per-block dead-code pass was collecting temp usage from only the block it was folding, so a `LoadImm` whose destination is consumed by a *sibling* block (for example via the merge block's branch terminator) was incorrectly treated as dead and dropped. The `and` / `or` short-circuit lowering emits exactly that shape: the false path writes `LoadImm(result, 0)` and joins with the right path at an `and_end` / `or_end` block whose branch terminator reads `result`. After the DCE the false path's store was gone, leaving the zero-page result slot to carry whatever value the *previous* `and` / `or` evaluation had written there — stale data that bled into subsequent conditional branches. I found this while instrumenting `examples/platformer.ne` through a puppeteer-driven jsnes harness, stepping one frame at a time and snapshotting the full zero-page trace of each scenario (title-skip, hold-right, hold-left, jump-spam, coin-drift, enemy-stomp, long-run). In a clean idle run the enemy-1 stomp bounce (`rise_count = 6`, `fall_vy = 0`) fired at emulator frames 83 and 96 with `camera_x` = 61 and 74, i.e. with `e1_sx` = 39 and 26, nowhere near the intended `[72, 96)` pickup window. The trigger turned out to be the slot alias: every time `c2_sx` landed in its pickup window (so the coin-2 `and` stored 1 into ZP(130)) and the player was mid-fall at or past `player_y = 152`, the enemy-1 stomp `and` short-circuited to its false path, left ZP(130) at 1, and the stomp `if` fired on stale data. The fix is to compute function-wide source-operand usage once before folding each function's blocks and OR it into the per-block liveness check, so a LoadImm is only dropped if nobody — neither its own block nor any other block in the function — reads the temp. Added a regression test (`const_fold_preserves_loadimm_used_by_sibling_branch`) that builds the exact CFG shape the `and` lowering emits and verifies the false-path `LoadImm(result, 0)` survives optimization. Impact on the example ROMs: - `examples/platformer.nes`: enemy-1 stomp now fires only when `e1_sx ∈ [72, 96)`, as the source intends. The pixel golden is unchanged (`player_y` converges back to the ground line before frame 180), but the audio hash flips because the spurious `play hit` sfx calls during coin-2 passage are gone. Committing the new `tests/emulator/goldens/platformer.audio.hash`. - `examples/logic_ops.nes`, `examples/bitwise_ops.nes`, `examples/match_demo.nes`, `examples/mmc3_per_state_split.nes`, `examples/two_player.nes`: byte-different but observably unchanged — their pixel + audio goldens still match to the byte. They exercise `and` / `or` in the source and now compile through the corrected DCE. All other example ROMs are byte-identical to pre-fix. `cargo fmt`, `cargo clippy --all-targets`, `cargo test --release` (498 tests), and `tests/emulator/run_examples.mjs` (22/22 goldens) are clean. https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi |
||
|
|
57faf9e36a
|
commit built ROMs alongside .ne sources
The compiler is deterministic: rebuilding any example produces a byte-identical ROM, verified across all 22 examples and all four mappers (NROM, MMC1, UxROM, MMC3). That means the .nes files are reproducible artefacts and can live next to their sources without drift. Benefits: - Users can clone the repo and open any example in an emulator without installing a Rust toolchain or running the compiler. - The emulator harness can trust examples/*.nes directly, so its CI job no longer needs a compiler build or a "compile all examples" loop — it just boots jsnes against the committed ROMs and diffs each against its golden. - ROM diffs in PRs are now meaningful: "this compiler change flipped 17 bytes in hello_sprite.nes" is visible review signal, not hidden behind the emulator golden. Guard rails so the ROMs don't drift from their sources: - .gitignore no longer excludes *.nes. - The `examples` CI job rebuilds every .ne into /tmp and fails loudly (with a GitHub error annotation pointing at the exact cargo command to rerun) if any committed ROM differs. - scripts/pre-commit does the same check locally. - CLAUDE.md now states that editing a .ne file requires rebuilding its .nes in the same commit, so future agents won't miss the invariant. Total footprint: 22 ROMs, 624 KB (avg 28 KB each — most are NROM 24 KB; two banked examples are larger). https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A |