mirror of
https://github.com/imjasonh/nescript
synced 2026-07-18 14:45:58 +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:
parent
e10d09db76
commit
76dd8eacb0
23 changed files with 762 additions and 412 deletions
|
|
@ -26,6 +26,14 @@ struct LoweringContext {
|
|||
/// symbol table). Used to decide between 8-bit and 16-bit IR
|
||||
/// ops for identifier reads/writes and binary operations.
|
||||
var_types: HashMap<String, NesType>,
|
||||
/// Current local scope prefix — mirrors the analyzer's field
|
||||
/// of the same name. While lowering a function or handler
|
||||
/// body this is `Some("<func_name>")` (or `Some("State__frame")`,
|
||||
/// etc), and `get_or_create_var` prepends
|
||||
/// `"__local__{prefix}__"` to any bare identifier lookup so
|
||||
/// function-local vars resolve to the scoped entry the
|
||||
/// analyzer registered for them. `None` outside of any body.
|
||||
current_scope_prefix: Option<String>,
|
||||
next_var_id: u32,
|
||||
next_temp: u32,
|
||||
next_block: u32,
|
||||
|
|
@ -98,6 +106,7 @@ impl LoweringContext {
|
|||
var_map,
|
||||
const_values: HashMap::new(),
|
||||
var_types,
|
||||
current_scope_prefix: None,
|
||||
next_var_id,
|
||||
next_temp: 0,
|
||||
next_block: 0,
|
||||
|
|
@ -113,13 +122,6 @@ impl LoweringContext {
|
|||
}
|
||||
}
|
||||
|
||||
/// Register a function parameter's type in the `var_types` map
|
||||
/// so that identifier reads inside the function body know
|
||||
/// whether to load as a byte or a word.
|
||||
fn register_param_type(&mut self, name: &str, ty: &NesType) {
|
||||
self.var_types.insert(name.to_string(), ty.clone());
|
||||
}
|
||||
|
||||
fn fresh_temp(&mut self) -> IrTemp {
|
||||
let t = IrTemp(self.next_temp);
|
||||
self.next_temp += 1;
|
||||
|
|
@ -131,15 +133,30 @@ impl LoweringContext {
|
|||
format!("{prefix}_{}", self.next_block)
|
||||
}
|
||||
|
||||
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
|
||||
/// Resolve a user-written identifier to the scoped key used by
|
||||
/// the symbol table. Mirrors `Analyzer::resolve_key`: tries the
|
||||
/// current function/handler's qualified key first, falls back
|
||||
/// to the bare key for globals / consts / enum variants /
|
||||
/// state-level vars / function names.
|
||||
fn scoped_key(&self, name: &str) -> String {
|
||||
if let Some(prefix) = &self.current_scope_prefix {
|
||||
let qualified = format!("__local__{prefix}__{name}");
|
||||
if self.var_map.contains_key(&qualified) || self.var_types.contains_key(&qualified) {
|
||||
return qualified;
|
||||
}
|
||||
}
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
fn get_or_create_var(&mut self, name: &str) -> VarId {
|
||||
let key = self.scoped_key(name);
|
||||
if let Some(&id) = self.var_map.get(&key) {
|
||||
return id;
|
||||
}
|
||||
let id = VarId(self.next_var_id);
|
||||
self.next_var_id += 1;
|
||||
self.var_map.insert(key, id);
|
||||
id
|
||||
}
|
||||
|
||||
/// Recursively expand a struct-literal global initializer into
|
||||
|
|
@ -408,8 +425,15 @@ impl LoweringContext {
|
|||
self.wide_hi.clear();
|
||||
self.current_blocks = Vec::new();
|
||||
self.current_locals = Vec::new();
|
||||
// Enter the function's local scope so all bare identifier
|
||||
// lookups inside the body resolve against the analyzer's
|
||||
// `__local__{function_name}__{name}` entries.
|
||||
self.current_scope_prefix = Some(fun.name.clone());
|
||||
|
||||
// Register parameters as locals
|
||||
// Register parameters as locals. They're looked up via
|
||||
// their bare name (which `get_or_create_var` now qualifies
|
||||
// via `scoped_key`), so two different functions can each
|
||||
// have a parameter named `x` without the VarIds colliding.
|
||||
for param in &fun.params {
|
||||
let var_id = self.get_or_create_var(¶m.name);
|
||||
self.current_locals.push(IrLocal {
|
||||
|
|
@ -417,7 +441,10 @@ impl LoweringContext {
|
|||
name: param.name.clone(),
|
||||
size: type_size(¶m.param_type),
|
||||
});
|
||||
self.register_param_type(¶m.name, ¶m.param_type);
|
||||
// Register the param type under the scoped key so
|
||||
// `lower_expr` can decide 8-bit vs 16-bit loads.
|
||||
let key = format!("__local__{}__{}", fun.name, param.name);
|
||||
self.var_types.insert(key, param.param_type.clone());
|
||||
}
|
||||
|
||||
let entry = self.fresh_label(&format!("fn_{}_entry", fun.name));
|
||||
|
|
@ -443,21 +470,40 @@ impl LoweringContext {
|
|||
bank: fun.bank.clone(),
|
||||
source_span: fun.span,
|
||||
});
|
||||
self.current_scope_prefix = None;
|
||||
}
|
||||
|
||||
fn lower_state(&mut self, state: &StateDecl, _is_start: bool) {
|
||||
// Lower each event handler as a separate function
|
||||
// Lower each event handler as a separate function. Each
|
||||
// handler uses a distinct scope prefix so a `var i` in
|
||||
// `Title::on frame` and one in `Playing::on frame` get
|
||||
// different VarIds.
|
||||
|
||||
if let Some(on_enter) = &state.on_enter {
|
||||
self.lower_handler(&format!("{}_enter", state.name), on_enter, state);
|
||||
self.lower_handler(
|
||||
&format!("{}_enter", state.name),
|
||||
&format!("{}__enter", state.name),
|
||||
on_enter,
|
||||
state,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(on_exit) = &state.on_exit {
|
||||
self.lower_handler(&format!("{}_exit", state.name), on_exit, state);
|
||||
self.lower_handler(
|
||||
&format!("{}_exit", state.name),
|
||||
&format!("{}__exit", state.name),
|
||||
on_exit,
|
||||
state,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(on_frame) = &state.on_frame {
|
||||
self.lower_handler(&format!("{}_frame", state.name), on_frame, state);
|
||||
self.lower_handler(
|
||||
&format!("{}_frame", state.name),
|
||||
&format!("{}__frame", state.name),
|
||||
on_frame,
|
||||
state,
|
||||
);
|
||||
}
|
||||
|
||||
// Lower each scanline handler as a function named
|
||||
|
|
@ -465,11 +511,12 @@ impl LoweringContext {
|
|||
// IRQ dispatch wrapper separately.
|
||||
for (line, block) in &state.on_scanline {
|
||||
let name = format!("{}_scanline_{line}", state.name);
|
||||
self.lower_handler(&name, block, state);
|
||||
let scope = format!("{}__scanline_{line}", state.name);
|
||||
self.lower_handler(&name, &scope, block, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_handler(&mut self, name: &str, block: &Block, state: &StateDecl) {
|
||||
fn lower_handler(&mut self, name: &str, scope_prefix: &str, block: &Block, state: &StateDecl) {
|
||||
self.next_temp = 0;
|
||||
// Same per-function reset as `lower_function`. See the
|
||||
// commentary there and COMPILER_BUGS.md §6 for why this is
|
||||
|
|
@ -478,6 +525,7 @@ impl LoweringContext {
|
|||
// catastrophically wrong 16-bit IR ops.
|
||||
self.wide_hi.clear();
|
||||
self.current_blocks = Vec::new();
|
||||
self.current_scope_prefix = Some(scope_prefix.to_string());
|
||||
// Seed `current_locals` with the state's declared locals so any
|
||||
// `VarDecl` inside the handler body — tracked by
|
||||
// `lower_statement` via `current_locals` — is appended alongside
|
||||
|
|
@ -488,6 +536,13 @@ impl LoweringContext {
|
|||
// addresses) would never see them. The result would be a
|
||||
// silent `LoadVar`/`StoreVar` emit-nothing bug that leaves the
|
||||
// temp slots uninitialized at runtime.
|
||||
//
|
||||
// State-level locals (declared at `state Foo { var i: u8 }`
|
||||
// outside any handler) live in the GLOBAL scope so every
|
||||
// handler in the state can read/write them across frames.
|
||||
// `get_or_create_var` would try the scoped key first —
|
||||
// which isn't registered for state-locals — then fall back
|
||||
// to the bare key, which IS registered.
|
||||
self.current_locals = Vec::new();
|
||||
for var in &state.locals {
|
||||
let var_id = self.get_or_create_var(&var.name);
|
||||
|
|
@ -516,6 +571,7 @@ impl LoweringContext {
|
|||
bank: None,
|
||||
source_span: state.span,
|
||||
});
|
||||
self.current_scope_prefix = None;
|
||||
}
|
||||
|
||||
fn lower_block(&mut self, block: &Block) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue