diff --git a/CLAUDE.md b/CLAUDE.md index 8fe317f..602a5ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,6 +210,18 @@ change needs a manual update + review. case — programs without palette/bg keep the old `$10` layout to preserve their goldens). User vars go at `$10+` or `$18+`; IR temps 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 item, delete its section; if you add a new gap, write one. diff --git a/docs/future-work.md b/docs/future-work.md index f490170..d18733c 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -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 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 The slot recycler is function-local per-block. Temps that flow across block diff --git a/docs/language-guide.md b/docs/language-guide.md index c840e63..0c7c122 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -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`. +### 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 --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 ``` diff --git a/docs/platformer.gif b/docs/platformer.gif index 4b982f7..2ed2b86 100644 Binary files a/docs/platformer.gif and b/docs/platformer.gif differ diff --git a/examples/coin_cavern.ne b/examples/coin_cavern.ne index 4d4a7f6..a5bb108 100644 --- a/examples/coin_cavern.ne +++ b/examples/coin_cavern.ne @@ -23,12 +23,7 @@ const COIN_X: u8 = 180 const COIN_Y: u8 = 100 // 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 coins_left: u8 = 3 // Helper function: clamp a value to screen bounds fun clamp_x(val: u8) -> u8 { @@ -53,11 +48,20 @@ state Title { // Main gameplay state 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 { - player_x = 40 - player_y = 200 score = 0 - coins_left = 3 } on frame { diff --git a/examples/coin_cavern.nes b/examples/coin_cavern.nes index 4be191b..d9b0319 100644 Binary files a/examples/coin_cavern.nes and b/examples/coin_cavern.nes differ diff --git a/examples/platformer.ne b/examples/platformer.ne index 0eaf76c..687cd23 100644 --- a/examples/platformer.ne +++ b/examples/platformer.ne @@ -367,21 +367,17 @@ const AUTOPILOT_JUMPS: u8 = 2 // ── Game state ────────────────────────────────────────────── -// Player physics -var player_y: u8 = 160 -var on_ground: u8 = 1 -var rise_count: u8 = 0 // frames of upward motion remaining -var fall_vy: u8 = 0 // gravity accumulator - -// World/camera -var camera_x: u8 = 0 +// `frame_tick` is shared: Title reads it to auto-advance, Playing +// reads it for animation phasing. `stomp_count` bridges +// Playing → GameOver so the death screen can tally coins. The +// rest — player physics, camera, liveness, autopilot budget — +// 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. + +// Cross-state scratch 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 auto_jumps: u8 = 0 // proximity pre-jumps used this life // ── Helper functions ──────────────────────────────────────── @@ -520,17 +516,24 @@ state Title { } 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 { - player_y = GROUND_Y - on_ground = 1 - rise_count = 0 - fall_vy = 0 - camera_x = 0 frame_tick = 0 - anim_tick = 0 - alive = 1 stomp_count = 0 - auto_jumps = 0 start_music Theme } diff --git a/examples/platformer.nes b/examples/platformer.nes index 81966a4..c55cdc9 100644 Binary files a/examples/platformer.nes and b/examples/platformer.nes differ diff --git a/examples/state_machine.nes b/examples/state_machine.nes index bc2ec80..ca4c775 100644 Binary files a/examples/state_machine.nes and b/examples/state_machine.nes differ diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index bdec3cb..5c65c87 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -31,6 +31,10 @@ pub struct AnalysisResult { pub diagnostics: Vec, pub call_graph: HashMap>, pub max_depths: HashMap, + /// 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, } /// Default call stack depth limit for the NES runtime. @@ -124,12 +128,20 @@ pub fn analyze(program: &Program) -> AnalysisResult { }; 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 { symbols: analyzer.symbols, var_allocations: analyzer.var_allocations, diagnostics: analyzer.diagnostics, call_graph: analyzer.call_graph, max_depths: analyzer.max_depths, + state_local_owners, } } @@ -524,12 +536,46 @@ impl Analyzer { 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 { + self.next_zp_addr = overlay_zp_base; + self.next_ram_addr = overlay_ram_base; for var in &state.locals { 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 let state_names: Vec<&str> = program.states.iter().map(|s| s.name.as_str()).collect(); diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 48f8d46..36661ab 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -655,6 +655,35 @@ impl LoweringContext { // enforced. 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 for fun in &program.functions { self.lower_function(fun); @@ -737,7 +766,26 @@ impl LoweringContext { // `Title::on frame` and one in `Playing::on frame` get // 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( &format!("{}_enter", state.name), &format!("{}__enter", state.name), @@ -813,6 +861,53 @@ impl LoweringContext { let entry = self.fresh_label(&format!("{name}_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.end_block(IrTerminator::Return(None)); diff --git a/src/main.rs b/src/main.rs index 962d429..4fe29f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -155,7 +155,24 @@ fn write_memory_map( backgrounds: &[BackgroundData], ) -> std::io::Result<()> { 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, "Zero Page ($00-$FF):")?; @@ -164,14 +181,16 @@ fn write_memory_map( " $00-$0F [SYSTEM] reserved (frame flag, input, state, params, scratch)" )?; for a in allocs.iter().filter(|a| a.address < 0x100) { + let tag = fmt_tag(&a.name); if a.size == 1 { - writeln!(w, " ${:04X} [USER] {} (u8)", a.address, a.name)?; + writeln!(w, " ${:04X} {} {} (u8)", a.address, tag, a.name)?; } else { writeln!( w, - " ${:04X}-${:04X} [USER] {} ({} bytes)", + " ${:04X}-${:04X} {} {} ({} bytes)", a.address, a.address + a.size - 1, + tag, a.name, a.size )?; @@ -183,14 +202,16 @@ fn write_memory_map( writeln!(w, "\nRAM ($0200-$07FF):")?; writeln!(w, " $0200-$02FF [SYSTEM] OAM shadow buffer")?; for a in &ram_allocs { + let tag = fmt_tag(&a.name); if a.size == 1 { - writeln!(w, " ${:04X} [USER] {} (u8)", a.address, a.name)?; + writeln!(w, " ${:04X} {} {} (u8)", a.address, tag, a.name)?; } else { writeln!( w, - " ${:04X}-${:04X} [USER] {} ({} bytes)", + " ${:04X}-${:04X} {} {} ({} bytes)", a.address, a.address + a.size - 1, + tag, a.name, a.size )?; @@ -198,17 +219,24 @@ fn write_memory_map( } } - // Summary line. - let zp_used: u16 = allocs - .iter() - .filter(|a| a.address < 0x80) - .map(|a| a.size) - .sum(); - let ram_used: u16 = allocs - .iter() - .filter(|a| a.address >= 0x300) - .map(|a| a.size) - .sum(); + // Summary counts distinct byte addresses in use, not the sum of + // allocation sizes, so overlaid state-locals are only counted + // once per shared byte. Non-state-local allocations and the + // per-state allocations each contribute their own bytes. + let mut zp_bytes_used: std::collections::HashSet = std::collections::HashSet::new(); + let mut ram_bytes_used: std::collections::HashSet = std::collections::HashSet::new(); + for a in &allocs { + for offset in 0..a.size { + let byte = a.address + offset; + if byte < 0x80 { + 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, "Zero Page: {zp_used}/128 bytes used")?; writeln!(w, "Main RAM: {ram_used}/1280 bytes used")?; @@ -513,6 +541,7 @@ mod tests { diagnostics: Vec::new(), call_graph: HashMap::new(), max_depths: HashMap::new(), + state_local_owners: HashMap::new(), } } diff --git a/tests/emulator/goldens/platformer.audio.hash b/tests/emulator/goldens/platformer.audio.hash index e2b0778..fbb89ce 100644 --- a/tests/emulator/goldens/platformer.audio.hash +++ b/tests/emulator/goldens/platformer.audio.hash @@ -1 +1 @@ -ea23d9c4 132084 +2b03b3ec 132084 diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 6d17916..99333f0 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -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 ── /// Compile a source string using the mapper-aware linker.