# Memory Module — Progress + Issues Log Tracking checkpoint for the mem.sx Zig-aligned implementation (plan: `~/.claude/plans/tidy-doodling-cray.md`). ## Last completed step - **`resolveType(null) → .s64` silent fallback removed.** `resolveType` now takes a non-optional `*const Node`; the `null → .s64` branch is gone. Callers that legitimately had no annotation handle it themselves: top-level `var_decl` at `lower.zig:630` infers from the initializer or diagnoses if neither is present (matches the `lowerVarDecl` pattern that already existed for locals). `FfiIntrinsicCall.return_type` is non-optional in the AST so its callers (`#objc_call`, `#jni_call`) didn't actually need the fallback. JNI super-call and JNI method paths were already guarded with `if (rt) |t| ... else .void`. Caller cleanup also dropped the explicit `if (x != null)` guards in favour of optional-payload syntax (`if (cd.type_annotation) |ta| ...`), which makes the always-non-null path obvious from the type. Real-world impact: `g_pi := 3.14;` at the top level used to be silently typed as `s64`. Now it infers as `f64`. Regression at `examples/137-toplevel-var-type-inference.sx` (count/pi/flag — int / float / bool inferred correctly). 159/159 example tests + chess clean. - **Phase 1.4a — IR `TypeId` threaded through `valueToLLVMConst`; string/slice fat-pointer aggregates serialize by reading host memory.** The Phase 1.4 serializer bailed on `heap_ptr` / `byte_ptr` and silently emitted `i0 0` for the trap-case where a `.int` host address landed in a ptr-typed slot. Now the call site at `emit_llvm.zig:676` passes `global.ty` (TypeId) and `&interp_inst` instead of just the LLVM type. The serializer splits on the IR type: - `string` / `slice` (fat pointer `{data, len}`): extract `len`, read that many bytes from the data field's address (heap_ptr → `interp.heapSlice`; byte_ptr/int → raw process memory via a new `readHostBytes` helper; string literal → direct slice). Emit the bytes as a private global byte array via the existing `emitConstStringGlobal` and use it as the aggregate's data ptr. - `struct`: walk the IR field types in lockstep with the value fields; recurse per field with its declared TypeId. Replaces the old LLVM-type-walk via `LLVMStructGetTypeAtIndex` which couldn't tell `string`-typed fields from generic ptr fields. - `array`: walk elements with the element TypeId. The `.int → ptr` slot mismatch (a host address landing in a non-fat-pointer ptr slot) now bails loudly with a named diagnostic — that's the genuine heap-walk frontier where future work would need to capture struct content recursively, not the silent malformed-const we had before. `Interpreter.heapSlice` was promoted from package-private to `pub` so the serializer can read interp heap. Regression at `examples/136-comptime-string-global.sx`: `GREETING :: #run build_greeting();` where `build_greeting` returns `concat("hello", " world")` — runtime prints `greeting = 'hello world' / greeting.len = 11`. Pre-1.4a this segfaulted. 158/158 example tests + chess clean on all three platforms via `tools/verify-step.sh`. - **Allocator `init` returns the state by value.** Building on the Option 3 lvalue-borrow rule, `GPA.init`, `Arena.init`, and `TrackingAllocator.init` now return `T` (not `*T`). The caller binds the local; the local IS the storage. `xx local` borrows under Option 3 so the `Allocator` protocol value's `ctx` points at the local. Saves one `libc_malloc` per allocator instance; closes the state-struct leak surface (the local goes away with its scope, no explicit `deinit` needed for the struct itself). Migration: - `library/modules/allocators.sx`: `init` signatures changed (`-> T` instead of `-> *T`); `Arena.deinit` drops the trailing `parent.dealloc(xx a)` — caller owns the storage. - `library/modules/ui/pipeline.sx`: `arena_a: Arena;` / `arena_b: Arena;` (was `*Arena;`); `@self.arena_a` / `@self.arena_b` at the use site that needs `*Arena` for the `build_arena` local. - `examples/126-xx-recover-then-dispatch.sx` updated: comparisons against `*GPA` use `@gpa` now. - `examples/135-xx-lvalue-borrows.sx` simplified: no `tracker_ptr.*` deref needed. - `examples/50-smoke.sx` Arena counts dropped by 1 each (no state-struct alloc); comments + snapshots updated to match. - CLAUDE.md "Allocator construction" rule rewritten around the by-value convention. 157/157 example tests + chess clean on macOS / iOS sim / Android (`tools/verify-step.sh` ran green). - **`xx ` borrows the operand's storage** (Option 3 in the protocol-erasure design discussion). Today's behavior — `xx ` heap-copies the value — was a silent footgun: the protocol value pointed at the heap copy, the original local stayed stale, mutations through the protocol weren't visible to the original (and vice versa). Under the new rule, when the operand names existing storage (identifier, field access, index expression, dereferenced pointer), `xx` takes its address and the protocol borrows. Heap-copy is reserved for `xx ` — struct literals, function-call results, arithmetic expressions, anything without its own storage. Single point of change at `buildProtocolErasure` in `lower.zig:10334`, via a new `isLvalueExpr` helper at `lower.zig:10322`. specs.md §3 ownership table updated. The `examples/130-...` regression that previously tested heap-copy on `xx ` now tests `xx ` (still the heap-copy path); new regression `examples/135-xx-lvalue-borrows.sx` witnesses the borrow path via TrackingAllocator. 157/157 example tests + chess clean across all three platforms (`tools/verify-step.sh` gate ran green right before this work landed). - **Phase 1.4 — `valueToLLVMConst` upgraded to handle every interp `Value` variant.** The serializer at `emit_llvm.zig:734` used to collapse anything past int/float/boolean into `LLVMConstNull(ty)` — a silent fallback that emitted `{0, 0}` for any `#run` returning a struct, string, function pointer, or pointer. Replaced with a real walk: int/float/boolean as before; null_val → LLVMConstNull; void_val/undef → LLVMGetUndef; func_ref → func_map lookup; string → emitConstStringGlobal; aggregate → recurse via `LLVMStructGetTypeAtIndex` / `LLVMGetElementType`. The unsupported cases (heap_ptr/byte_ptr/slot_ptr/closure/type_tag) now bail loudly with a named diagnostic that includes the global name (per CLAUDE.md REJECTED PATTERNS — no silent unimplemented arms). Heap_ptr is deferred to Phase 1.4a (needs IR TypeId threaded down for cyclic / recursive heap content). Also closes the type-inference half of the same bug: `NAME :: #run expr;` with no annotation used to default to `s64` (silent fallback in `resolveType(null)`). `lowerComptimeGlobal` now infers from the expression's return type when no annotation is provided. The silent fallback in `resolveType` itself is left in place for other callers — separate audit, separate session. Regression test at `examples/134-comptime-aggregate-global.sx`: a `POINT :: #run make_point();` struct binding now prints the real fields instead of zeros, on both interp and codegen paths. 156/156 example tests + chess clean. ## Current state Phase 0.0c shipped (allocator API on one-line `init` returning `*T`; TrackingAllocator added). 148/148 tests pass. Phase 0.2 verified across all known patterns. Phase 0.3 documented. Phase 0 of the MEM plan is **COMPLETE**. Fixed this session by paired sessions: issue-0038 (transitive `#import`), issue-0039 (chess + stdlib migration to explicit imports), issue-0040 (generic struct method dot-dispatch). All landed and re-verified. Full gate green: 151/151 example tests + chess on macOS / iOS sim / Android with screenshots. Phase 0 spike outcomes: - **0.2** xx cast patterns — verified across 5 additional shapes beyond the issue-0037 regression. - **0.3** chess allocator audit — documented; no chess-side migration needed. - **0.6** `align_of($T)` builtin — landed. Mirrors `size_of` in inst.zig BuiltinId, lower.zig (registry + return-type + reflection handler), interp.zig (fallback), sema.zig (allowed-builtins list), lsp/server.zig (both completion tables), library/modules/std.sx. Smoke coverage added in `examples/50-smoke.sx` (u8/s32/s64/Point). - **0.7** `#import` transitivity — surfaced and fixed via issue-0038. - **0.8** `#foreign("c")` rename syntax — confirmed `#foreign libc "name"`. - **0.9** generic UFCS dispatch — surfaced and fixed via issue-0040. Dot-call now works for struct methods with `$T: Type` parameters, so the plan's `a.create(MyType)` shape is viable. issue-0041 (pointer type as type-arg) and issue-0042 (alias names in `resolveTypeArg`) **FIXED**. Regression tests at `examples/issue-0041.sx` and `examples/issue-0042.sx`. Scratch verification: `size_of(*u8)=8`, `size_of(Ptr where Ptr::*u8)=8`, `size_of(?u8)=2`, `size_of(Maybe where Maybe::?u8)=2` — all clean on interp + codegen. Also landed during 0041/0042: the silent `.s64` fallback in `resolveTypeArg` is gone — unresolved type names now emit a real diagnostic. Surfaced and removed two bogus `size_of(Complex)` / `size_of(Sx)` calls in `examples/10-generic-struct.sx` that were relying on the silent default. Caller-side speculative paths in `buildTypeBindings` + `inferGenericReturnType` now gate the call with `type_bridge.isTypeShapedAstNode` before invoking `resolveTypeArg`. CLAUDE.md REJECTED PATTERNS gained a section forbidding silent `orelse default` returns in compiler code. Parser regressions introduced by the 0041 work fixed in `src/parser.zig:hasFnBodyAfterArrow`: - `(s: string) -> [:0]u8 { ... }` — added `.colon` to the return-type token walk. - `(x) -> Closure(...) -> R { ... }` — added `.arrow` so nested return-type arrows continue the walk. - `name :: (self: T) -> Ret;` inside `struct #compiler` — recognise trailing `;` as a method-decl terminator. Was silently dropping every `BuildOptions.*` method from `fn_ast_map`. Full gate green: 151/151 example tests + chess on macOS / iOS sim / Android with screenshots. **Implicit Context refactor SHIPPED.** Every default-conv sx function now carries `__sx_ctx: *void` at LLVM slot 0. `context.X` lookups resolve through the lowering's `current_ctx_ref` — a one-indirection load, no per-access walk, thread-safe by construction (each call stack carries its own Context chain). `push Context.{...}` allocates a fresh slot and rebinds `current_ctx_ref` for the body's lexical scope. The `context` LLVM global is gone; the only runtime Context is the static `__sx_default_context` (a CAllocator backing libc malloc/free), installed at FFI-inbound entries (`main`, `Java_*`, `JNI_OnLoad`) and used by the interp for `#run` evaluation. What landed: - `Function.has_implicit_ctx` flag + `Module.has_implicit_ctx` per-compilation switch (gated by `Context :: struct {...}` being present in the dep graph). - Param prepend + call-site forwarding across every sx-to-sx path: direct calls, indirect through fn-pointer vars, protocol dispatch, closure trampolines, lambda trampolines, bare-fn trampolines, generic monomorphizations, comptime functions. - `ConstantValue.func_ref: FuncId` variant so the static initializer for `__sx_default_context` can reference the CAllocator thunks. - emit_llvm two-pass global emission: aggregates that name funcs by FuncId resolve them after `func_map` is populated. - Interp: `defaultContextValue()` builds the Context aggregate on demand; `interp.call` bootstraps slot_ptr(0) when an entry function with implicit ctx is invoked sans args; `materializeCtxArg` derefs the caller's slot_ptr at every sx-to-sx boundary so callees can treat ref 0 as the Context aggregate; `.load` of an aggregate is passthrough; `.global_addr` of `__sx_default_context` returns the aggregate directly. - `matchContextAllocCall` is GONE (commit `d415bcc`). Comptime now runs the full Allocator-protocol dispatch chain — the same IR codegen emits — by reusing the parent module instead of spinning up a separate `ct_module`. The interp gained raw-pointer paths (`index_gep`, `index_get`, `store`, `marshalForeignArg`, `asString`) so `context.allocator.alloc` bottoms out at host `libc_malloc` and the returned pointer survives downstream sx ops. - `inst.Store` carries `val_ty: TypeId` so the interp's raw-pointer store honours the destination width — no more "assume 8 bytes" silent clobber. Regression test at `examples/132-comptime-typed-store-widths.sx` exercises every primitive width (u8/u16/u32/u64, s8..s64, bool, f32, f64) via comptime checksums compared to runtime checksums. - Call-convention mismatch at bare-fn → fn-pointer coercion is now a compile error (commit `f886d5f`). The chess-debug sweep that surfaced the bug also moved `#foreign` decls to default `callconv(.c)` and fixed every consumer-side sx callback exposed to a C API. Regression test: `examples/131-callconv-mismatch-diagnostic.sx`. - Interp silent-arm sweep (commit `e9df33a`). Every `else =>` arm has a named bail reason via `bailDetail` / `typeErrorDetail`. `.deref` and `.unbox_any` used to silently pass through arbitrary Value kinds — now enumerated. `#run const` errors no longer swallow into `void_val`; emit_llvm surfaces them via std.debug. - C-side callback into sx requires `callconv(.c)` on the sx fn (and on any fn-pointer TYPE the user casts a C fn-pointer through). Tests adjusted: `examples/61-objc-roundtrip.sx`, `examples/62-objc-class.sx`, `examples/95..97-objc-block*.sx`, `examples/ffi-06-callback.sx`. 154/154 example tests pass (two new regression tests added: 131 and 132). Chess green on macOS / iOS sim / Android. ISSUE-MEM-002 (the `context.allocator.alloc(size)` pattern-match bypass) is FULLY CLOSED. User-typed `context.allocator.X` flows through the real protocol vtable at codegen *and* runs the same chain at comptime in the interp. No remaining shortcut. ## Next step Phase 1.3 (closure env allocation through context) shipped in commit `8e21cc5`. Phase 1.4 (codegen serializer for all interp Value variants) shipped this session. Phase 1.2 (free / malloc through context) was considered and **skipped** — `context.allocator.alloc /dealloc` already works directly; wrapper-only `malloc`/`free` would be lossy renames. The `List(T)` allocator-arg work shipped earlier this session is **outside the original MEM plan** but lives in the same problem space (long-lived container growth silently capturing the wrong allocator). Open follow-ups, in roughly the order they make sense: - **`.int → ptr` heap-walk follow-up.** Phase 1.4a handles the fat-pointer aggregate case. A `.int` host-address landing in a bare ptr field (e.g. a struct with `*Inner` or `[*]u8` members) still bails. Investigated this session — the practical blocker is interp-level: `struct_gep` / load / store through raw integer pointers aren't wired (only `index_gep` is), so users can't even construct most cases. Two-step plan if a real trigger surfaces: (1) lift the interp's struct_gep+load+store-through-raw-int limit using `FieldAccess.base_type` for the field offset table; (2) add recursive heap-walk in `valueToLLVMConst` with cycle detection on `(addr, type_id)` visited pairs. No practical trigger in-tree — the canonical buffer/string case is already handled by `[]T` / `string`. ## Phase 0.3 audit findings — chess allocator usage (closed) After Step 5 / matchContextAllocCall removal, every consumer call to `context.allocator.X` flows through the real protocol vtable. This section is left for history — the audit drove which sites needed migration, but no chess code actually needed any allocator- API change. The sites that used to bypass the protocol via the `.heap_alloc` pattern-match now dispatch through the inline Allocator value naturally. - `~/projects/game/main.sx` — 7 sites of `context.allocator.alloc(size_of(T))` for platform/GPU/pipeline state. Now real protocol dispatch. - `~/projects/game/chess/game.sx` — `ChessGameState.init` captures `context.allocator` into a `parent_allocator: Allocator` field and restores it via `push Context.{ allocator = ... }`. Worked before, still works. - `~/projects/game/chess/pieces.sx` — declares its own `free` bound to libc and calls it on a libc-malloc'd buffer (from a foreign reader). Intentional C-interop bypass — no change needed. - `~/projects/game/quick.sx` — quicksort demo. Same flow as main.sx. ## Log - **2026-05-25 (latest)** — `resolveType(null) → .s64` removed. Signature changed to non-optional `*const Node`; 12 callers surveyed and classified. The three unguarded ones — top-level `var_decl` at `lower.zig:630` (now mirrors lowerVarDecl's infer-from-initializer pattern), `#objc_call` / `#jni_call` return types (already non-optional in AST so no actual silent fallback was reached) — handle null explicitly. The rest were guarded by `if (x != null)` blocks; cleaned up to optional-payload syntax. `examples/137-toplevel-var-type-inference.sx` proves the visible win: `g_pi := 3.14;` at module scope now infers `f64` (used to be silent `s64`). 159/159 + chess clean. - **2026-05-25 (penultimate)** — Phase 1.4a shipped. `valueToLLVMConst` takes IR `TypeId` (not LLVM type) + an interpreter handle. String/slice fat pointers are serialized by capturing the pointed-to bytes (via `interp.heapSlice` for heap_ptr, raw process memory via new `readHostBytes` for byte_ptr / .int / string literal) and emitting a private global byte array. Struct / array aggregates recurse with declared field/element TypeIds. The trap case (`.int` landing in a ptr slot outside a fat pointer) bails loudly. `Interpreter.heapSlice` promoted to `pub`. Regression: `examples/136-comptime-string-global.sx`. 158/158 + chess green on all three platforms. - **2026-05-25 (penultimate)** — Allocator `init` returns the state by value. GPA / Arena / TrackingAllocator all changed; `Arena.deinit` no longer self-deallocs. `UIPipeline.arena_a/_b` embedded as values; `@self.arena_a` at the *Arena use site. `examples/50-smoke.sx` Arena alloc counts dropped by 1 (no state struct alloc); FFI IR snapshots regenerated to reflect new signatures (`-> ptr` → `-> i64`/`-> void sret(...)`). CLAUDE.md "Allocator construction" rewritten around the by-value convention. 157/157 + chess green on all three platforms via `tools/verify-step.sh`. - **2026-05-25 (penultimate)** — `xx ` semantics changed to borrow. Single change at `lower.zig:10334` (`buildProtocolErasure`) gated by new `isLvalueExpr` helper at `lower.zig:10322`. specs.md §3 ownership table extended (three modes: rvalue / lvalue / pointer). `examples/130-xx-value-routes-through-context-allocator.sx` updated to use a struct literal (rvalue) as the operand — the heap-copy routing through `context.allocator` is what Phase 1.1 actually proves, and that path is still active for rvalues. New regression at `examples/135-xx-lvalue-borrows.sx` witnesses the borrow path via TrackingAllocator counts on the local. 157/157 + chess green on all three platforms (`tools/verify-step.sh` ran green immediately before this). - **2026-05-25 (penultimate)** — Phase 1.4 shipped. `valueToLLVMConst` (`emit_llvm.zig:734`) replaced the primitive-only switch with a full serializer covering null_val, void_val, undef, func_ref, string, and aggregate (struct + array via `LLVMStructGetTypeAtIndex` / `LLVMGetElementType`). Unsupported variants (heap_ptr, byte_ptr, slot_ptr, closure, type_tag) bail loudly via `std.debug.print` with the global name. The call site at line 676 now passes `global.name` so the diagnostic locates the offending `#run` site. `lowerComptimeGlobal` (`lower.zig:6384`) infers the return type from the expression when the user omits the type annotation — closes the silent-s64 default for `NAME :: #run expr;` bindings. The broader `resolveType(null) -> .s64` fallback is left in place for other callers — flagged for a follow-up audit. Regression at `examples/134-comptime-aggregate-global.sx`. 156/156 + chess green. - **2026-05-25 (penultimate)** — `List(T)` mutation API gained an optional trailing `alloc: Allocator = context.allocator` argument (`library/modules/std.sx`). Default-arg substitution previously only fired for identifier callees; extended to the generic-method dispatch path via new `appendDefaultArgs` helper at `lower.zig:7974-7991`, wired in at `lower.zig:5332`. Long-lived owners that grew internal Lists during render — `GlyphCache`, `DockInteraction`, `StateStore`, `Gles3Gpu`, `MetalGPU` — now capture `parent_allocator: Allocator` at init and forward it to every internal `.append` / `.alloc` / `.dealloc`. Chess panel-text regression (text vanished after the first move because GlyphCache hash + entries grew into the per-frame arena and died on reset) fixed end-to-end on macOS. specs.md §11 gains a "Default Parameter Values" subsection documenting the existing capability. Operator- precedence fix kept in `pipeline.sx` (`(self.frame_index & 1) == 0` instead of `self.frame_index & 1 == 0`, which was parsing as `self.frame_index & (1 == 0)` = always 0). All diagnostic logging added during the bug hunt has been stripped. 155/155 example tests green. - **2026-05-25 (late)** — Interp silent-arm sweep (`e9df33a`). Every `else =>` arm has a `bailDetail` reason; `.deref` / `.unbox_any` previously silently passed through arbitrary Value kinds, now enumerated. `#run const` errors surface a real diagnostic via emit_llvm instead of becoming `void_val`. CLAUDE.md REJECTED PATTERNS gained the "silent unimplemented arms" section (`4de565b`). 154/154 + chess green. - **2026-05-25 (mid)** — Typed raw-pointer stores (`f2b3868`). `inst.Store` carries `val_ty: TypeId`, threaded by `builder.store` and consumed by `storeAtRawPtr` to write exactly the declared destination width. Regression at `examples/132-comptime-typed-store-widths.sx` exercises every primitive width via comptime/runtime checksum comparison. `index_get` raw-pointer arm added (was bailing). Comptime init errors no longer swallow into zero. - **2026-05-25 (mid)** — Drop `matchContextAllocCall` (`d415bcc`). Comptime now runs the full Allocator-protocol dispatch chain by reusing the parent module instead of spinning up a fresh ct_module. Interp gained `.int` / `.byte_ptr` arms in `index_gep`, `store`, `marshalForeignArg`, `asString`. Closes ISSUE-MEM-002 fully. JNI stub binding extended to call current_ctx_ref → &__sx_default_context (used to be gated on isExportedEntryName). - **2026-05-25 (mid)** — Reject call-conv mismatch at bare-fn → fn-pointer coercion (`f886d5f`). `#foreign` decls now default to `callconv(.c)`. Library audit (`619aff8`) — all C-side callbacks already followed the rule; documented the one remaining gap (`xx : *void` cast to opaque, ambiguous from cast alone). Regression at `examples/131-callconv-mismatch-diagnostic.sx`. - **2026-05-25 (early)** — Implicit-Context refactor SHIPPED end-to-end. All 9 plan steps (`lets-see-options-for-merry-dijkstra.md`) landed. `context` is no longer an LLVM global; every sx function carries `__sx_ctx` at slot 0; `context.X` reads load through `current_ctx_ref`; `push Context.{...}` is alloca + rebind; FFI-inbound entries install `&__sx_default_context`; interp bootstraps the default Context on top-level call. 152/152 + unit tests green. Commits: `29784c2` (Steps 1-2), `92c6b47` (Step 3), `4bf5908` (Steps 5-7), `b69a2ea` (Step 8). - **2026-05-24** — Phase 1.1 shipped: `buildProtocolValue` heap-copy now routes through `context.allocator.alloc` via the new `allocViaContext` helper. Regression at `examples/130-xx-value-routes-through-context-allocator.sx` proves a `Tracer` installed via `push Context` sees the alloc (`Tracer.count = 1`) — interp + codegen parity. 152/152 + chess green. - **2026-05-24** — issue-0041 and issue-0042 both fixed end-to-end. Also removed the silent `.s64` fallback in `resolveTypeArg`, guarded the two upstream callers (`buildTypeBindings`, `inferGenericReturnType`) with `type_bridge.isTypeShapedAstNode`, and fixed three parser regressions introduced by the 0041 work (`[:0]u8` return type, nested return-arrows, `struct #compiler` trailing-`;` method decls). Full verify-step.sh gate green. CLAUDE.md REJECTED PATTERNS gained the silent-fallback-defaults prohibition. Stream now READY for Phase 1. - **2026-05-24** — Phase 0.6 shipped (`align_of($T)` builtin). Touchpoints: `inst.zig` BuiltinId, `lower.zig` registry + return-type table + reflection handler, `interp.zig` fallback, `sema.zig` builtin allowlist, `lsp/server.zig` both completion tables, `library/modules/std.sx`. Smoke coverage added in `examples/50-smoke.sx`. 151/151 + chess green on all platforms. Then `size_of(*u8)` parse error was investigated — filed as issue-0041 (pre-existing, affects both `size_of` and `align_of`). Stream paused on 0041. Also tightened CLAUDE.md IMPASSIBLE RULES to close the "pre-existing / non-blocking" loophole that almost let this session roll past the issue filing. - **2026-05-24** — issue-0040 filed. Phase 0.9 verified that `obj.method(Type)` with a `$T: Type` parameter fails to dispatch via dot, while explicit static (`T.method(obj, Type)`) and pipe (`obj |> T.method(Type)`) both work. Root cause pinpoint: `src/ir/lower.zig:5066-5123` has branches for generic-template struct methods (5082) and non-generic qualified (5106), but no branch for a non-template struct with a generic method. Stream paused on 0040. - **2026-05-24** — Phase 0.8 `#foreign("c")` syntax verified. Form is `name :: #foreign libc ["c_name"]` with the optional string literal supplying a rename. Confirmed via `libc_strlen :: (s: *u8) -> usize #foreign libc "strlen";` scratch test (interp + codegen parity). - **2026-05-24** — issue-0039 fix verified, full `tools/verify-step.sh` gate green again: 150/150 example tests pass and chess builds + screenshots OK on macOS / iOS sim / Android. - **2026-05-24** — issue-0038 fix verified; spike now errors as expected on transitive references. 149/149 example tests pass (+1 vs pre-fix). Chess build broken as predicted — three-bucket triage written up in `issues/0039-chess-needs-explicit-imports- post-0038.md`. Stream paused on 0039. - **2026-05-24** — Phase 0.7 spike at `/tmp/sx-import-spike/` (`a.sx → b.sx → c.sx`) showed `a.sx` calls `c_only_fn()` and reads `c_only_const` directly. Filed as issue-0038. Stream is paused per the IMPASSIBLE RULE — no workaround, the libc hide-by-internal- module strategy in the plan depends on the language semantics matching the assumption. - **2026-05-24** — Phase 0.2 sanity sweep landed. Five additional xx cast patterns exercised through `tools/scratch.sh`; all show interp/codegen parity. Combined with the issue-0037 regression at `examples/126-xx-recover-then-dispatch.sx`, the cast story is now considered tight for the cases the MEM plan relies on. - **2026-05-24** — Phase 0.3 chess allocator audit recorded above. No chess-side migration needed; ISSUE-MEM-002 (`context.allocator` bypass) is the only thing the chess codebase is exposed to and Phase 1 already owns it. - **2026-05-24** — Phase 0.0a (`tools/verify-step.sh`) shipped. Confirmed working: 145/145 example tests + chess builds + screenshots on all 3 platforms. Initial 3-second screencap delay was too short for Android — increased to 6 seconds; iOS sim + macOS to 5 seconds. - **2026-05-24** — Phase 0.0b (`tools/scratch.sh`) shipped. Verified with a hello-world snippet: interp + codegen agree. - **2026-05-24** — Phase 0.0c initial implementation of `TrackingAllocator` in `library/modules/allocators.sx`. Build + 145 tests pass after snapshot regen. Chess green on all 3 platforms. Manual scratch.sh test confirms counters increment correctly when called directly on the tracker variable. - **2026-05-24** — ISSUE-MEM-007 fixed in `src/ir/lower.zig`. Root cause: `emitProtocolDispatch` keyed the auto-unbox path on `mi.ret_type == void_ptr`, but `*void` is overloaded — both Self-disguised-as-*void AND a literal `-> *void` return appear as the same `TypeId`. With `target_type` leaking from the enclosing function's return type (e.g. `main -> s32`), every `*void` return was loaded as `s32`, yielding 0 → null. Fix: stash `ret_is_self` on `ProtocolMethodInfo` during `registerProtocolDecl` (set when the AST return-type node is the `Self` type-expr), and gate the unbox on that flag. Regression at `examples/99-protocol-void-pointer-return.sx`. Sister symptom (SIGTRAP inside struct-static method storing an Allocator field) also fixed by the same change. - **2026-05-24** — issue-0037 fixed in `src/ir/lower.zig`. Root cause: `lowerXX` had a Concrete→Protocol erasure branch but no inverse Protocol→pointer recovery — the cast fell through to `coerceToType`, which couldn't match the (struct, pointer) shape and returned the operand unchanged. Result: a 24-byte protocol struct stored into an 8-byte ptr alloca, corrupting adjacent stack (the protocol value's own slot was the next victim, so the next dispatch loaded garbage and crashed). Fix: when src is a protocol value and dst is a pointer, emit `struct_get` of field 0 (ctx), then bitcast `*void` → dst. Regression at `examples/126-xx-recover-then-dispatch.sx`. ## Known issues (discovered during execution) ### ISSUE-MEM-001: Type inference defaults `p := malloc(64)` to `s64` **Severity:** medium (workaround exists; bites unexpectedly). **Symptom:** Writing `p := malloc(64)` (no explicit type) infers `p: s64` instead of `p: *void`. Subsequent `free(p)` then fails LLVM verification with "Call parameter type does not match function signature!" because `free` expects `ptr` but receives `i64`. **Workaround:** Explicit type annotation `p : *void = malloc(64);` or `xx malloc(64);` at the call site. **Reproduction:** ```sx main :: () -> s32 { p := malloc(64); // p inferred as s64 free(p); // LLVM verify fails: ptr expected, i64 given 0; } ``` **Root cause:** Likely in the inference path for `:=` declarations when the RHS is a `*void`-returning #builtin. The compiler defaults the binding to s64 instead of matching the return type. To investigate in a future session. **Status:** Open. Not blocking mem.sx work but worth fixing as a quality-of-life issue. File as `examples/issue-NNNN.sx` when addressed. ### ISSUE-MEM-002: `context.allocator.alloc/dealloc` bypasses protocol dispatch **Severity:** high (breaks the entire Phase 1 premise; documented fix in Phase 1). **Symptom:** Any code that goes through `context.allocator.alloc(size)` or `context.allocator.dealloc(ptr)` is pattern-matched in [src/ir/lower.zig:5137-5159](src/ir/lower.zig#L5137-L5159) and lowered directly to `.heap_alloc`/`.heap_free` IR — which calls libc malloc/free. The protocol-value vtable is bypassed entirely. This means a `push Context { allocator = my_tracker }` block followed by `context.allocator.alloc(size)` does NOT call the tracker's `alloc` method. The tracker sees zero allocations even though many occurred. **Workaround:** Call the allocator directly via a variable rather than via `context.allocator`: ```sx push Context.{ allocator = tracker, data = null } { p := tracker.alloc(64); // works — dispatches through tracker tracker.dealloc(p); } ``` But this defeats the purpose of context-allocator overriding for user code that doesn't know about the tracker. **Fix:** Phase 1 of the mem.sx plan removes this pattern-match and replaces it with proper context dispatch through the Allocator protocol. After Phase 1, `context.allocator.alloc(size)` correctly dispatches to whatever allocator is currently set in context. **Status:** Documented in plan; fixed in Phase 1. Blocks auto-tracker-wrap (Phase 5 `--leak-check`). ### ISSUE-MEM-003: 08-types test depends on undefined memory contents **Severity:** low (flaky test exposes itself when allocator code changes). **Symptom:** `examples/08-types.sx` declares a struct field `c : u8 = ---;` (uninitialized) and prints the struct. The expected snapshot captures a specific value (formerly `c: 176`, now `c: 8`) which depends on whatever's in undefined memory at that moment. Any allocator change shifts the value. **Workaround:** Regenerate snapshot via `bash tests/run_examples.sh --update` when this test fails for unrelated reasons. **Fix:** Test should be rewritten to NOT depend on undefined memory content — perhaps verify the field is one of N specific values, or just don't print uninitialized data. Out of scope for mem.sx. **Status:** Open. Document and live with it for now. ### ISSUE-MEM-004: Adding code to allocators.sx shifts JNI/Obj-C IR snapshots **Severity:** medium (eats time per step). **Symptom:** Every additive change to allocators.sx (new struct, new method) cascades into ~11 IR snapshot diffs across the `tests/expected/ffi-{jni,objc}-*.ir` files. Each step needs `--update` + `git diff` review. The diffs are usually benign (additive declarations) but one (`ffi-objc-call-06-sret-return.ir`) reorganises ~1500 lines because it uses reflection on the full type registry. **Workaround:** Per-step `--update` + diff review. **Fix:** Phase 0.1 spike — extend `normalize_ir()` in `tests/run_examples.sh` to strip allocator-related declarations and constant-pool renumberings from snapshots. Should make additive changes invisible at the snapshot level while preserving JNI/Obj-C ceremony signal. **Status:** Mitigated; structural fix in Phase 0.1. ### ISSUE-MEM-005: Two-line `create(@storage)` pattern for allocators **Severity:** low (cosmetic but real DX friction). **Symptom:** Current pattern requires a separate storage decl + a create call: ```sx g_gpa : GPA = ---; libc := GPA.create(@g_gpa); ``` Two lines per allocator. Plan committed to one-line via heap-copy: ```sx libc := GPA.create(); // heap-copies via xx value ``` **Fix in progress:** Phase 0.0c — switch `create` methods on at least TrackingAllocator (and possibly GPA/Arena/BufAlloc) to use `xx value` heap-copy. Add `instance(a) -> *T` accessor for types where users need the underlying state (TrackingAllocator). **Status:** In progress (active work). ### ISSUE-MEM-007: Protocol dispatch on `*void`-returning methods returns null (FIXED) **Severity:** was CRITICAL; resolved. **Root cause:** `emitProtocolDispatch` in `src/ir/lower.zig` gated its auto-unbox path on `mi.ret_type == void_ptr`, but the same `TypeId` covers both Self-disguised-as-*void and a literal `-> *void`. With `target_type` leaking from the surrounding function (e.g. `main -> s32`), every protocol call returning `*void` got its result loaded as `sizeof(target_type)` bytes — for `s32` that's the first 4 bytes of the malloc'd block, which were zero, comparing equal to null. **Fix:** Tag `ProtocolMethodInfo` with `ret_is_self: bool`, set in `registerProtocolDecl` when the AST return-type is the `Self` type-expr. `emitProtocolDispatch` only takes the auto-unbox path when `ret_is_self` is true. Literal `*void` returns are now passed through unchanged. **Regression:** `examples/99-protocol-void-pointer-return.sx`. The sister symptom (SIGTRAP from protocol dispatch inside a struct static method that stores an Allocator field) was the same root cause and is also fixed. **Status:** Resolved 2026-05-24. ### ISSUE-MEM-006: Android screencap needs 6+ seconds; iOS sim + macOS 5+ **Severity:** low (fixed in tooling). **Symptom:** Initial `verify-step.sh` used `sleep 3` before screencap. Android needed longer for the side panel to render; without it, the screenshot showed only the chess board and a black strip where the side panel should be. **Fix:** Updated `verify-step.sh` delays: macOS 5s, iOS sim 5s, Android 6s. Documented inline in the script. **Status:** Resolved.