Fe Lisp — Memory Model
Fe runs against a fixed-size memory arena per Lisp context, with reset boundaries instead of incremental garbage collection. This page is the implementer-facing rule book: how arenas are sized, when they reset, what’s safe to hold across resets, and how cartridges defend against exhaustion. Cartridge authors and runtime engineers read this before writing anything that allocates.
../../../adr/ADR-0004-vm-selection.md— Fe selection, memory budget, arena-allocation discipline.../../../adr/ADR-0001-embedded-lisp-scripting-layer.md— original arena policy and boundary semantics.../../../adr/ADR-0012-lisp-slot-table-widening.md— handler-table sizing (per-cell-type, ~6 KB worst case).language-reference.mdandbuiltins.md— what allocates and when.
Arena, not GC
Section titled “Arena, not GC”The platform’s contract per ADR-0001 / ADR-0004 is arena allocation, no GC pauses observable to gameplay. Fe upstream is a mark-sweep GC over a fixed-size object pool, but the integration uses Fe at coarse scope boundaries:
- A Lisp context (
fe_Context*) owns one arena, allocated by the host withfe_open(ptr, size). - The arena is fully reset by tearing the context down (
fe_close) and re-opening at boundaries the runtime defines. - Fe’s mark-sweep GC still runs inside a context, but the cart authoring model treats GC as a no-op: do not allocate at a rate that requires the collector to keep up. Arena exhaustion within a context is a runtime error, not a recoverable hiccup.
The result: from the cart’s point of view, the arena is bump-allocated until a boundary; at the boundary, everything in it is gone.
Arena boundaries
Section titled “Arena boundaries”| Boundary | What happens | Triggered by |
|---|---|---|
| Cart load | Fresh context. Cart’s bytecode is loaded; top-level forms run; cell types and mission templates register; grammar contributions install. | Insert / select cartridge. |
| Cart unload | Context torn down. All cart state, handlers, and grammar contributions are dropped. Coherence stack and event memory are runtime-owned and survive. | Cart eject, swap, or device sleep below the arena-retain threshold. |
| Mission instance start | Mission-local arena (4–8 KB per ADR-0007) opened for any scripted-mission code. | Player accepts a contract. |
| Mission instance end | Mission-local arena torn down. Mission outputs (credits, reputation, capability bits) have already been committed to deck state via NoshAPI. | Mission complete or abort. |
| REPL session | 24 KB context per ADR-0002. | Player enters REPL; freed on exit. |
| nEmacs session | 16 KB editor arena per ADR-0002. | Player enters nEmacs; freed on exit. |
Cart code does not control these boundaries. The runtime decides when to reset; carts must not assume an object survives one.
Memory budgets
Section titled “Memory budgets”Per ADR-0004 and ADR-0001 (originally sized for RP2350’s 520 KB SRAM; trivially satisfied on the Pi Zero 2 W’s 512 MB but retained as design discipline):
| Compartment | Budget | Notes |
|---|---|---|
| Fe interpreter code | ~12 KB binary | Vendored at kn86-emulator/vendor/fe/. |
| FFI bridge code | ~5–8 KB | nosh_lisp_bridge.c. |
| VM working state (per context) | ~1–2 KB | Eval stack + GC stack (GCSTACKSIZE = 256). |
| Cart arena | 16–32 KB, configurable per cart | Sized by the cart manifest; runtime allocates at load. |
| Mission instance arena | 4–8 KB per ADR-0007 | Scripted-mission isolation. |
| REPL arena | 24 KB | ADR-0002. |
| nEmacs arena | 16 KB | ADR-0002. |
| Handler slot table | ~6 KB worst case | Per ADR-0012 — 32 cell types × 31 handlers × 8 bytes. Lives in C-side static storage, not the cart arena. |
Sizing a cart arena
Section titled “Sizing a cart arena”Default to 16 KB. Bump to 32 KB only if the cart genuinely needs it. Concrete sizing inputs:
- Cell-type registry. Each
defcelladds runtime metadata, but the metadata lives in C-sideCellTypeInfostorage (per ADR-0012), not the arena. Arena impact per cell type is negligible. - Cipher grammar contributions. Vocabulary pools, productions, mode biases, style deltas (see
software/runtime/cipher-voice.md§10). These live in the cart arena while the cart is active. A cart contributing ~50 vocabulary terms + ~20 productions occupies ~2–4 KB. - Behavior tables. Static lookup data (e.g., ICE Breaker’s threat-class tables) — count entries × ~16 bytes per pair.
- Procedural-generation working memory. The peak transient allocation during a
(generate-...)call. Measure with the emulator’s debug overlay (F12). - Strings. Each unique string atom occupies its byte length plus per-buffer overhead (Fe stores strings as cons-chains of 7-byte buffers, see
language-reference.md). Don’t construct strings in tight loops.
If you can’t bound transient allocation analytically, profile under a 16 KB cap and bump only when you see exhaustion.
Pointer / value discipline across boundaries
Section titled “Pointer / value discipline across boundaries”Rule: nothing crosses a boundary except deck state. Universal Deck State (handle, credits, reputation, cartridge history, phase chain) is owned by the runtime, persisted on the device’s microSD per ADR-0011, and accessed through NoshAPI primitives — not as Fe values that survive a reset.
Specifically:
- Cart-load survives nothing from a previous cart. Cart values are gone when the cart unloads. Anything the cart needs to persist must go through NoshAPI deck-state writes (e.g.,
set-capability,award-credits,modify-reputation). - Mission-instance arena survives nothing of its own. Mission outputs commit to deck state via the runtime before teardown.
- REPL and nEmacs do not write to deck state. Per ADR-0002, the REPL has read-only access to deck-state fields. Edits in nEmacs save Lisp source to deck state via the editor’s explicit save action; the editor arena itself does not persist.
- Coherence stack and event memory store (CIPHER-LINE) are runtime-owned across cart unloads — but those structures are not Fe values. Carts read them through
cipher-stack-headand similar NoshAPI accessors; they cannot hold direct Fe references to runtime memory.
If a cart constructs a Fe object and hands it to NoshAPI, the FFI layer copies what it needs into runtime-owned storage immediately. The cart must not assume the FFI keeps a live reference. Conversely, a cart receiving an object from NoshAPI (e.g. a cell handle as FE_TPTR) holds a tagged pointer whose lifetime is bounded by the runtime — typically the cell pool or mission instance.
Special case: setcar / setcdr on shared structure
Section titled “Special case: setcar / setcdr on shared structure”Fe allows in-place mutation. Mutating a pair inside the cart arena is fine; mutating something handed to you by the runtime FFI (an opaque FE_TPTR) is generally undefined — there’s no source the FFI can mutate, and the bridge does not unmarshal pointers as live pairs. Treat anything that came in as FE_TPTR as opaque.
Failure mode: arena exhaustion
Section titled “Failure mode: arena exhaustion”When cons (or any constructor) cannot find a free object after a GC pass, Fe raises "out of memory" via fe_error. The KN-86 error handler catches this and:
- In a cart event handler: The handler aborts. The runtime logs the error, plays
sfx-error, and returns control to the dispatch loop. Cart state is whatever it was at the start of the handler — there is no rollback. The cart should treat handler abort as “this input did nothing” and the player should see no other change. - In a mission script (per ADR-0007): The mission fails with the
:oomclause set. Per ADR-0007 §“Safe Termination Guarantees,” the script’s arena is bounded at 4–8 KB and timed out at 1 s. Memory exhaustion is a normal failure mode for ill-written scripts. - In the REPL: The current expression aborts; the REPL returns to its prompt. History buffer is preserved.
- In nEmacs: The current edit step aborts; the editor returns to its previous well-formed buffer state. The structural editor’s invariants ensure the buffer never goes to a malformed state from an OOM mid-edit.
Defensive cartridge patterns
Section titled “Defensive cartridge patterns”- Pre-allocate at cart-load. Build long-lived data structures (mission templates, vocabulary pools) once during cart init. Don’t re-allocate on every event.
- Avoid string concatenation in handlers.
(text-printf ...)from NoshAPI does its own snprintf without arena allocation; prefer it over building a Lisp string and passing totext-puts. - Use
whileand mutation for tight loops. A(while ...)that walks a list withsetcar/setcdrdoes not allocate per iteration; a recursive walk that builds a new list viaconsdoes. The former is the right shape for “process this batch.” - Don’t capture large state in closures. A
fnbody that closes over a multi-KB list extends that list’s lifetime to the closure’s. If the closure registers as a handler (lifetime = cart-load), so does the list. - Profile. Run the cart in the emulator with the F12 debug overlay enabled. Watch arena occupancy at peak. If you’re at 80%+ of your declared budget, either bump the budget or trim allocations.
When to bump from 16 KB to 32 KB
Section titled “When to bump from 16 KB to 32 KB”Per ADR-0001’s per-cart envelope. Bump only when you can point to a concrete pressure source:
- Cipher grammar > ~6 KB of vocabulary + productions. The vocabulary-heavy cartridges (The Vault, Cipher Garden, Black Ledger) are the realistic candidates.
- Procedural generation peaks > ~6 KB transient. Network-topology generation in ICE Breaker or maze synthesis in NeonGrid can spike here.
- Mission template count > ~20. Each template carries a Lisp lambda for its generator and acceptance contract.
If none of these apply, stay at 16 KB and use the headroom for incidental allocations.