1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-15 20:58:20 +00:00

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
This commit is contained in:
Claude 2026-04-15 20:33:41 +00:00
parent e10d09db76
commit 76dd8eacb0
No known key found for this signature in database
23 changed files with 762 additions and 412 deletions

View file

@ -2,15 +2,30 @@
This document captures bugs and limitations discovered while
building `examples/war.ne`. Each entry includes a minimal
reproduction, the symptom we observed, the root cause if known,
and a workaround we used in `examples/war/*.ne`. The intent is
to track these so they can be fixed in a future compiler pass —
once they are, the corresponding workarounds in `war/*.ne`
should be reverted to keep the example honest.
reproduction, the symptom we observed, the root cause, the
workaround originally used in `examples/war/*.ne`, and the
compiler fix that shipped (when shipped).
## Status summary
| # | Short name | Status | Fix commit | Regression test |
|---|---|---|---|---|
| 1 | `fun` with > 4 params silently drops the rest | **FIXED** (E0506 diagnostic) | `analyzer: reject functions with more than 4 parameters (E0506)` | `analyze_rejects_function_with_more_than_4_params`, `analyze_accepts_function_with_exactly_4_params` |
| 1b | Same-named params share VarIds across functions | **FIXED** (scope-qualified keys) | `analyzer/ir: scope function locals per function body` | `analyze_allows_same_param_name_in_two_functions` |
| 2 | Param transport slots $04-$07 clobbered by nested calls | **FIXED** (codegen prologue spill) | `codegen: spill parameters from $04-$07 into per-function RAM slots` | `codegen::ir_codegen::gen_function_prologue_spills_params_to_local_ram` |
| 3 | Function-local `var` declarations share one flat namespace | **FIXED** (scope-qualified keys) | `analyzer/ir: scope function locals per function body` | `analyze_allows_same_local_name_in_two_functions`, `analyze_allows_same_local_name_in_two_state_handlers`, `analyze_still_rejects_duplicate_local_in_same_function` |
| 4 | 8-sprites-per-scanline limit invisible to user code | Open (hardware limit; static analyzer hint could help) | — | — |
| 5 | `inline` keyword silently declined for short functions | Open | — | — |
| 6 | `wide_hi` IR map leaked between functions (u16→u8 aliasing) | **FIXED** (cleared per function) | `ir: clear wide_hi between functions to fix 16-bit op aliasing` | `ir::tests::wide_hi_does_not_leak_between_functions` |
**Once a fix lands, revert the workaround in `examples/war/*.ne`
in the same commit** so the example keeps the game honest and
the PR diff visibly proves the fix works end-to-end. Bugs #1,
#1b, #2, #3, and #6 have had their workarounds reverted.
---
## 1. Functions with more than 4 parameters silently corrupt the 5th+
## 1. Functions with more than 4 parameters silently corrupt the 5th+ *(FIXED)*
### Symptom
@ -94,7 +109,7 @@ Two reasonable options:
---
## 1b. Function parameters with the same name in different functions share a VarId, which collides their zero-page slot mapping
## 1b. Function parameters with the same name in different functions share a VarId, which collides their zero-page slot mapping *(FIXED)*
### Symptom
@ -171,24 +186,35 @@ function (see bug #3); we extended the same scheme to params:
`pbb_card` / `dcf_card` snapshots from bug #2 stay because they
also help with the bug-2 clobbering.
### Fix proposal
### Fix
Two layers to fix in:
Both the analyzer and the IR lowerer now qualify function-body
`var` / parameter declarations with the enclosing function name
(or state handler name) under an internal key
`"__local__{scope}__{name}"`. Each function's locals and
parameters therefore get **distinct** symbol-table entries and
VarIds even when the source names collide.
1. **IR lowering**: give every function its own `var_map` for
parameters and locals. The global `var_map` should only hold
top-level `var` / `const` / `enum` symbols.
Lookups inside a function body go through
`Analyzer::resolve_symbol` / `LoweringContext::scoped_key`,
which prefer the scope-qualified key over the bare one — so
a function-local `var x` correctly shadows a same-named global
(or another function's `var x`).
2. **Codegen**: even after the IR fix, the global `var_addrs`
`HashMap` should grow a per-function dimension (one map per
`IrFunction`) so two different functions can independently
assign their own VarIds to overlapping zero-page slots.
State-level locals (declared at `state Foo { var x: u8 }`
outside any handler) stay in the global namespace so every
handler in the state can read/write them across frames.
Either fix alone is probably enough; both together is robust.
See `src/analyzer/mod.rs::resolve_symbol` / `resolve_key` /
`scoped_name` and `src/ir/lowering.rs::scoped_key`.
Together with fix #2 below, bugs #1b and #2 are completely
gone: the workaround-prefixed locals and params in `war/*.ne`
(the `dcf_`, `dwp_`, `pba_`, etc tags) are all reverted.
---
## 2. Function parameters share zero-page slots with nested calls — values clobbered across `JSR`
## 2. Function parameters share zero-page slots with nested calls — values clobbered across `JSR` *(FIXED)*
### Symptom
@ -231,24 +257,38 @@ throughout the body. See `war/render.ne::draw_card_face`,
`war/render.ne::draw_flying_card`, `war/deck.ne::push_back_a`,
`war/deck.ne::push_back_b`.
### Fix proposal
### Fix
1. **Spill on entry**: at the top of every function body that
makes a call, copy `$04..$07` into per-function RAM slots and
rewrite all parameter reads to load from the RAM copies.
Equivalent to what users are doing manually today.
`codegen::ir_codegen::IrCodeGen::new` now allocates every
function-local — including its parameters — into 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 4-instruction
**prologue** at every function entry:
2. **Smarter scheduling**: only spill a parameter slot if it's
live across a call site (CFG-aware liveness pass on params).
Same effect, less RAM cost for short helpers that never read
their params after calling out.
```
LDA $04 ; transport slot 0
STA <param_0_addr>
LDA $05 ; transport slot 1
STA <param_1_addr>
... etc ...
```
Either fix would let users write straightforward function bodies
without having to remember the snapshot dance.
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` (passing its own arguments to _its_ callee)
without corrupting the caller's saved parameters.
The cost is 4 LDA/STA pairs at every function entry (≈ 20
bytes of ROM, 16 cycles). Worth it to make the calling
convention sound.
See `codegen::ir_codegen::gen_function_prologue_spills_params_to_local_ram`
for the regression test.
---
## 3. Function-local variable names are in a flat global namespace
## 3. Function-local variable names are in a flat global namespace *(FIXED)*
### Symptom
@ -302,18 +342,23 @@ identifying its enclosing function (e.g. `dfa_card` in
`dwp_px` in `draw_word_player`). This makes long files harder to
read but is fully mechanical.
### Fix proposal
### Fix
Rework `register_var` to maintain a stack of scopes (one per
function body, one per nested block). Each `Statement::VarDecl`
inserts into the current scope. Lookup walks the stack from
innermost to outermost. The existing global symbol table is
unchanged for top-level globals / consts / fun names; only
function-locals shift to the scoped table.
Same as #1b: the analyzer and IR lowerer now internally
qualify function-body `var` declarations with the enclosing
scope's name, so `foo`'s `var i` and `bar`'s `var i` resolve
to `__local__foo__i` and `__local__bar__i` respectively. The
two entries coexist peacefully in the (still-flat) symbol
table.
A smaller intermediate fix: keep the flat table but qualify
each local's stored name as `<function>::<var>` so the global
table sees unique entries even when source names collide.
What *didn't* change: two `var i` declarations inside the
same function body still collide with E0501 (we scoped per
function body, not per nested block). That's a deliberate
trade-off — per-block scoping would require live-range
analysis to reuse RAM slots across blocks, which is a much
bigger change. The analyzer test
`analyze_still_rejects_duplicate_local_in_same_function`
pins this behaviour.
---

View file

@ -2,28 +2,24 @@
//
// Cards are packed as `(rank << 4) | suit`; the extractors are
// one shift or one mask each.
//
// Parameter names are unique across the entire program — see
// COMPILER_BUGS.md §1b for why same-named params in different
// functions silently corrupt each other through shared VarIds.
inline fun card_rank(crk_c: u8) -> u8 {
return crk_c >> 4
inline fun card_rank(card: u8) -> u8 {
return card >> 4
}
inline fun card_suit(csu_c: u8) -> u8 {
return csu_c & 0x0F
inline fun card_suit(card: u8) -> u8 {
return card & 0x0F
}
// Compare two cards by rank. Returns:
// 1 if A wins, 2 if B wins, 0 if they tie
fun compare_cards(cmp_a: u8, cmp_b: u8) -> u8 {
var cmp_ra: u8 = card_rank(cmp_a)
var cmp_rb: u8 = card_rank(cmp_b)
if cmp_ra > cmp_rb {
// 1 if A wins, 2 if B wins, 0 if they tie.
fun compare_cards(a: u8, b: u8) -> u8 {
var ra: u8 = card_rank(a)
var rb: u8 = card_rank(b)
if ra > rb {
return 1
}
if cmp_rb > cmp_ra {
if rb > ra {
return 2
}
return 0

View file

@ -47,36 +47,36 @@ state Deal {
// deal_next counter controls how many of the 52 have
// "landed", so the first half goes to A and the second
// half to B — matching the actual split_decks() logic.
var dl_dealt_a: u8 = deal_next
var dl_dealt_b: u8 = 0
if dl_dealt_a > HALF_DECK {
dl_dealt_b = dl_dealt_a - HALF_DECK
dl_dealt_a = HALF_DECK
var dealt_a: u8 = deal_next
var dealt_b: u8 = 0
if dealt_a > HALF_DECK {
dealt_b = dealt_a - HALF_DECK
dealt_a = HALF_DECK
}
// Both decks drawn as card backs whenever they have at
// least one card. Before that, skip the draw so the slot
// is empty.
if dl_dealt_a > 0 {
if dealt_a > 0 {
draw_card_back(DECK_A_X, DECK_Y)
}
if dl_dealt_b > 0 {
if dealt_b > 0 {
draw_card_back(DECK_B_X, DECK_Y)
}
draw_count(COUNT_A_X, COUNT_Y, dl_dealt_a)
draw_count(COUNT_B_X, COUNT_Y, dl_dealt_b)
draw_count(COUNT_A_X, COUNT_Y, dealt_a)
draw_count(COUNT_B_X, COUNT_Y, dealt_b)
// ── Flying card ──────────────────────────────────
// A single face-down card bouncing between the centre
// and each deck. The x position alternates based on the
// low bit of deal_next (even → going to A, odd → B).
if deal_next < DECK_SIZE {
var dl_fly_x: u8 = DECK_A_X + 32
var fly_x_pos: u8 = DECK_A_X + 32
if (deal_next & 1) != 0 {
dl_fly_x = DECK_B_X - 32
fly_x_pos = DECK_B_X - 32
}
draw_card_back(dl_fly_x, 96)
draw_card_back(fly_x_pos, 96)
}
// ── Transition ───────────────────────────────────

View file

@ -6,12 +6,6 @@
// the buffer. Everything wraps mod 52 — since 52 isn't a power
// of two, we use `if idx >= 52 { idx -= 52 }` instead of `%` to
// avoid the expensive software mod routine.
//
// NEScript v0.1 has a flat global symbol table for every `var`
// declaration (function-locals included), so each function's
// locals are prefixed with the function's short name to avoid
// E0501 collisions across the program. Function parameters ARE
// scoped per-function, so we can keep their names short.
// ── Helpers shared by every deck ──────────────────────────
//
@ -19,19 +13,11 @@
// needed inside push_back because `front` and `count` are both
// ≤ 51 by construction, so the sum is at most 102 and one
// subtraction is enough.
//
// Every parameter name in this file is unique across the entire
// program. NEScript v0.1's IR lowering uses a single global
// var_map for parameter names, so two functions both named
// `wrap52(v: u8)` and `another(v: u8)` end up sharing a VarId
// and the codegen routes their parameter reads to whichever
// zero-page slot the LAST function to be lowered claimed. See
// COMPILER_BUGS.md §1b for the gory details.
inline fun wrap52(w52_v: u8) -> u8 {
if w52_v >= DECK_SIZE {
return w52_v - DECK_SIZE
inline fun wrap52(v: u8) -> u8 {
if v >= DECK_SIZE {
return v - DECK_SIZE
}
return w52_v
return v
}
// ── deck_a ────────────────────────────────────────────────
@ -44,20 +30,15 @@ fun deck_a_empty() -> u8 {
}
fun draw_front_a() -> u8 {
var dfa_card: u8 = deck_a[deck_a_front]
var card: u8 = deck_a[deck_a_front]
deck_a_front = wrap52(deck_a_front + 1)
deck_a_count -= 1
return dfa_card
return card
}
fun push_back_a(pba_in: u8) {
// Snapshot `pba_in` into a local before calling wrap52,
// because NEScript v0.1's parameter-passing ABI uses fixed
// zero-page slots — wrap52's first param shares slot $04
// with our `pba_in` and would silently clobber it.
var pba_card: u8 = pba_in
var pba_slot: u8 = wrap52(deck_a_front + deck_a_count)
deck_a[pba_slot] = pba_card
fun push_back_a(card: u8) {
var slot: u8 = wrap52(deck_a_front + deck_a_count)
deck_a[slot] = card
deck_a_count += 1
}
@ -71,23 +52,22 @@ fun deck_b_empty() -> u8 {
}
fun draw_front_b() -> u8 {
var dfb_card: u8 = deck_b[deck_b_front]
var card: u8 = deck_b[deck_b_front]
deck_b_front = wrap52(deck_b_front + 1)
deck_b_count -= 1
return dfb_card
return card
}
fun push_back_b(pbb_in: u8) {
var pbb_card: u8 = pbb_in
var pbb_slot: u8 = wrap52(deck_b_front + deck_b_count)
deck_b[pbb_slot] = pbb_card
fun push_back_b(card: u8) {
var slot: u8 = wrap52(deck_b_front + deck_b_count)
deck_b[slot] = card
deck_b_count += 1
}
// ── pot ───────────────────────────────────────────────────
fun push_back_pot(pbp_in: u8) {
pot[pot_count] = pbp_in
fun push_back_pot(card: u8) {
pot[pot_count] = card
pot_count += 1
}
@ -98,19 +78,19 @@ fun clear_pot() {
// Transfer every card currently in the pot into deck_a, in FIFO
// order (so the face-up and face-down cards layer naturally).
fun pot_to_a() {
var pta_i: u8 = 0
while pta_i < pot_count {
push_back_a(pot[pta_i])
pta_i += 1
var i: u8 = 0
while i < pot_count {
push_back_a(pot[i])
i += 1
}
pot_count = 0
}
fun pot_to_b() {
var ptb_i: u8 = 0
while ptb_i < pot_count {
push_back_b(pot[ptb_i])
ptb_i += 1
var i: u8 = 0
while i < pot_count {
push_back_b(pot[i])
i += 1
}
pot_count = 0
}
@ -125,43 +105,42 @@ fun pot_to_b() {
// half, and reset both queues' cursors.
//
// The random-swap shuffle is a bounded alternative to
// Fisher-Yates: it does N swaps between two random indices, where
// each index is rand() & 0x3F (0..63) and the swap is only done
// when both indices are < 52. 200 iterations on a 52-card deck is
// empirically well-mixed and uses only bitwise ops (no multiply,
// no divide, no W0101 warning).
// Fisher-Yates: it does 200 swaps between two random indices,
// where each index is rand() & 0x3F (0..63) and the swap is
// only done when both indices are < 52. Uses only bitwise ops
// (no multiply, no divide).
fun build_master_deck() {
var bmd_r: u8 = 1
var bmd_i: u8 = 0
while bmd_r <= RANK_KING {
var bmd_s: u8 = 0
while bmd_s < 4 {
var r: u8 = 1
var i: u8 = 0
while r <= RANK_KING {
var s: u8 = 0
while s < 4 {
// Pack rank into the high nibble, suit into the low.
// rank fits in 4 bits (max 13) and suit fits in 2
// bits, so the shift-and-or is exact.
var bmd_shifted: u8 = bmd_r << 4
deck_a[bmd_i] = bmd_shifted | bmd_s
bmd_i += 1
bmd_s += 1
var packed: u8 = r << 4
deck_a[i] = packed | s
i += 1
s += 1
}
bmd_r += 1
r += 1
}
}
fun shuffle_deck_a() {
var shf_k: u8 = 0
while shf_k < 200 {
var shf_i: u8 = rand_u8() & 0x3F
var shf_j: u8 = rand_u8() & 0x3F
if shf_i < DECK_SIZE {
if shf_j < DECK_SIZE {
var shf_tmp: u8 = deck_a[shf_i]
deck_a[shf_i] = deck_a[shf_j]
deck_a[shf_j] = shf_tmp
var k: u8 = 0
while k < 200 {
var i: u8 = rand_u8() & 0x3F
var j: u8 = rand_u8() & 0x3F
if i < DECK_SIZE {
if j < DECK_SIZE {
var tmp: u8 = deck_a[i]
deck_a[i] = deck_a[j]
deck_a[j] = tmp
}
}
shf_k += 1
k += 1
}
}
@ -169,10 +148,10 @@ fun shuffle_deck_a() {
// first 26 stay in deck_a, the second 26 move into deck_b.
// Reset both queues' front/count cursors in the process.
fun split_decks() {
var spd_i: u8 = 0
while spd_i < HALF_DECK {
deck_b[spd_i] = deck_a[HALF_DECK + spd_i]
spd_i += 1
var i: u8 = 0
while i < HALF_DECK {
deck_b[i] = deck_a[HALF_DECK + i]
i += 1
}
deck_a_front = 0
deck_a_count = HALF_DECK

View file

@ -3,10 +3,6 @@
// `phase` cycles through the P_* constants defined in
// war/constants.ne. `phase_timer` counts frames inside the
// current phase and is reset to 0 whenever the phase changes.
//
// All function-local var names are prefixed with the function's
// short name (or with `pf_` for "play frame") so the global
// symbol table stays collision-free.
// Set the current phase and zero the timer in one shot. Inlined
// so each call site is just two stores.
@ -30,57 +26,46 @@ fun draw_table() {
}
// Bury helper for a war: move one card from the deck into the
// pot (face-down). Must be called with a non-empty deck. Two
// near-identical helpers (one per side) keep the locals
// uniquely-named.
// pot (face-down). Must be called with a non-empty deck.
fun bury_from_a() {
var bfa_c: u8 = draw_front_a()
push_back_pot(bfa_c)
var c: u8 = draw_front_a()
push_back_pot(c)
}
fun bury_from_b() {
var bfb_c: u8 = draw_front_b()
push_back_pot(bfb_c)
var c: u8 = draw_front_b()
push_back_pot(c)
}
// Draw the BIG WAR banner — three 16x16 metasprites at the
// centre of the screen. 12 sprites total, drawn in the centre
// row so they don't conflict with the deck stacks (rows 64-87)
// or the face-up cards (rows 128-151).
//
// All offsets stepped through locals to dodge the `x + N`
// parameter aliasing bug; see draw_word_player.
// Draw the BIG WAR banner — three 16×16 metasprite letters at
// the centre of the screen. 12 sprites total, drawn in the
// centre row so they don't conflict with the deck stacks (rows
// 64-87) or the face-up cards (rows 128-151).
fun draw_big_war_banner(x: u8, y: u8) {
var bwb_y1: u8 = y + 8
var bwb_x1: u8 = x + 8
var bwb_x2: u8 = x + 20
var bwb_x3: u8 = x + 28
var bwb_x4: u8 = x + 40
var bwb_x5: u8 = x + 48
// BIG W
draw Tileset at: (x, y) frame: TILE_BIG_W_TL
draw Tileset at: (bwb_x1, y) frame: TILE_BIG_W_TR
draw Tileset at: (x, bwb_y1) frame: TILE_BIG_W_BL
draw Tileset at: (bwb_x1, bwb_y1) frame: TILE_BIG_W_BR
draw Tileset at: (x, y) frame: TILE_BIG_W_TL
draw Tileset at: (x + 8, y) frame: TILE_BIG_W_TR
draw Tileset at: (x, y + 8) frame: TILE_BIG_W_BL
draw Tileset at: (x + 8, y + 8) frame: TILE_BIG_W_BR
// BIG A
draw Tileset at: (bwb_x2, y) frame: TILE_BIG_A_TL
draw Tileset at: (bwb_x3, y) frame: TILE_BIG_A_TR
draw Tileset at: (bwb_x2, bwb_y1) frame: TILE_BIG_A_BL
draw Tileset at: (bwb_x3, bwb_y1) frame: TILE_BIG_A_BR
draw Tileset at: (x + 20, y) frame: TILE_BIG_A_TL
draw Tileset at: (x + 28, y) frame: TILE_BIG_A_TR
draw Tileset at: (x + 20, y + 8) frame: TILE_BIG_A_BL
draw Tileset at: (x + 28, y + 8) frame: TILE_BIG_A_BR
// BIG R
draw Tileset at: (bwb_x4, y) frame: TILE_BIG_R_TL
draw Tileset at: (bwb_x5, y) frame: TILE_BIG_R_TR
draw Tileset at: (bwb_x4, bwb_y1) frame: TILE_BIG_R_BL
draw Tileset at: (bwb_x5, bwb_y1) frame: TILE_BIG_R_BR
draw Tileset at: (x + 40, y) frame: TILE_BIG_R_TL
draw Tileset at: (x + 48, y) frame: TILE_BIG_R_TR
draw Tileset at: (x + 40, y + 8) frame: TILE_BIG_R_BL
draw Tileset at: (x + 48, y + 8) frame: TILE_BIG_R_BR
}
// Begin the A-side draw animation: pull the top card off deck_a,
// stash it as the face-up `card_a`, arm the fly state for the
// deck → play slide, and play the click sfx.
//
// fly_card / fly_face_up are stuffed directly into globals
// instead of being passed to arm_fly, because arm_fly only takes
// 4 params (the v0.1 ABI limit) and silently drops anything past
// the fourth.
// fly_card / fly_face_up are written directly to globals
// instead of being passed to arm_fly, because arm_fly already
// takes 4 parameters and the v0.1 ABI caps function signatures
// at 4 parameters (E0506).
fun begin_draw_a() {
if deck_a_count > 0 {
card_a = draw_front_a()
@ -189,16 +174,16 @@ state Playing {
// Both cards go into the pot regardless of outcome.
push_back_pot(card_a)
push_back_pot(card_b)
pf_result = compare_cards(card_a, card_b)
if pf_result == 1 {
var result: u8 = compare_cards(card_a, card_b)
if result == 1 {
play CheerA
set_phase(P_WIN_A)
}
if pf_result == 2 {
if result == 2 {
play CheerB
set_phase(P_WIN_B)
}
if pf_result == 0 {
if result == 0 {
// It's a tie — but only enter the war flow if both
// sides actually have cards left to bury. If a
// player ran out of cards on this very tie, the

View file

@ -6,10 +6,6 @@
// a 16×24 card face burns 6 sprite slots, a single-character
// letter burns 1, and so on. The caller is responsible for
// sprite budgeting — see PLAN.md §3.
//
// Function-local var names are prefixed with the function's
// short name to avoid the global symbol-table collisions that
// E0501 would otherwise complain about.
// ── Card face ──────────────────────────────────────────────
//
@ -22,69 +18,46 @@
//
// Every tile in the card has a fully-opaque white background
// (palette index 2) so the felt green behind the card does not
// bleed through — see `assets.ne` for the art itself. The big
// centre pip is a single 16×16 shape split across the bottom
// two rows (4 tiles total); `rank` sits in the top-left corner
// and `small_suit` in the top-right.
fun draw_card_face(dcf_in_x: u8, dcf_in_y: u8, dcf_in_card: u8) {
// Snapshot every parameter into a fresh local *before* any
// nested function call. NEScript v0.1 passes parameters via
// fixed zero-page slots ($04, $05, $06), and any inner call
// overwrites those slots with its own parameter values —
// including the inline `card_rank` / `card_suit` calls below,
// which would otherwise leave `x` and `y` corrupted by the
// time the draw lines run.
var dcf_x: u8 = dcf_in_x
var dcf_y: u8 = dcf_in_y
var dcf_card: u8 = dcf_in_card
var dcf_rank: u8 = card_rank(dcf_card)
var dcf_suit: u8 = card_suit(dcf_card)
var dcf_rank_tile: u8 = TILE_RANK_BASE + dcf_rank - 1
var dcf_small_tile: u8 = TILE_SUIT_SMALL_BASE + dcf_suit
var dcf_pip_tl: u8 = TILE_PIP_TL_BASE + dcf_suit
var dcf_pip_tr: u8 = TILE_PIP_TR_BASE + dcf_suit
var dcf_pip_bl: u8 = TILE_PIP_BL_BASE + dcf_suit
var dcf_pip_br: u8 = TILE_PIP_BR_BASE + dcf_suit
var dcf_x1: u8 = dcf_x + 8
var dcf_y1: u8 = dcf_y + 8
var dcf_y2: u8 = dcf_y + 16
// bleed through — see `assets.ne` for the art itself.
fun draw_card_face(x: u8, y: u8, card: u8) {
var rank: u8 = card_rank(card)
var suit: u8 = card_suit(card)
var rank_tile: u8 = TILE_RANK_BASE + rank - 1
var small_tile: u8 = TILE_SUIT_SMALL_BASE + suit
var pip_tl: u8 = TILE_PIP_TL_BASE + suit
var pip_tr: u8 = TILE_PIP_TR_BASE + suit
var pip_bl: u8 = TILE_PIP_BL_BASE + suit
var pip_br: u8 = TILE_PIP_BR_BASE + suit
// Row 0 — rank corner + small suit
draw Tileset at: (dcf_x, dcf_y) frame: dcf_rank_tile
draw Tileset at: (dcf_x1, dcf_y) frame: dcf_small_tile
draw Tileset at: (x, y) frame: rank_tile
draw Tileset at: (x + 8, y) frame: small_tile
// Row 1 — top half of the 16×16 big pip
draw Tileset at: (dcf_x, dcf_y1) frame: dcf_pip_tl
draw Tileset at: (dcf_x1, dcf_y1) frame: dcf_pip_tr
draw Tileset at: (x, y + 8) frame: pip_tl
draw Tileset at: (x + 8, y + 8) frame: pip_tr
// Row 2 — bottom half of the 16×16 big pip
draw Tileset at: (dcf_x, dcf_y2) frame: dcf_pip_bl
draw Tileset at: (dcf_x1, dcf_y2) frame: dcf_pip_br
draw Tileset at: (x, y + 16) frame: pip_bl
draw Tileset at: (x + 8, y + 16) frame: pip_br
}
// Draw the card-back lattice at (x, y). 6 sprites again. Rows
// 0 and 2 reuse the same top/bottom back tiles; row 1 uses the
// bottom row of the lattice as a filler so the pattern stays
// continuous.
// Draw the card-back checkerboard at (x, y). 6 sprites. The
// back tiles tile seamlessly so every cell of the 16×24 card
// body carries a 2-pixel square of the same black/white grid.
fun draw_card_back(x: u8, y: u8) {
var dcb_x1: u8 = x + 8
var dcb_y1: u8 = y + 8
var dcb_y2: u8 = y + 16
draw Tileset at: (x, y) frame: TILE_BACK_TL
draw Tileset at: (dcb_x1, y) frame: TILE_BACK_TR
draw Tileset at: (x, dcb_y1) frame: TILE_BACK_BL
draw Tileset at: (dcb_x1, dcb_y1) frame: TILE_BACK_BR
draw Tileset at: (x, dcb_y2) frame: TILE_BACK_TL
draw Tileset at: (dcb_x1, dcb_y2) frame: TILE_BACK_TR
draw Tileset at: (x, y) frame: TILE_BACK_TL
draw Tileset at: (x + 8, y) frame: TILE_BACK_TR
draw Tileset at: (x, y + 8) frame: TILE_BACK_BL
draw Tileset at: (x + 8, y + 8) frame: TILE_BACK_BR
draw Tileset at: (x, y + 16) frame: TILE_BACK_TL
draw Tileset at: (x + 8, y + 16) frame: TILE_BACK_TR
}
// Dispatch between front and back based on the fly_face_up flag
// stamped by the phase handlers. Snapshots params first because
// the inner call clobbers $04/$05.
// stamped by the phase handlers.
fun draw_flying_card(x: u8, y: u8) {
var dfc_x: u8 = x
var dfc_y: u8 = y
if fly_face_up == 0 {
draw_card_back(dfc_x, dfc_y)
draw_card_back(x, y)
} else {
draw_card_face(dfc_x, dfc_y, fly_card)
draw_card_face(x, y, fly_card)
}
}
@ -95,91 +68,62 @@ fun draw_digit(x: u8, y: u8, d: u8) {
draw Tileset at: (x, y) frame: TILE_DIGIT_BASE + d
}
// Draw a two-digit decimal count (0..99) at (x, y). Leading zero
// is preserved so the HUD always renders two glyphs wide, which
// keeps the layout stable as the counts change.
// Draw a two-digit decimal count (0..99) at (x, y). Leading
// zero is preserved so the HUD always renders two glyphs wide,
// which keeps the layout stable as the counts change.
fun draw_count(x: u8, y: u8, v: u8) {
var dct_tens: u8 = 0
var dct_n: u8 = v
var tens: u8 = 0
var n: u8 = v
// Divide by 10 without the software divide: repeatedly
// subtract 10 until n < 10. For v ≤ 52 this loops at most
// 5 times, cheaper than calling `/` and `%`.
while dct_n >= 10 {
dct_n -= 10
dct_tens += 1
while n >= 10 {
n -= 10
tens += 1
}
draw_digit(x, y, dct_tens)
draw_digit(x + 8, y, dct_n)
draw_digit(x, y, tens)
draw_digit(x + 8, y, n)
}
// Draw a single letter 'A'..'Z' using the sprite font. `ch` is
// the letter index (0 = A, 25 = Z). Deliberately NOT marked
// `inline`: when this was inlined the resulting code put each
// inlined `draw` at double the intended X step (the inliner
// appears to re-evaluate the (x + N) parameter expression in a
// way that compounds across consecutive draws). Keeping it as
// a real function call gives every draw_letter call its own
// argument-evaluation context and the spacing comes out right.
// the letter index (0 = A, 25 = Z).
fun draw_letter(x: u8, y: u8, ch: u8) {
draw Tileset at: (x, y) frame: TILE_LETTER_BASE + ch
}
// Short helper for drawing the word "PLAYER" at (x, y). 6 letters.
// Used by the HUD and the victory banner.
//
// We accumulate the X position in a local instead of passing
// `x + N` as a call argument: the latter pattern miscompiles in
// NEScript v0.1 (consecutive `x + N` arguments to the same
// function appear to alias the parameter slot, leaving the second
// onward call site reading a stale offset). Stepping a local
// avoids the issue entirely.
fun draw_word_player(x: u8, y: u8) {
var dwp_px: u8 = x
draw_letter(dwp_px, y, 15) // P
dwp_px += 8
draw_letter(dwp_px, y, 11) // L
dwp_px += 8
draw_letter(dwp_px, y, 0) // A
dwp_px += 8
draw_letter(dwp_px, y, 24) // Y
dwp_px += 8
draw_letter(dwp_px, y, 4) // E
dwp_px += 8
draw_letter(dwp_px, y, 17) // R
draw_letter(x, y, 15) // P
draw_letter(x + 8, y, 11) // L
draw_letter(x + 16, y, 0) // A
draw_letter(x + 24, y, 24) // Y
draw_letter(x + 32, y, 4) // E
draw_letter(x + 40, y, 17) // R
}
// "WINS" — used on the victory screen.
fun draw_word_wins(x: u8, y: u8) {
var dww_px: u8 = x
draw_letter(dww_px, y, 22) // W
dww_px += 8
draw_letter(dww_px, y, 8) // I
dww_px += 8
draw_letter(dww_px, y, 13) // N
dww_px += 8
draw_letter(dww_px, y, 18) // S
draw_letter(x, y, 22) // W
draw_letter(x + 8, y, 8) // I
draw_letter(x + 16, y, 13) // N
draw_letter(x + 24, y, 18) // S
}
// "PRESS" — used on the title screen.
fun draw_word_press(x: u8, y: u8) {
var dwr_px: u8 = x
draw_letter(dwr_px, y, 15) // P
dwr_px += 8
draw_letter(dwr_px, y, 17) // R
dwr_px += 8
draw_letter(dwr_px, y, 4) // E
dwr_px += 8
draw_letter(dwr_px, y, 18) // S
dwr_px += 8
draw_letter(dwr_px, y, 18) // S
draw_letter(x, y, 15) // P
draw_letter(x + 8, y, 17) // R
draw_letter(x + 16, y, 4) // E
draw_letter(x + 24, y, 18) // S
draw_letter(x + 32, y, 18) // S
}
// ── Fly animation driver ──────────────────────────────────
//
// We avoid the software multiply (and the W0101 "expensive
// multiply" warning) by stepping the fly position by a fixed
// constant each frame instead of computing `start + dx * t /
// FRAMES`. The constant FLY_STEP is chosen so that
// Avoiding a software multiply: instead of computing
// `start + dx * t / FRAMES` we step the fly position by a
// fixed constant each frame. FLY_STEP is chosen so that
// FRAMES_FLY * FLY_STEP equals the deck-to-play distance on
// both axes:
//
@ -192,8 +136,8 @@ const FLY_STEP: u8 = 4
// Step the global (fly_x, fly_y) by FLY_STEP in the directions
// stored in fly_dx_sign / fly_dy_sign. Sign 0 = positive (move
// right / down), 1 = negative (move left / up). Called once per
// frame inside the relevant fly-phase handler.
// right / down), 1 = negative (move left / up). Called once
// per frame inside the relevant fly-phase handler.
fun step_fly_pos() {
if fly_dx_sign == 0 {
fly_x += FLY_STEP
@ -208,11 +152,9 @@ fun step_fly_pos() {
}
// Initialise the fly state for a card slide from (sx, sy) using
// the given direction signs. NEScript v0.1 only allocates four
// zero-page slots ($04-$07) for function parameters, so callers
// must stash `fly_card` and `fly_face_up` directly into the
// globals before invoking arm_fly — passing them as the 5th and
// 6th params would silently drop the values on the floor.
// the given direction signs, card byte, and face-up flag. The
// caller is responsible for picking signs so the end position
// lands where it should after FRAMES_FLY frames.
fun arm_fly(sx: u8, sy: u8, dxsign: u8, dysign: u8) {
fly_x = sx
fly_y = sy

View file

@ -40,7 +40,6 @@ 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
var pf_result: u8 = 0 // P_RESOLVE comparison result (1=A, 2=B, 0=tie)
// ── Animation state ───────────────────────────────────────
// Shared by every card-fly phase. We step (fly_x, fly_y) by a

View file

@ -34,17 +34,21 @@ state Victory {
// deck is empty (shouldn't happen — we only arrive
// here because the *loser* is empty) fall back to a
// card back so the layout is stable.
// Pull the winner's top card into a single local. Only
// one `var top` declaration per on-frame body — NEScript
// scopes locals per function body, not per if-block.
var top: u8 = 0
if winner == 0 {
if deck_a_count > 0 {
var vic_top_a: u8 = deck_a[deck_a_front]
draw_card_face(120, 128, vic_top_a)
top = deck_a[deck_a_front]
draw_card_face(120, 128, top)
} else {
draw_card_back(120, 128)
}
} else {
if deck_b_count > 0 {
var vic_top_b: u8 = deck_b[deck_b_front]
draw_card_face(120, 128, vic_top_b)
top = deck_b[deck_b_front]
draw_card_face(120, 128, top)
} else {
draw_card_back(120, 128)
}