1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 00:45:38 +00:00

Merge pull request #31 from imjasonh/claude/resolve-issue-22-0YxNw

analyzer+ir: fix state-local variables and overlay them across mutually-exclusive states
This commit is contained in:
Jason Hall 2026-04-17 09:00:07 -04:00 committed by GitHub
commit 431f144be7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 481 additions and 47 deletions

View file

@ -210,6 +210,18 @@ change needs a manual update + review.
case — programs without palette/bg keep the old `$10` layout to case — programs without palette/bg keep the old `$10` layout to
preserve their goldens). User vars go at `$10+` or `$18+`; IR temps preserve their goldens). User vars go at `$10+` or `$18+`; IR temps
land at `$80+`. land at `$80+`.
- State-local variables (declared at `state Foo { var x }`) are
automatically **overlaid** across states. The analyzer snapshots
the ZP/RAM cursors after the globals are laid out, rewinds to the
snapshot before each state's locals, and advances to the running
max at the end. Because `ZP_CURRENT_STATE` makes at most one state
active at runtime, two states' locals can share the same bytes —
the IR lowerer re-emits each state's declared initializers at the
top of its `on_enter` handler (synthesizing one if needed) so a
freshly entered state doesn't inherit the previous state's writes.
`--memory-map` annotates each allocation with its owning state
(`[@Title]`, `[@Playing]`, ...) so the overlay shows up in the
report.
- `docs/future-work.md` is the authoritative roadmap. If you finish an - `docs/future-work.md` is the authoritative roadmap. If you finish an
item, delete its section; if you add a new gap, write one. item, delete its section; if you add a new gap, write one.

View file

