Skip to content

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.


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 with fe_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.


BoundaryWhat happensTriggered by
Cart loadFresh context. Cart’s bytecode is loaded; top-level forms run; cell types and mission templates register; grammar contributions install.Insert / select cartridge.
Cart unloadContext 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 startMission-local arena (4–8 KB per ADR-0007) opened for any scripted-mission code.Player accepts a contract.
Mission instance endMission-local arena torn down. Mission outputs (credits, reputation, capability bits) have already been committed to deck state via NoshAPI.Mission complete or abort.
REPL session24 KB context per ADR-0002.Player enters REPL; freed on exit.
nEmacs session16 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.


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):

CompartmentBudgetNotes
Fe interpreter code~12 KB binaryVendored at kn86-emulator/vendor/fe/.
FFI bridge code~5–8 KBnosh_lisp_bridge.c.
VM working state (per context)~1–2 KBEval stack + GC stack (GCSTACKSIZE = 256).
Cart arena16–32 KB, configurable per cartSized by the cart manifest; runtime allocates at load.
Mission instance arena4–8 KB per ADR-0007Scripted-mission isolation.
REPL arena24 KBADR-0002.
nEmacs arena16 KBADR-0002.
Handler slot table~6 KB worst casePer ADR-0012 — 32 cell types × 31 handlers × 8 bytes. Lives in C-side static storage, not the cart arena.

Default to 16 KB. Bump to 32 KB only if the cart genuinely needs it. Concrete sizing inputs:

  1. Cell-type registry. Each defcell adds runtime metadata, but the metadata lives in C-side CellTypeInfo storage (per ADR-0012), not the arena. Arena impact per cell type is negligible.
  2. 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.
  3. Behavior tables. Static lookup data (e.g., ICE Breaker’s threat-class tables) — count entries × ~16 bytes per pair.
  4. Procedural-generation working memory. The peak transient allocation during a (generate-...) call. Measure with the emulator’s debug overlay (F12).
  5. 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-head and 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.


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:

  1. 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.
  2. In a mission script (per ADR-0007): The mission fails with the :oom clause 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.
  3. In the REPL: The current expression aborts; the REPL returns to its prompt. History buffer is preserved.
  4. 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.
  • 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 to text-puts.
  • Use while and mutation for tight loops. A (while ...) that walks a list with setcar/setcdr does not allocate per iteration; a recursive walk that builds a new list via cons does. The former is the right shape for “process this batch.”
  • Don’t capture large state in closures. A fn body 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.

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.