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
14 KiB
NEScript v0.1 — Compiler Bugs and Limitations Found While Building War
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.
1. Functions with more than 4 parameters silently corrupt the 5th+
Symptom
Calling a function with 5 or 6 parameters compiles cleanly, with no warning or error, but at runtime the 5th and 6th parameter values are silently replaced by garbage (typically the value of parameter 3 or 4). Animations and state writes that depend on those parameters behave as if zero was passed.
Reproduction
fun arm_fly(sx: u8, sy: u8, dxsign: u8, dysign: u8, card: u8, fu: u8) {
fly_x = sx
fly_y = sy
fly_dx_sign = dxsign
fly_dy_sign = dysign
fly_card = card // gets the value of dxsign instead!
fly_face_up = fu // gets the value of dxsign instead!
}
fun caller() {
arm_fly(32, 64, 0, 0, 147, 1)
// After this call:
// fly_x = 32, fly_y = 64, fly_dx_sign = 0, fly_dy_sign = 0
// fly_card = 0 (NOT 147)
// fly_face_up = 0 (NOT 1)
}
Root cause
src/codegen/ir_codegen.rs (around line 240) iterates through
func.locals and assigns the first 4 entries to zero-page
parameter slots $04-$07:
for func in &ir.functions {
for (i, local) in func.locals.iter().enumerate() {
if i < func.param_count {
if i < 4 {
var_addrs.insert(local.var_id, 0x04 + i as u16);
...
}
} else {
...
}
}
}
The if i < 4 guard silently drops the mapping for params 5+
without inserting any RAM allocation for them. The corresponding
caller-side codegen for Call writes only the first four
arguments. Result: params 5 and 6 are never passed and the
callee reads stale memory from $04-$07 in their place.
Workaround used in examples/war/
arm_fly is split: the four "arming" parameters stay in the
function signature, and fly_card / fly_face_up are written to
the global state directly at every call site instead. See
war/play_state.ne (begin_draw_a / begin_draw_b).
Fix proposal
Two reasonable options:
-
Diagnose-only: emit
E05XX too many parameterswhen afundeclaration has more than 4 params. This is the smallest possible change and turns silent miscompiles into a loud compile-time error. Should ship immediately even if option 2 is also planned. -
Spill to RAM: extend the calling convention so params beyond the first four are passed via dedicated RAM slots in the callee's local frame. The caller-side
Callcodegen would write those slots beforeJSR, the callee-side prologue could leave them as-is. This grows the per-function RAM footprint but lets users write any signature they like.
1b. Function parameters with the same name in different functions share a VarId, which collides their zero-page slot mapping
Symptom
Two unrelated functions whose parameters happen to be named the
same (e.g. both have a card: u8 parameter, or both have an
x: u8 parameter) end up reading parameters from the wrong
zero-page slot at runtime. One function reads $04, another
reads $06, a third reads $05 — depending on the parameter's
position in whichever function is processed last by the
codegen.
This is a much sneakier sibling of bug #1: rather than dropping a parameter past the 4th slot, it silently reroutes parameter reads to slots that hold completely unrelated values from the caller.
Reproduction
// Function A: card is the 1st parameter, expected at $04
fun push_back_a(card: u8) {
deck_a[deck_a_front] = card // reads from $06, not $04!
deck_a_count += 1
}
// Function B: card is the 3rd parameter, expected at $06
fun draw_card_face(x: u8, y: u8, card: u8) {
// ... uses card normally ...
}
The IR lowering assigns card a single shared VarId because
its var_map is global across all functions. The codegen then
walks each function in turn, inserting (VarId(card), $0X)
mappings into a single global var_addrs HashMap — and
whichever function comes last in iteration order wins the
mapping. If draw_card_face is processed after push_back_a,
VarId(card) ends up mapped to $06, and push_back_a then
reads its card parameter from $06 (which holds whatever the
caller was using as a third argument — typically junk).
Root cause
src/ir/lowering.rs::get_or_create_var looks up names in
self.var_map, which is shared across the whole program:
fn get_or_create_var(&mut self, name: &str) -> VarId {
if let Some(&id) = self.var_map.get(name) {
id
} else {
let id = VarId(self.next_var_id);
self.next_var_id += 1;
self.var_map.insert(name.to_string(), id);
id
}
}
lower_function calls get_or_create_var(¶m.name) for each
parameter, so two different functions both with a card
parameter resolve to the same VarId. Once that single VarId
flows into the codegen, the per-function "this is param index N
of function F" relationship is lost — there's only one global
mapping per VarId.
Workaround used in examples/war/
Every parameter name in the war source is unique across the
entire program. Function-locals were already prefixed by
function (see bug #3); we extended the same scheme to params:
push_back_a(pba_arg_card: u8) instead of
push_back_a(card: u8), etc. The wrapping pba_card /
pbb_card / dcf_card snapshots from bug #2 stay because they
also help with the bug-2 clobbering.
Fix proposal
Two layers to fix in:
-
IR lowering: give every function its own
var_mapfor parameters and locals. The globalvar_mapshould only hold top-levelvar/const/enumsymbols. -
Codegen: even after the IR fix, the global
var_addrsHashMapshould grow a per-function dimension (one map perIrFunction) so two different functions can independently assign their own VarIds to overlapping zero-page slots.
Either fix alone is probably enough; both together is robust.
2. Function parameters share zero-page slots with nested calls — values clobbered across JSR
Symptom
A function that takes parameters and then calls another function sees its own parameters silently replaced by the inner call's arguments. Any code path that reads the original parameter after the inner call gets the wrong value.
Reproduction
fun draw_card_face(x: u8, y: u8, card: u8) {
var rank: u8 = card_rank(card) // x at $04 is now `card`
var suit: u8 = card_suit(card) // x at $04 is still `card`
// x is supposed to be 120 here, but it's actually `card`
var x1: u8 = x + 8 // computes card + 8, not 120 + 8
draw Tileset at: (x, y) frame: ... // draws at x = card, not 120
}
Concretely, calling draw_card_face(120, 128, 0x93) puts the
card sprite at (0x93, 128) — completely wrong.
Root cause
Same allocator as bug #1: func.locals[0..param_count] are
mapped to $04, $05, $06, $07. The caller writes its own
arguments into the same zero-page slots before JSR, so the
caller's parameters at those slots get clobbered by the callee's
arguments. There is no save/restore wrapper around JSR and no
spill/reload pass to refresh the caller's parameters from a
backing copy.
Workaround used in examples/war/
Every helper that takes parameters AND makes any nested function
call snapshots its parameters into fresh local variables at the
top of the function, then references the locals exclusively
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
-
Spill on entry: at the top of every function body that makes a call, copy
$04..$07into per-function RAM slots and rewrite all parameter reads to load from the RAM copies. Equivalent to what users are doing manually today. -
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.
Either fix would let users write straightforward function bodies without having to remember the snapshot dance.
3. Function-local variable names are in a flat global namespace
Symptom
Two different functions cannot declare locals with the same
name. The compiler emits E0501 duplicate declaration of '<name>'
even though the locals are in disjoint scopes.
Reproduction
fun foo() {
var i: u8 = 0
while i < 10 { i += 1 }
}
fun bar() {
var i: u8 = 0 // E0501 duplicate declaration of 'i'
while i < 5 { i += 1 }
}
Root cause
src/analyzer/mod.rs::register_var inserts every var
declaration into a single self.symbols map keyed only on the
variable's name, with no qualification by function or block:
fn register_var(&mut self, var: &VarDecl) {
if self.symbols.contains_key(&var.name) {
self.diagnostics.push(Diagnostic::error(
ErrorCode::E0501,
format!("duplicate declaration of '{}'", var.name),
var.span,
));
return;
}
...
}
check_statement calls register_var for every Statement::VarDecl
encountered while walking function bodies, so all locals across
all functions and all nested blocks land in the same namespace.
Workaround used in examples/war/
Every function-local variable is prefixed with a short tag
identifying its enclosing function (e.g. dfa_card in
draw_front_a, pba_slot in push_back_a,
dwp_px in draw_word_player). This makes long files harder to
read but is fully mechanical.
Fix proposal
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.
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.
4. Per-frame sprite-per-scanline limit is invisible to user code
Symptom
Drawing more than 8 sprites whose Y rectangles intersect a single scanline causes the NES PPU to silently drop the excess sprites past the 8th in OAM order. There's no compile-time detection and no runtime warning — letters or tiles just don't render.
Reproduction
// 9 letters all on the same Y row:
draw_letter(0, 100, 0)
draw_letter(8, 100, 1)
draw_letter(16, 100, 2)
draw_letter(24, 100, 3)
draw_letter(32, 100, 4)
draw_letter(40, 100, 5)
draw_letter(48, 100, 6)
draw_letter(56, 100, 7)
draw_letter(64, 100, 8) // this one will not render
Root cause
This is a real NES hardware constraint, not a compiler bug.
However, because NEScript's draw allocator is purely
sequential, the compiler cannot warn even when it has all the
information needed to know the layout would overflow.
Workaround used in examples/war/
We staggered text rows. The title screen's "WAR / CARD GAME / 0 PLAYER / 1 PLAYER / 2 PLAYER" layout sits each row at a different y so no scanline carries more than 7 sprites; the victory screen's "PLAYER X / WINS" wraps after the player letter for the same reason.
Fix proposal
Two complementary improvements:
-
Static analyzer pass: walk the IR for each frame handler, collect the set of
(x, y)literal pairs feedingdrawops within the same basic block, and emitW01XXif any scanline (8-px row) would have > 8 sprites. Only catches the literal case but that's the most common. -
Sprite-cycling runtime helper: a
cycle_sprites()intrinsic that rotates OAM order each frame so the same sprites get dropped on different frames, producing a flicker instead of a permanent dropout. Standard NES technique.
5. The inline keyword is a hint and is silently ignored for short functions
Symptom
Marking a tiny function inline fun does not always inline it.
The compiler still emits a real JSR with full parameter
passing through $04-$07, which means the inlining doesn't
escape the bug-2 parameter clobbering.
Reproduction
inline fun card_rank(card: u8) -> u8 {
return card >> 4
}
The asm dump shows JSR __ir_fn_card_rank at every call site —
the function was not inlined.
Root cause
(Inferred — would need to confirm by reading the inliner pass.)
The optimizer's inlining pass has a size threshold or a heuristic
that prevents inlining in some contexts even when the function
is marked inline. There's no diagnostic emitted when the hint
is declined.
Workaround used in examples/war/
None — we just live with the JSR overhead and the bug-2 fallout.
Fix proposal
-
Promote
inlineto a hard contract: wheninlineis present, always inline (or emitW01XXif it cannot be inlined for a structural reason like recursion). -
Optional dump: add
--dump-inlinerto print whichinline fundeclarations were inlined and which weren't, with the reason.
Verification path after fixes
Once any of the bugs above are fixed in the compiler, the
corresponding workarounds in examples/war/*.ne should be
reverted in the same PR so:
- The example demonstrates idiomatic code, not workaround code.
- The PR's diff visibly proves the fix works end-to-end (the workaround removal would otherwise be a silent regression).
- The committed
examples/war.nesrebuilds byte-identically to the reverted source, which the pre-commit hook enforces.
The relevant workaround sites are catalogued in each bug's
"Workaround used" section above; grep for the prefix tags
(dcf_, dfa_, pba_, dwp_, …) to find them all.