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:
commit
431f144be7
14 changed files with 481 additions and 47 deletions
12
CLAUDE.md
12
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
|
||||
```
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 446 KiB After Width: | Height: | Size: 446 KiB |
|
|
@ -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 {
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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
|
||||
// `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.
|
||||
|
||||
// World/camera
|
||||
var camera_x: u8 = 0
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -31,6 +31,10 @@ pub struct AnalysisResult {
|
|||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub call_graph: HashMap<String, Vec<String>>,
|
||||
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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
61
src/main.rs
61
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<u16> = std::collections::HashSet::new();
|
||||
let mut ram_bytes_used: std::collections::HashSet<u16> = 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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
ea23d9c4 132084
|
||||
2b03b3ec 132084
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue