Skip to content

feat(vapor): overlay allocation for frame-local temporaries - #269

Merged
doodlewind merged 1 commit into
pocket-stack:mainfrom
lfkdsk:feat/vapor-overlay-alloc
Aug 15, 2026
Merged

feat(vapor): overlay allocation for frame-local temporaries#269
doodlewind merged 1 commit into
pocket-stack:mainfrom
lfkdsk:feat/vapor-overlay-alloc

Conversation

@lfkdsk

@lfkdsk lfkdsk commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Stacked on #268 (its toolchain-path commit is included here so the parity suite runs on Linux; rebases away once #268 lands).

What

Materialized view chains and string scratch used to be either permanent statics (static vp_view vt5 lives forever for one call's worth of work) or C-stack locals (21–33 B vp_sb/vp_view frames on the cc65/sdcc software stack). Both are wrong for the 8-bit targets: statics never share, stack frames cost code and cycles on every access, and on the NES the stack shares 2 KB with the pool, the computed views, and the shadow grid.

This PR compiles every frame-local temporary into a shared static overlay slot — the classic 6502/8051 overlay technique, driven by the compiler's own call graph.

How

  • Every temporary is tagged with the generated function that owns it (effect unit, computed update, handler, keymap action, helper).
  • Two temporaries may share one slot unless their owners can be live at the same time: same function, or one owner reachable from the other in the static call graph. Helpers, computed accessors, and keymap dispatch contribute edges — an indirect dispatch edges to every action in its table.
  • The subset forbids recursion and nothing runs from interrupts, so reachability is the whole liveness story. Units merged into one effect run sequentially, so their temps share slots naturally.
  • Greedy coloring assigns slots; placeholders substitute at emit time; the memory plan prints the overlay plan:
    overlay RAM: 51 B in 3 shared slots (8 frame-local temps off the C stack)
    

Measured on the todo example

target ROM RAM/stack
NES CODE 8,806 → 8,666 B (−140 B) — absolute beats stack-relative on the 6502 8 temps → 3 slots; ≥42 B of frames leave the deepest stack path; RAM/stack boundary moves up 32 B (funded by those 42 B, so stack margin grows)
GB used bytes 11,413 → 11,383 (−30 B) SM83 stack relieved; RAM impact negligible in 8 KB
GBA 9,328 → 9,356 B (+28 B) ARM stack addressing is cheap, so the slot indirection costs slightly more there — the honest cost of a uniform lowering

The structural point for the NES: string scratch and view materialization no longer scale the stack with app complexity — they scale a statically-known, deduplicated overlay region the memory plan accounts for byte-by-byte.

Tests

  • vapor/tests/overlay.test.ts: todo/NES slot inventory; mutually-exclusive keymap actions sharing one string slot; nested slice scratch interfering with its enclosing build; view temps splitting when an effect's chain reads through a computed's chain.
  • Full suite 71 tests, 7,398 assertions, 0 fail — including three-console oracle↔ROM parity, whose 31-press tape exercises every converted site (draft editing, glyph commit, filter cycling, clear-completed).
  • vapor/DESIGN.md §3 documents the overlay model.

This is also groundwork for the storage-allocation direction discussed around #267: the owner/interference machinery is the substrate a future lifetime-aware packer (field narrowing, bool bit-packing) would build on.

🤖 Generated with Claude Code

@lfkdsk
lfkdsk marked this pull request as ready for review August 13, 2026 23:14
Materialized view chains and string scratch used to be either permanent
statics (a vt slot lives forever for one call's worth of work) or C-stack
locals (21-33 B frames on the cc65/sdcc software stack). Both are wrong
for the 8-bit targets: statics never share, stack frames cost code and
cycles on every access, and on the NES the stack shares 2 KB with the
pool, the computed views, and the shadow grid.

Every temporary is now tagged with the generated function that owns it.
Two temporaries share one static slot unless their owners can be live at
the same time: same function, or one reachable from the other in the
static call graph (helpers, computed accessors, keymap dispatch - an
indirect dispatch edges to every action in the table). The subset forbids
recursion and nothing runs from interrupts, so reachability is the whole
liveness story. Greedy coloring assigns slots; placeholders substitute at
emit time; the memory plan prints the overlay plan.

Todo on NES: 8 frame-local temps collapse into 3 shared slots (51 B).
Mutually-exclusive keymap actions share string scratch; the nested slice
buffer interferes with its enclosing build and gets its own slot. Effects
measured on the todo example:

- NES: CODE 8806 -> 8666 B (-140 B ROM); >=42 B of vp_sb/vp_view frames
  leave the deepest stack path. The RAM/stack boundary moves up 32 B
  (funded by those 42 B, so stack margin grows by >=10 B).
- GB: 11413 -> 11383 used ROM bytes (-30 B); SM83 stack relieved.
- GBA: 9328 -> 9356 B (+28 B) - ARM stack addressing is cheap, so the
  slot indirection costs slightly more there; RAM impact is negligible.

Full suite including three-console oracle-ROM parity: 71 tests, 7,398
assertions, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@doodlewind
doodlewind force-pushed the feat/vapor-overlay-alloc branch from 1b452c1 to 4c38c5a Compare August 14, 2026 23:59
@doodlewind

Copy link
Copy Markdown
Collaborator

Verdict: merging. Reviewed on a local checkout; the size table, the safety argument, and the NES memory-map change were each verified independently.

All three measured claims reproduce exactly. NES CODE 8806 → 8666 B (relinked the build's kept genDir objects with ld65 -m on base and branch); GBA todo.gba 9328 → 9356 B; GB _CODE 9310 → 9257 B (the description's "used bytes 11,413 → 11,383" is a different accounting, same direction). The honest +28 B on GBA — disclosed rather than buried — is appreciated.

The sharing invariant holds structurally. The three ways a shared static could go wrong were each checked: vp_sb_slice starts with dst->len = 0, so a recycled slot carries no state; the handler and the effects never nest (app_on_button returns before the target main calls app_flush, and effects run sequentially inside it); and the "app_init" fallback owner can never actually own a temp, because the setup subset admits no free statements. Every call-like edge (helper, computed accessor, keymap dispatch fan-out) feeds the reachability relation, and the subset's no-recursion rule makes reachability the complete liveness story, as the comment says. Zero @OVL placeholders leak into any target's generated C.

The NES boundary move is real and safe. On the branch, BSS ends at $0717 — inside the new $0720 limit — and the jsnes-emulated 31-press parity tape (draft editing, glyph commit, filter cycling, clear-completed) runs green over the new layout, so the deepest converted paths are exercised behaviorally, not just argued.

Rebased onto main for you (force-pushed 4c38c5a): the stacked #268 copy dropped automatically, and the one conflict with #267 (both PRs add class fields at the same spot) resolved by keeping both. The composition is the interesting part and it works: an SCCP-folded conditional row takes #267's early return and then allocates its temps under #269's per-unit owner. Full suite on the rebased branch: 77/79, 7421 assertions — sccp, overlay, and three-console parity all green; the 2 failures are a machine-local XProtect stall on freshly compiled test binaries (they fail identically on main right now, and the binaries print ok once the ~36 s scan completes), not this PR.

One non-blocking nit for a future pass: placeholder substitution is a regex over the whole C text, so an app string literal containing @OVL0@ would be rewritten. A stray-marker assert before the replace would close it cheaply.

Thanks @lfkdsk — two compiler PRs in one week that both come with a soundness argument grounded in the subset's actual rules, measured byte tables, and tests that pin the interference cases. The owner/interference machinery reads like it will carry the lifetime-aware packing you sketched, and I'd be glad to see that follow-up.

@doodlewind
doodlewind merged commit 73b7841 into pocket-stack:main Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants