mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +00:00
analyzer+ir: automatically overlay state-local variables
Before this change, state-local variables (`state Foo { var x: u8 = 0 }`)
were silently no-ops: the analyzer allocated a ZP slot for them, but
the codegen's `var_addrs` map only covered IR globals and scope-qualified
function locals — so every `LoadVar` / `StoreVar` whose `VarId` pointed
at a state-local resolved to no address and emitted nothing. Existing
examples compiled and matched their goldens because none of them observed
the dropped writes within the 180-frame harness window.
The overlay changes the analyzer's state-local pass to snapshot both the
ZP and RAM cursors after the globals have been laid out, then rewind to
that snapshot before each state's locals and track the running max.
`ZP_CURRENT_STATE` keeps exactly one state active at runtime, so every
state's locals are mutually exclusive with every other state's and can
share the same bytes. The IR lowerer now pushes each state's locals into
the IR globals table (with `init_value=None`) so the codegen resolves
their addresses the same way it does program globals, and prepends the
declared initializers to each state's `on_enter` handler (synthesizing
an empty one where needed) so a freshly-entered state re-establishes its
bytes before user code runs.
`--memory-map` now tags each allocation with its owning state
(`[@Title]`, `[@Playing]`, ...) and counts distinct bytes rather than
summed allocation sizes so overlaid slots don't double-count. The
`AnalysisResult.state_local_owners` map exposes the ownership to any
tool that wants to group allocations the same way.
Only `state_machine.ne` and `platformer.ne` declare state-level vars,
so they're the only example ROMs whose bytes change. `platformer.ne`'s
audio golden shifts slightly (the now-working `blink` counter in Title
adds a few cycles per frame before the auto-transition to Playing, which
offsets APU register writes within each frame); its video golden and
every other example ROM stay byte-for-byte identical.
Fixes #22.
https://claude.ai/code/session_015kvJu3iEFLSRJoShPBfm3X
This commit is contained in:
parent
77d55bc16b
commit
73dcf08c7a
11 changed files with 446 additions and 19 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 |
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
|
||||
8f18a5d1 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