Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 27 additions & 0 deletions docs/future-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions docs/language-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
Binary file modified docs/platformer.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 12 additions & 8 deletions examples/coin_cavern.ne
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Binary file modified examples/coin_cavern.nes
Binary file not shown.
45 changes: 24 additions & 21 deletions examples/platformer.ne
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────

Expand Down Expand Up @@ -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
}

Expand Down
Binary file modified examples/platformer.nes
Binary file not shown.
Binary file modified examples/state_machine.nes
Binary file not shown.
48 changes: 47 additions & 1 deletion src/analyzer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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();
Expand Down
97 changes: 96 additions & 1 deletion src/ir/lowering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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));

Expand Down
Loading
Loading