@ -186,6 +186,33 @@ peephole pass mops up the most obvious waste, but a real CFG-aware allocator
that holds short-lived temps in `A`/`X`/`Y` would cut a noticeable number of that holds short-lived temps in `A`/`X`/`Y` would cut a noticeable number of
LDA/STA pairs. LDA/STA pairs.
### State-local memory overlay follow-ups
State-local variables are now overlaid across mutually-exclusive states
(see the analyzer's per-state allocation cursor rewind and the IR
lowerer's `on_enter` initializer prologue), but a few pieces are still
missing:
- **Same-named locals across different states.** `register_var` stores
state-locals under their bare name, so two states each declaring
`var timer: u8` collide with E0501. A per-state symbol-table scope
prefix would let each state carve its own namespace while keeping
the overlay.
- **Struct-literal and array-literal initializers on state-locals.**
The on-enter prologue lowers scalar initializers cleanly, and
struct-literal initializers fall back to per-field stores, but
array-literal initializers (`var xs: u8[4] = [1,2,3,4]`) are
skipped. A runtime `memcpy` from a ROM blob into the overlay
slot (mirroring the reset-time global path) is the natural
lowering.
- **Handler-local overlay.** Handler-local `var`s declared inside
`on_frame { ... }` are already per-handler scoped via
`current_scope_prefix`, but they get a dedicated RAM slot for the
program's lifetime. Overlaying them inside each handler's stack
frame — using a per-handler bump allocator that resets on each
call — would shave a few bytes more on programs with many deep
handlers.
### Cross-block temp live-range analysis ### Cross-block temp live-range analysis
The slot recycler is function-local per-block. Temps that flow across block The slot recycler is function-local per-block. Temps that flow across block

View file

@ -337,6 +337,31 @@ state Playing {
`on frame` is syntactic sugar for a loop with an implicit `wait_frame()` at the end. A state can have any combination of `on enter`, `on exit`, and `on frame`. `on frame` is syntactic sugar for a loop with an implicit `wait_frame()` at the end. A state can have any combination of `on enter`, `on exit`, and `on frame`.
### State-Local Variables and Memory Overlays
Variables declared directly inside a `state` block (outside any handler) are **state-local**. They are visible to every handler in the state (`on enter`, `on frame`, etc.) and persist for as long as that state is active.
Because the NES runtime keeps exactly one state active at a time, the compiler **automatically overlays state-local variables across states**. Two states' locals can share the same RAM bytes without colliding — only the currently active state reads or writes them. This makes the limited 2 KB of NES work RAM go much further on programs with many scenes or game modes.
```
state Title {
var blink: u8 = 0 // overlays with Playing.timer below
on enter { blink = 0 }
on frame { blink = blink + 1 }
}
state Playing {
var timer: u8 = 0 // same byte as Title.blink — reused
var lives: u8 = 3
on enter { timer = 0; lives = 3 }
on frame { timer = timer + 1 }
}
```
Every time a state is entered, its state-local variables are re-initialized from their declared initializers (`= 0`, `= 3` above) before `on enter` runs. This is what makes the overlay safe: entering Playing re-runs `timer = 0` even if the previous state wrote a different value into the shared byte. `cargo run -- build <file> --memory-map` shows each overlaid address alongside its owning state.
Global `var`s (declared at the top level, outside any state) are never overlaid and keep dedicated RAM slots. Variables declared inside a handler block are handler-local and live only for the handler invocation.
### State Transitions ### State Transitions
``` ```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 446 KiB

Before After
Before After

View file

@ -23,12 +23,7 @@ const COIN_X: u8 = 180
const COIN_Y: u8 = 100 const COIN_Y: u8 = 100
// Global variables // Global variables
var player_x: u8 = 40
var player_y: u8 = 200
var player_vy: u8 = 0
var on_ground: u8 = 1
var score: u8 = 0 var score: u8 = 0
var coins_left: u8 = 3
// Helper function: clamp a value to screen bounds // Helper function: clamp a value to screen bounds
fun clamp_x(val: u8) -> u8 { fun clamp_x(val: u8) -> u8 {
@ -53,11 +48,20 @@ state Title {
// Main gameplay state // Main gameplay state
state Playing { state Playing {
// Physics and position live with the state: they're only
// meaningful while Playing is active, and the analyzer
// overlays them with the locals of the Title and GameOver
// states so the idle scenes don't reserve bytes they never
// touch. Initializers re-run on every entry, so dying and
// retrying starts the player back on the ground.
var player_x: u8 = 40
var player_y: u8 = 200
var player_vy: u8 = 0
var on_ground: u8 = 1
var coins_left: u8 = 3
on enter { on enter {
player_x = 40
player_y = 200
score = 0 score = 0
coins_left = 3
} }
on frame { on frame {

Binary file not shown.

View file

@ -367,21 +367,17 @@ const AUTOPILOT_JUMPS: u8 = 2
// ── Game state ────────────────────────────────────────────── // ── Game state ──────────────────────────────────────────────
// Player physics // `frame_tick` is shared: Title reads it to auto-advance, Playing
var player_y: u8 = 160 // reads it for animation phasing. `stomp_count` bridges
var on_ground: u8 = 1 // Playing → GameOver so the death screen can tally coins. The
var rise_count: u8 = 0 // frames of upward motion remaining // rest — player physics, camera, liveness, autopilot budget —
var fall_vy: u8 = 0 // gravity accumulator // are only meaningful while Playing is running, so they live on
// Playing's state block and overlay with the Title / GameOver
// locals (`blink`, `linger`) at the same bytes.
// World/camera // Cross-state scratch
var camera_x: u8 = 0
var frame_tick: u8 = 0 // free-running frame counter var frame_tick: u8 = 0 // free-running frame counter
var anim_tick: u8 = 0 // visual animation phase
// Gameplay
var alive: u8 = 1 // 0 = dying/dead, 1 = playable
var stomp_count: u8 = 0 // successful enemy stomps this life var stomp_count: u8 = 0 // successful enemy stomps this life
var auto_jumps: u8 = 0 // proximity pre-jumps used this life
// ── Helper functions ──────────────────────────────────────── // ── Helper functions ────────────────────────────────────────
@ -520,17 +516,24 @@ state Title {
} }
state Playing { state Playing {
// Physics, camera, liveness, and autopilot budget — all of
// this is Playing-only. Declaring them inside the state block
// lets the analyzer overlay them with Title.blink and
// GameOver.linger; each variable's initializer re-runs on
// entry, so the retry loop starts each life on the ground
// with a fresh autopilot budget without any manual reset.
var player_y: u8 = GROUND_Y
var on_ground: u8 = 1
var rise_count: u8 = 0
var fall_vy: u8 = 0
var camera_x: u8 = 0
var anim_tick: u8 = 0
var alive: u8 = 1
var auto_jumps: u8 = 0
on enter { on enter {
player_y = GROUND_Y
on_ground = 1
rise_count = 0
fall_vy = 0
camera_x = 0
frame_tick = 0 frame_tick = 0
anim_tick = 0
alive = 1
stomp_count = 0 stomp_count = 0
auto_jumps = 0
start_music Theme start_music Theme
} }

Binary file not shown.

Binary file not shown.

View file

@ -31,6 +31,10 @@ pub struct AnalysisResult {
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
pub call_graph: HashMap<String, Vec<String>>, pub call_graph: HashMap<String, Vec<String>>,
pub max_depths: HashMap<String, u32>, pub max_depths: HashMap<String, u32>,
/// For each state-local variable name, the state it belongs to.
/// Consumed by the memory-map printer to group overlaid slots by
/// their owning state. Empty for programs without state-locals.
pub state_local_owners: HashMap<String, String>,
} }
/// Default call stack depth limit for the NES runtime. /// Default call stack depth limit for the NES runtime.
@ -124,12 +128,20 @@ pub fn analyze(program: &Program) -> AnalysisResult {
}; };
analyzer.analyze_program(program); analyzer.analyze_program(program);
let mut state_local_owners = HashMap::new();
for state in &program.states {
for var in &state.locals {
state_local_owners.insert(var.name.clone(), state.name.clone());
}
}
AnalysisResult { AnalysisResult {
symbols: analyzer.symbols, symbols: analyzer.symbols,
var_allocations: analyzer.var_allocations, var_allocations: analyzer.var_allocations,
diagnostics: analyzer.diagnostics, diagnostics: analyzer.diagnostics,
call_graph: analyzer.call_graph, call_graph: analyzer.call_graph,
max_depths: analyzer.max_depths, max_depths: analyzer.max_depths,
state_local_owners,
} }
} }
@ -524,12 +536,46 @@ impl Analyzer {
self.register_fun(fun); self.register_fun(fun);
} }
// Register state-local variables // Register state-local variables with automatic memory
// overlaying. At runtime only one state is active at a time
// (a single `ZP_CURRENT_STATE` byte picks the handler), so
// every state's locals are mutually exclusive with every
// other state's — their RAM footprints can share the same
// addresses. The allocator snapshots both cursors after the
// globals have been laid out, then rewinds to that snapshot
// before each state's locals and tracks the running max.
// The overall cursor advances to the max at the end, so
// anything allocated after the state-locals (function
// parameters, function bodies' locals) picks up past every
// state's overlay window.
//
// Each state's on_enter handler re-initializes the locals
// from their declared initializers — the IR lowering moves
// those stores into the handler's prologue so a freshly
// entered state doesn't read another state's leftover
// bytes. State-locals whose name collides with a global or
// another state's local are still rejected via E0501 at
// `register_var` because the symbol table is keyed by the
// bare name.
let overlay_zp_base = self.next_zp_addr;
let overlay_ram_base = self.next_ram_addr;
let mut max_zp = overlay_zp_base;
let mut max_ram = overlay_ram_base;
for state in &program.states { for state in &program.states {
self.next_zp_addr = overlay_zp_base;
self.next_ram_addr = overlay_ram_base;
for var in &state.locals { for var in &state.locals {
self.register_var(var); self.register_var(var);
} }
if self.next_zp_addr > max_zp {
max_zp = self.next_zp_addr;
} }
if self.next_ram_addr > max_ram {
max_ram = self.next_ram_addr;
}
}
self.next_zp_addr = max_zp;
self.next_ram_addr = max_ram;
// Validate state references // Validate state references
let state_names: Vec<&str> = program.states.iter().map(|s| s.name.as_str()).collect(); let state_names: Vec<&str> = program.states.iter().map(|s| s.name.as_str()).collect();

View file

@ -655,6 +655,35 @@ impl LoweringContext {
// enforced. // enforced.
self.capture_inline_bodies(program); self.capture_inline_bodies(program);
// Register state-local variables as IR globals so the codegen
// resolves their addresses through the same `ir.globals`
// pathway it uses for program globals — the analyzer records
// them under their bare names in `var_allocations`, which
// `IrCodeGen::new` then matches against each global's
// `name` field. Without this, a `LoadVar`/`StoreVar` on a
// state-local variable resolved its `VarId` to no address
// and the codegen silently emitted nothing — the root
// cause of the "state-local variables don't actually work"
// bug that this change ships with the overlay feature.
//
// `init_value` / `init_array` are intentionally left blank:
// state-locals are re-initialized in each state's on_enter
// handler below, not at program reset. The analyzer's
// overlay allocation means one state's initial bytes would
// stomp on another state's if we emitted them at reset.
for state in &program.states {
for var in &state.locals {
let var_id = self.get_or_create_var(&var.name);
self.globals.push(IrGlobal {
var_id,
name: var.name.clone(),
size: type_size(&var.var_type),
init_value: None,
init_array: Vec::new(),
});
}
}
// Lower user functions // Lower user functions
for fun in &program.functions { for fun in &program.functions {
self.lower_function(fun); self.lower_function(fun);
@ -737,7 +766,26 @@ impl LoweringContext {
// `Title::on frame` and one in `Playing::on frame` get // `Title::on frame` and one in `Playing::on frame` get
// different VarIds. // different VarIds.
if let Some(on_enter) = &state.on_enter { // State-local variables with initializers need their values
// re-established every time the state is entered, because
// the analyzer overlays state-locals across mutually
// exclusive states and another state's writes can clobber
// the bytes in between. If the state already has an
// on_enter handler, `lower_handler` prepends the
// initializer stores; if not, synthesize an empty one here
// so the dispatch path still calls into the prelude.
let needs_synthetic_enter =
state.on_enter.is_none() && state.locals.iter().any(|v| v.init.is_some());
let synthetic_enter = Block {
statements: Vec::new(),
span: state.span,
};
let on_enter_block: Option<&Block> = state.on_enter.as_ref().or(if needs_synthetic_enter {
Some(&synthetic_enter)
} else {
None
});
if let Some(on_enter) = on_enter_block {
self.lower_handler( self.lower_handler(
&format!("{}_enter", state.name), &format!("{}_enter", state.name),
&format!("{}__enter", state.name), &format!("{}__enter", state.name),
@ -813,6 +861,53 @@ impl LoweringContext {
let entry = self.fresh_label(&format!("{name}_entry")); let entry = self.fresh_label(&format!("{name}_entry"));
self.start_block(&entry); self.start_block(&entry);
// on_enter handlers carry the state-local initializer
// prologue: every `var x: u8 = expr` declared at
// `state Foo { ... }` level gets a store emitted at the
// top of on_enter so the state's locals are reset every
// time the state is entered. This is what makes the
// analyzer's overlay allocation safe — another state
// having written into these bytes no longer matters,
// because we unconditionally re-initialize them here.
// User code inside the on_enter body then runs on top.
// Locals without an initializer are left at whatever
// bytes the previous state wrote; the programmer can
// explicitly assign them if they want a fresh value.
if name.ends_with("_enter") {
for var in &state.locals {
let Some(init) = &var.init else { continue };
let var_id = self.get_or_create_var(&var.name);
if let Expr::ArrayLiteral(_, _) = init {
// Array initializers for state-locals aren't
// supported yet — a runtime memcpy loop from a
// ROM blob would be the natural lowering.
// Programs that try this should get a diagnostic
// from the analyzer; for now, silently skip.
continue;
}
if let Expr::StructLiteral(_, fields, _) = init {
for (fname, fexpr) in fields {
let full = format!("{}.{fname}", var.name);
let fvid = self.get_or_create_var(&full);
let val = self.lower_expr(fexpr);
self.emit(IrOp::StoreVar(fvid, val));
}
continue;
}
let val = self.lower_expr(init);
self.emit(IrOp::StoreVar(var_id, val));
// u16-typed state-locals also need the high byte
// of the initializer stored at base+1. Mirror the
// `VarDecl` lowering in `lower_statement` so wide
// inits round-trip cleanly.
if matches!(var.var_type, NesType::U16) {
let (_, hi) = self.widen(val);
self.emit(IrOp::StoreVarHi(var_id, hi));
}
}
}
self.lower_block(block); self.lower_block(block);
self.end_block(IrTerminator::Return(None)); self.end_block(IrTerminator::Return(None));

View file

@ -155,7 +155,24 @@ fn write_memory_map(
backgrounds: &[BackgroundData], backgrounds: &[BackgroundData],
) -> std::io::Result<()> { ) -> std::io::Result<()> {
let mut allocs: Vec<_> = analysis.var_allocations.iter().collect(); let mut allocs: Vec<_> = analysis.var_allocations.iter().collect();
allocs.sort_by_key(|a| a.address); // Sort by address, then by state-local owner (None before Some),
// so the memory map groups overlaid state-locals together under
// their shared base address.
allocs.sort_by(|a, b| {
a.address.cmp(&b.address).then_with(|| {
analysis
.state_local_owners
.get(&a.name)
.cmp(&analysis.state_local_owners.get(&b.name))
})
});
let fmt_tag = |name: &str| -> String {
match analysis.state_local_owners.get(name) {
Some(state) => format!("[@{state}]"),
None => "[USER] ".to_string(),
}
};
writeln!(w, "=== NEScript Memory Map ===")?; writeln!(w, "=== NEScript Memory Map ===")?;
writeln!(w, "Zero Page ($00-$FF):")?; writeln!(w, "Zero Page ($00-$FF):")?;
@ -164,14 +181,16 @@ fn write_memory_map(
" $00-$0F [SYSTEM] reserved (frame flag, input, state, params, scratch)" " $00-$0F [SYSTEM] reserved (frame flag, input, state, params, scratch)"
)?; )?;
for a in allocs.iter().filter(|a| a.address < 0x100) { for a in allocs.iter().filter(|a| a.address < 0x100) {
let tag = fmt_tag(&a.name);
if a.size == 1 { if a.size == 1 {
writeln!(w, " ${:04X} [USER] {} (u8)", a.address, a.name)?; writeln!(w, " ${:04X} {} {} (u8)", a.address, tag, a.name)?;
} else { } else {
writeln!( writeln!(
w, w,
" ${:04X}-${:04X} [USER] {} ({} bytes)", " ${:04X}-${:04X} {} {} ({} bytes)",
a.address, a.address,
a.address + a.size - 1, a.address + a.size - 1,
tag,
a.name, a.name,
a.size a.size
)?; )?;
@ -183,14 +202,16 @@ fn write_memory_map(
writeln!(w, "\nRAM ($0200-$07FF):")?; writeln!(w, "\nRAM ($0200-$07FF):")?;
writeln!(w, " $0200-$02FF [SYSTEM] OAM shadow buffer")?; writeln!(w, " $0200-$02FF [SYSTEM] OAM shadow buffer")?;
for a in &ram_allocs { for a in &ram_allocs {
let tag = fmt_tag(&a.name);
if a.size == 1 { if a.size == 1 {
writeln!(w, " ${:04X} [USER] {} (u8)", a.address, a.name)?; writeln!(w, " ${:04X} {} {} (u8)", a.address, tag, a.name)?;
} else { } else {
writeln!( writeln!(
w, w,
" ${:04X}-${:04X} [USER] {} ({} bytes)", " ${:04X}-${:04X} {} {} ({} bytes)",
a.address, a.address,
a.address + a.size - 1, a.address + a.size - 1,
tag,
a.name, a.name,
a.size a.size
)?; )?;
@ -198,17 +219,24 @@ fn write_memory_map(
} }
} }
// Summary line. // Summary counts distinct byte addresses in use, not the sum of
let zp_used: u16 = allocs // allocation sizes, so overlaid state-locals are only counted
.iter() // once per shared byte. Non-state-local allocations and the
.filter(|a| a.address < 0x80) // per-state allocations each contribute their own bytes.
.map(|a| a.size) let mut zp_bytes_used: std::collections::HashSet<u16> = std::collections::HashSet::new();
.sum(); let mut ram_bytes_used: std::collections::HashSet<u16> = std::collections::HashSet::new();
let ram_used: u16 = allocs for a in &allocs {
.iter() for offset in 0..a.size {
.filter(|a| a.address >= 0x300) let byte = a.address + offset;
.map(|a| a.size) if byte < 0x80 {
.sum(); zp_bytes_used.insert(byte);
} else if byte >= 0x300 {
ram_bytes_used.insert(byte);
}
}
}
let zp_used = zp_bytes_used.len();
let ram_used = ram_bytes_used.len();
writeln!(w)?; writeln!(w)?;
writeln!(w, "Zero Page: {zp_used}/128 bytes used")?; writeln!(w, "Zero Page: {zp_used}/128 bytes used")?;
writeln!(w, "Main RAM: {ram_used}/1280 bytes used")?; writeln!(w, "Main RAM: {ram_used}/1280 bytes used")?;
@ -513,6 +541,7 @@ mod tests {
diagnostics: Vec::new(), diagnostics: Vec::new(),
call_graph: HashMap::new(), call_graph: HashMap::new(),
max_depths: HashMap::new(), max_depths: HashMap::new(),
state_local_owners: HashMap::new(),
} }
} }

View file

@ -1 +1 @@
ea23d9c4 132084 2b03b3ec 132084

View file

@ -1120,6 +1120,199 @@ fn program_without_palette_does_not_reserve_ppu_zero_page() {
); );
} }
#[test]
fn state_locals_overlay_at_same_base_address() {
// Two states' locals each start at the same ZP address because
// `ZP_CURRENT_STATE` makes them mutually exclusive at runtime.
// The overlay saves bytes: without it, A's two locals plus B's
// two locals would occupy four distinct slots; with it, each
// state uses the same pair of slots.
let source = r#"
game "Overlay" { mapper: NROM }
state A {
var a1: u8 = 11
var a2: u8 = 22
on frame { a1 = a1 + 1; a2 = a2 + 1; wait_frame }
}
state B {
var b1: u8 = 33
var b2: u8 = 44
on frame { b1 = b1 + 1; b2 = b2 + 1; wait_frame }
}
start A
"#;
let (program, diags) = nescript::parser::parse(source);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let program = program.expect("parse should succeed");
let analysis = analyzer::analyze(&program);
assert!(
analysis.diagnostics.iter().all(|d| !d.is_error()),
"unexpected analysis errors: {:?}",
analysis.diagnostics
);
let addr_of = |name: &str| -> u16 {
analysis
.var_allocations
.iter()
.find(|a| a.name == name)
.unwrap_or_else(|| panic!("var '{name}' not allocated"))
.address
};
// First locals of each state share the overlay base.
assert_eq!(addr_of("a1"), addr_of("b1"));
// Second locals share the next overlay byte.
assert_eq!(addr_of("a2"), addr_of("b2"));
// Within a single state, sibling locals land at distinct slots.
assert_ne!(addr_of("a1"), addr_of("a2"));
// The second state's owners are recorded so tooling (memory map,
// debug symbols) can group overlaid slots by owning state.
assert_eq!(
analysis.state_local_owners.get("b1").map(String::as_str),
Some("B")
);
}
#[test]
fn state_local_and_global_do_not_overlay() {
// Globals sit before the state-local overlay window and keep
// their own slots even if the state-locals happen to start at
// the next address. This guards against a regression where the
// overlay cursor snapshot gets taken before globals are laid
// out, which would alias a global onto a state-local.
let source = r#"
game "NoAlias" { mapper: NROM }
var g1: u8 = 5
var g2: u8 = 6
state S {
var s1: u8 = 0
on frame { s1 = s1 + 1; wait_frame }
}
start S
"#;
let (program, diags) = nescript::parser::parse(source);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let analysis = analyzer::analyze(&program.unwrap());
let addr_of = |name: &str| {
analysis
.var_allocations
.iter()
.find(|a| a.name == name)
.unwrap_or_else(|| panic!("var '{name}' not allocated"))
.address
};
assert_ne!(addr_of("g1"), addr_of("s1"));
assert_ne!(addr_of("g2"), addr_of("s1"));
assert!(addr_of("s1") > addr_of("g2"));
}
#[test]
fn state_local_store_round_trips_through_zero_page() {
// Prior to the overlay work, a `StoreVar` on a state-local
// silently emitted nothing because the codegen never mapped the
// IR `VarId` to a RAM address — reads and writes inside state
// handlers got dropped and the declared initializer at
// `var counter: u8 = 7` never ran. With the fix, the on_enter
// prologue stores the initializer and the frame handler stores
// a literal value, both landing on the allocated ZP slot.
let source = r#"
game "SL" { mapper: NROM }
state Main {
var counter: u8 = 7
on frame {
counter = 42
wait_frame
}
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("valid iNES");
// `LDA #7 / STA $10` — the on_enter prologue writes the
// state-local's declared initializer every time the state is
// entered.
let init_bytes = [0xA9u8, 0x07, 0x85, 0x10];
assert!(
rom_data.windows(init_bytes.len()).any(|w| w == init_bytes),
"state-local initializer `= 7` should write $10 at state entry"
);
// `LDA #42 / STA $10` — the frame handler's assignment reaches
// the same slot. Previously this was silently dropped.
let assign_bytes = [0xA9u8, 0x2A, 0x85, 0x10];
assert!(
rom_data
.windows(assign_bytes.len())
.any(|w| w == assign_bytes),
"frame handler assignment `counter = 42` should reach $10"
);
}
#[test]
fn state_local_initializer_does_not_run_at_reset() {
// With the overlay allocator, each state's `var x = expr`
// initializer runs on every state entry — not once at reset.
// Emitting the init at reset would fight the overlay: the
// last state's initializer would stomp the byte that belongs
// to the active starting state. Verify by looking at the reset
// path in the ROM — the `STA $10` happens only inside each
// state's `_enter` handler (i.e., preceded by a `JSR`), never
// in the straight-line reset prologue.
let source = r#"
game "SL" { mapper: NROM }
state First {
var x: u8 = 1
on frame { x = x + 1; wait_frame }
}
state Second {
var x2: u8 = 2
on frame { x2 = x2 + 1; wait_frame }
}
start First
"#;
// x and x2 overlay at $10 (in the no-global case). We can check
// the generated ROM contains both initializers and that both
// land on the same ZP address — which would be impossible if
// they ran at reset (one would overwrite the other before the
// loop ever started).
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("valid iNES");
let init_first = [0xA9u8, 0x01, 0x85, 0x10]; // LDA #1 / STA $10
let init_second = [0xA9u8, 0x02, 0x85, 0x10]; // LDA #2 / STA $10
assert!(
rom_data.windows(init_first.len()).any(|w| w == init_first),
"First's initializer must survive to its on_enter"
);
assert!(
rom_data
.windows(init_second.len())
.any(|w| w == init_second),
"Second's initializer must survive to its on_enter"
);
}
#[test]
fn state_without_on_enter_gets_synthesized_one_for_initializers() {
// A state with locals that have initializers but no explicit
// on_enter still needs its initializers re-established on every
// entry. The lowering synthesizes an empty on_enter and
// prepends the init stores.
let source = r#"
game "Synth" { mapper: NROM }
state Only {
var v: u8 = 99
on frame { v = v + 1; wait_frame }
}
start Only
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("valid iNES");
// `LDA #99 / STA $10`
let init_bytes = [0xA9u8, 0x63, 0x85, 0x10];
assert!(
rom_data.windows(init_bytes.len()).any(|w| w == init_bytes),
"synthesized on_enter should write $10 with the initializer"
);
}
// ── M5 Tests ── // ── M5 Tests ──
/// Compile a source string using the mapper-aware linker. /// Compile a source string using the mapper-aware linker.