1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00
nescript/examples/war/state.ne
Claude 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
2026-04-15 20:33:41 +00:00

82 lines
4.2 KiB
Text

// war/state.ne — every mutable variable the game touches.
//
// NEScript allocates top-level `var` declarations in general RAM
// (the analyzer places them starting at $10 / $18 depending on
// whether the program declares background/palette updates). We
// keep everything global so helper functions can read and write
// without having to take parameters and return values — the
// 6502's 8-register ABI makes every extra parameter an extra
// LDA/STA pair.
// ── Decks + pot ───────────────────────────────────────────
// Each deck is a circular buffer backed by a 52-byte array.
// `*_front` is the index of the next card to draw; `*_count` is
// the number of cards currently in the buffer. Both fields wrap
// mod 52 — there's a helper for that in war/deck.ne.
var deck_a: u8[52]
var deck_a_front: u8 = 0
var deck_a_count: u8 = 0
var deck_b: u8[52]
var deck_b_front: u8 = 0
var deck_b_count: u8 = 0
// Pot holds every card currently in play (the normal-round draws
// and, during a war, the face-down buries too). Drained into the
// winner's deck after each round resolves.
var pot: u8[52]
var pot_count: u8 = 0
// ── The two cards showing face-up this round ─────────────
// These are the packed rank/suit bytes drawn from each deck at
// the start of a round. During a war, the old face-up cards get
// pushed into the pot and new values land in these slots.
var card_a: u8 = 0
var card_b: u8 = 0
// ── Game mode + phase machine ─────────────────────────────
var mode: u8 = 0 // 0/1/2 players
var a_is_cpu: u8 = 1 // bool-ish
var b_is_cpu: u8 = 1
var phase: u8 = 0 // one of the P_* constants
var phase_timer: u8 = 0 // counts up during each phase
// ── Animation state ───────────────────────────────────────
// Shared by every card-fly phase. We step (fly_x, fly_y) by a
// constant FLY_STEP per frame in the directions encoded in
// fly_dx_sign / fly_dy_sign. The screen layout is arranged so
// that FRAMES_FLY * FLY_STEP exactly matches the deck-to-play
// distance on both axes — see render.ne for the math.
var fly_x: u8 = 0
var fly_y: u8 = 0
var fly_dx_sign: u8 = 0 // 0 = +FLY_STEP / 1 = -FLY_STEP
var fly_dy_sign: u8 = 0
var fly_card: u8 = 0 // packed rank/suit to show during the fly
var fly_face_up: u8 = 1 // 0 = show card back, 1 = show face
// ── Title menu ────────────────────────────────────────────
var title_cursor: u8 = 1 // menu index (0/1/2) — default "1 PLAYER"
var title_timer: u8 = 0 // auto-advance counter
var title_debounce: u8 = 0 // menu input debounce
var title_blink: u8 = 0 // "PRESS A" blink counter
// ── Deal state ────────────────────────────────────────────
var deal_next: u8 = 0 // next card index to deal
var deal_timer: u8 = 0
// ── Victory state ─────────────────────────────────────────
var winner: u8 = 0 // 0 = A wins, 1 = B wins
var victory_timer: u8 = 0
// ── RNG ──────────────────────────────────────────────────
// 8-bit Galois LFSR state. Seeded from the free-running title
// frame counter at the moment the title screen transitions to
// Deal, so the shuffle is deterministic once the user commits
// to starting a game. The jsnes headless harness always starts
// at frame 45, so the seed is stable there too.
var rng_state: u8 = 0xA7
// ── Global free-running frame counter ────────────────────
// Used by any state that needs a coarse "which frame are we
// on" read without installing its own counter.
var global_tick: u16 = 0