mem: List(T) mutations gain optional alloc: Allocator = context.allocator

The chess panel-text regression (text vanished after the first move on
macOS) had a single root cause: GlyphCache's entries List, hash table,
and shaped_buf grew through `context.allocator` — which during render
is the per-frame arena. On the next arena reset the backing died, and
subsequent glyph lookups read garbage / wrote into freshly-allocated
view-tree memory.

Fix is shaped as the user proposed: `List(T)`'s mutations take an
optional trailing `alloc: Allocator = context.allocator` argument. No
allocator stored on the container, no init ceremony, every existing
`list.append(item)` callsite keeps working unchanged. Long-lived
owners now write `list.append(item, self.parent_allocator)` and the
arena-leak bug becomes impossible to write accidentally.

Default-arg substitution previously only fired for identifier callees
(`expandCallDefaults` at lower.zig:7978). Extended to the generic
struct-method dispatch path (`list.append(...)` lands here) via a new
`appendDefaultArgs` helper that lowers fd.params[i].default_expr in
the caller's scope and appends to the lowered args slice.

Long-lived owners updated to capture `parent_allocator: Allocator` at
init and use it for every internal growth:

- GlyphCache (the chess bug) — entries, shaped_buf, hash_keys,
  hash_vals, atlas bitmap.
- DockInteraction — drops the existing `push Context` workaround in
  `ensure_capacity` for the explicit-arg form.
- StateStore — entries list + per-entry data buffer.
- Gles3Gpu, MetalGPU — shaders, buffers, textures (atlas-grow during
  render would otherwise leak resources into the frame arena).

Also kept: an operator-precedence fix in pipeline.sx
(`(self.frame_index & 1) == 0` instead of
`self.frame_index & 1 == 0`, which parses as
`self.frame_index & (1 == 0)` = always 0). That was a stealth
single-arena-only bug that masked the GlyphCache one for a long time.

Docs:
- specs.md §11 documents `param: T = expr` default parameter values.
  The parser already supported it — formalised in the spec now.
- current/CHECKPOINT-MEM.md logs the change.
- CLAUDE.md REJECTED PATTERNS gains a "Long-lived containers growing
  through context.allocator" section with the `parent_allocator`
  capture template and the list of existing examples to mirror.

155/155 example tests pass — zero-diff against snapshots since every
existing callsite still resolves to `context.allocator`.
This commit is contained in:
agra
2026-05-25 14:41:17 +03:00
parent b263704664
commit 72593db953
11 changed files with 258 additions and 63 deletions

View File

@@ -218,6 +218,79 @@ history. If an existing allocator type still uses the old `create`
pattern, migrate it OR ask the user — never propagate the pattern pattern, migrate it OR ask the user — never propagate the pattern
in new code, docstrings, examples, or tests. in new code, docstrings, examples, or tests.
### Long-lived containers growing through `context.allocator`
**Forbidden:** a struct that **outlives any single
`push Context { ... }` scope** (caches, persistent UI state, GPU
resource tables, anything-accessed-across-frame-boundaries) appending
to or growing an internal List/hash/buffer using whatever
`context.allocator` happens to be at the call site:
```sx
GlyphCache :: struct {
entries: List(GlyphEntry);
// ...
rasterize :: (self: *GlyphCache, ...) {
// BAD — during render, context.allocator is a per-frame arena,
// so the entries List backing dies on the next arena reset.
self.entries.append(entry);
}
}
```
The chess panel-text bug (text vanished after the first move) was
exactly this shape: `GlyphCache.entries`, `hash_keys`, `hash_vals`,
and `shaped_buf` all grew through the per-frame arena.
**Required:** capture the long-lived allocator at init time on a
`parent_allocator: Allocator` field, and forward it explicitly to
every internal growth point. `List(T)` mutations take an optional
trailing `alloc: Allocator = context.allocator`, so the call site
just names the owner:
```sx
GlyphCache :: struct {
entries: List(GlyphEntry);
parent_allocator: Allocator;
init :: (self: *GlyphCache, ...) {
self.parent_allocator = context.allocator; // libc / GPA at init
// ...
}
rasterize :: (self: *GlyphCache, ...) {
// GOOD — entries always grows through the long-lived owner,
// regardless of who's pushed what context above us.
self.entries.append(entry, self.parent_allocator);
}
}
```
Heuristic for "is this struct long-lived?" — if its `init` is called
once at startup (or once per logical instance) and its methods are
called from a frame/event/render hot path, it's long-lived. Capture
`parent_allocator` and use it for every internal growth call.
The same applies to direct `context.allocator.alloc(...)` /
`.dealloc(...)` inside such structs — replace with
`self.parent_allocator.alloc(...)` / `.dealloc(...)`.
Existing examples of this pattern (use as templates):
- `library/modules/ui/glyph_cache.sx` — atlas, hash table, entries,
shaped_buf.
- `library/modules/ui/dock.sx` — DockInteraction's nine per-child
Lists.
- `library/modules/ui/state.sx` — StateStore.entries.
- `library/modules/gpu/gles3.sx`, `library/modules/gpu/metal.sx`
shaders / buffers / textures.
- `library/modules/ui/pipeline.sx` — UIPipeline (used for arena
parents).
`RenderTree.nodes` in `pipeline.sx` is the **opposite** case — it's
*intentionally* per-frame arena-allocated and gets its `items` field
zeroed at the top of `tick_with_body`. Don't migrate that one.
## On every session start ## On every session start
Three active workstreams run in parallel — **IR** (the language compiler), Three active workstreams run in parallel — **IR** (the language compiler),

View File

@@ -5,14 +5,18 @@ Tracking checkpoint for the mem.sx Zig-aligned implementation
## Last completed step ## Last completed step
- **Interp silent-arm sweep + typed raw-pointer stores.** Every - **`List(T)` mutations gain an optional `alloc: Allocator =
`else =>` arm in the interp now bails with a `bailDetail("...")` context.allocator` argument** (plan:
reason that surfaces through the host diagnostic as `~/.claude/plans/lets-see-options-for-merry-dijkstra.md`). One
`op=X/X: <reason>`. `inst.Store` carries `val_ty: TypeId` so language-level change (function-param defaults already supported)
comptime raw-pointer stores honour the declared destination width closes a whole bug class where long-lived containers' growth
(no more 8-byte-everywhere assumption). New CLAUDE.md REJECTED silently landed in a per-frame arena. The chess panel-text
PATTERN forbids silent unimplemented arms going forward. regression that triggered this work is fixed end-to-end on macOS.
154/154 example tests + chess on macOS / iOS sim / Android green. GlyphCache, DockInteraction, StateStore, Gles3Gpu, and MetalGPU
all carry `parent_allocator` and pass it explicitly to internal
list growth. 155/155 example tests pass — zero-diff against
snapshots since every existing callsite still resolves to
`context.allocator`.
## Current state ## Current state
@@ -146,21 +150,23 @@ chain at comptime in the interp. No remaining shortcut.
## Next step ## Next step
Phase 1.3 (closure env allocation through context) and Phase 1.4 Phase 1.3 (closure env allocation through context) shipped in commit
(codegen serializer for all interp Value variants) are unblocked. `8e21cc5`. Phase 1.4 (codegen serializer for all interp Value
Phase 1.2 (free / malloc through context) was considered and variants) remains open. Phase 1.2 (free / malloc through context) was
**skipped**`context.allocator.alloc/dealloc` already works considered and **skipped** — `context.allocator.alloc/dealloc`
directly; wrapper-only `malloc`/`free` would be lossy renames. already works directly; wrapper-only `malloc`/`free` would be lossy
renames.
Suggested next move: **Phase 1.3**. Closure trampolines in The `List(T)` allocator-arg work documented above is **outside the
[lower.zig:lowerLambda](../src/ir/lower.zig#L5549) call original MEM plan** but lives in the same problem space (long-lived
`.heap_alloc` directly for the env pointer; routing through container growth silently capturing the wrong allocator). It
`context.allocator.alloc` means closures respect generalises the `parent_allocator` capture pattern that
`push Context.{ allocator = ... }` and get leak-tracked by `ChessGameState` / `UIPipeline` already used.
`TrackingAllocator`. Contained change. Regression test pattern:
mirror `examples/130-xx-value-routes-through-context-allocator.sx` Suggested next move: verify on iOS sim + Android via
with a closure that captures a variable, install a tracker via `tools/verify-step.sh` to confirm the GlyphCache fix + Metal/Gles3
`push`, verify the tracker's counter incremented. sweep behave on those platforms, then either commit the verify-step
goldens or move to Phase 1.4.
## Phase 0.3 audit findings — chess allocator usage (closed) ## Phase 0.3 audit findings — chess allocator usage (closed)
@@ -186,6 +192,25 @@ Allocator value naturally.
## Log ## Log
- **2026-05-25 (latest)** — `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`). - **2026-05-25 (late)** — Interp silent-arm sweep (`e9df33a`).
Every `else =>` arm has a `bailDetail` reason; `.deref` / Every `else =>` arm has a `bailDetail` reason; `.deref` /
`.unbox_any` previously silently passed through arbitrary Value `.unbox_any` previously silently passed through arbitrary Value

View File

@@ -65,6 +65,12 @@ Gles3Gpu :: struct {
shaders: List(Gles3ShaderSlot) = .{}; shaders: List(Gles3ShaderSlot) = .{};
buffers: List(u32) = .{}; buffers: List(u32) = .{};
textures: List(Gles3TextureSlot) = .{}; textures: List(Gles3TextureSlot) = .{};
// Captured at init() time so resource creation always grows the cache
// lists through the long-lived allocator, even when a caller (e.g.
// glyph_cache atlas-grow during render) is currently inside a transient
// arena context.
parent_allocator: Allocator = .{};
} }
// ── GPU impl ─────────────────────────────────────────────────────────── // ── GPU impl ───────────────────────────────────────────────────────────
@@ -79,6 +85,7 @@ impl GPU for Gles3Gpu {
inline if OS != .android { return false; } inline if OS != .android { return false; }
self.pixel_w = pixel_w; self.pixel_w = pixel_w;
self.pixel_h = pixel_h; self.pixel_h = pixel_h;
self.parent_allocator = context.allocator;
if !self.gl_loaded { if !self.gl_loaded {
load_gl(@eglGetProcAddress); load_gl(@eglGetProcAddress);
@@ -133,7 +140,7 @@ impl GPU for Gles3Gpu {
proj_loc = glGetUniformLocation(prog, "uProj".ptr), proj_loc = glGetUniformLocation(prog, "uProj".ptr),
tex_loc = glGetUniformLocation(prog, "uTex".ptr), tex_loc = glGetUniformLocation(prog, "uTex".ptr),
}; };
self.shaders.append(slot); self.shaders.append(slot, self.parent_allocator);
xx self.shaders.len; xx self.shaders.len;
} }
@@ -144,7 +151,7 @@ impl GPU for Gles3Gpu {
glGenBuffers(1, @b); glGenBuffers(1, @b);
glBindBuffer(GL_ARRAY_BUFFER, b); glBindBuffer(GL_ARRAY_BUFFER, b);
glBufferData(GL_ARRAY_BUFFER, xx size_bytes, null, GL_DYNAMIC_DRAW); glBufferData(GL_ARRAY_BUFFER, xx size_bytes, null, GL_DYNAMIC_DRAW);
self.buffers.append(b); self.buffers.append(b, self.parent_allocator);
xx self.buffers.len; xx self.buffers.len;
} }
@@ -198,7 +205,7 @@ impl GPU for Gles3Gpu {
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
slot : Gles3TextureSlot = .{ tex = t, bytes_per_pixel = bpp }; slot : Gles3TextureSlot = .{ tex = t, bytes_per_pixel = bpp };
self.textures.append(slot); self.textures.append(slot, self.parent_allocator);
xx self.textures.len; xx self.textures.len;
} }

View File

@@ -86,6 +86,11 @@ MetalGPU :: struct {
shaders: List(*void) = .{}; // MTLRenderPipelineState* shaders: List(*void) = .{}; // MTLRenderPipelineState*
buffers: List(*void) = .{}; // MTLBuffer* buffers: List(*void) = .{}; // MTLBuffer*
textures: List(TextureSlot) = .{}; textures: List(TextureSlot) = .{};
// Captured at init() so resource creation always grows the cache lists
// through the long-lived allocator, even when the caller is currently
// inside a transient arena context (e.g. glyph atlas grow during render).
parent_allocator: Allocator = .{};
} }
impl GPU for MetalGPU { impl GPU for MetalGPU {
@@ -101,6 +106,7 @@ impl GPU for MetalGPU {
self.pixel_w = pixel_w; self.pixel_w = pixel_w;
self.pixel_h = pixel_h; self.pixel_h = pixel_h;
} }
self.parent_allocator = context.allocator;
metal_init_ios(self); metal_init_ios(self);
} }
@@ -432,7 +438,7 @@ metal_create_shader_ios :: (self: *MetalGPU, src: string) -> u32 {
return 0; return 0;
} }
self.shaders.append(state); self.shaders.append(state, self.parent_allocator);
xx self.shaders.len; xx self.shaders.len;
} }
@@ -452,7 +458,7 @@ metal_create_buffer_ios :: (self: *MetalGPU, size_bytes: s64) -> u32 {
xx size_bytes, 0); xx size_bytes, 0);
if buf == null { return 0; } if buf == null { return 0; }
self.buffers.append(buf); self.buffers.append(buf, self.parent_allocator);
xx self.buffers.len; xx self.buffers.len;
} }
@@ -541,7 +547,7 @@ metal_create_texture_ios :: (self: *MetalGPU, w: s32, h: s32, format: TextureFor
if tex == null { return 0; } if tex == null { return 0; }
slot : TextureSlot = .{ tex = tex, bytes_per_pixel = bytes_per_pixel }; slot : TextureSlot = .{ tex = tex, bytes_per_pixel = bytes_per_pixel };
self.textures.append(slot); self.textures.append(slot, self.parent_allocator);
if pixels != null { if pixels != null {
handle : u32 = xx self.textures.len; handle : u32 = xx self.textures.len;

View File

@@ -408,13 +408,13 @@ List :: struct ($T: Type) {
len: s64 = 0; len: s64 = 0;
cap: s64 = 0; cap: s64 = 0;
append :: (list: *List(T), item: T) { append :: (list: *List(T), item: T, alloc: Allocator = context.allocator) {
if list.len >= list.cap { if list.len >= list.cap {
new_cap := if list.cap == 0 then 4 else list.cap * 2; new_cap := if list.cap == 0 then 4 else list.cap * 2;
new_items : [*]T = xx context.allocator.alloc(new_cap * size_of(T)); new_items : [*]T = xx alloc.alloc(new_cap * size_of(T));
if list.len > 0 { if list.len > 0 {
memcpy(new_items, list.items, list.len * size_of(T)); memcpy(new_items, list.items, list.len * size_of(T));
context.allocator.dealloc(list.items); alloc.dealloc(list.items);
} }
list.items = new_items; list.items = new_items;
list.cap = new_cap; list.cap = new_cap;
@@ -422,4 +422,26 @@ List :: struct ($T: Type) {
list.items[list.len] = item; list.items[list.len] = item;
list.len += 1; list.len += 1;
} }
ensure_capacity :: (list: *List(T), n: s64, alloc: Allocator = context.allocator) {
if list.cap >= n { return; }
new_cap := if list.cap == 0 then 4 else list.cap;
while new_cap < n { new_cap = new_cap * 2; }
new_items : [*]T = xx alloc.alloc(new_cap * size_of(T));
if list.len > 0 {
memcpy(new_items, list.items, list.len * size_of(T));
alloc.dealloc(list.items);
}
list.items = new_items;
list.cap = new_cap;
}
deinit :: (list: *List(T), alloc: Allocator = context.allocator) {
if list.items != null {
alloc.dealloc(list.items);
}
list.items = null;
list.len = 0;
list.cap = 0;
}
} }

View File

@@ -134,22 +134,19 @@ DockInteraction :: struct {
self.header_pressed = List(bool).{}; self.header_pressed = List(bool).{};
} }
// BLOCKED on issue-0009: should use push instead of manual save/restore
ensure_capacity :: (self: *DockInteraction, count: s64) { ensure_capacity :: (self: *DockInteraction, count: s64) {
if self.child_count >= count { return; } if self.child_count >= count { return; }
push Context.{ allocator = self.parent_allocator, data = context.data } { while self.child_count < count {
while self.child_count < count { self.natural_sizes.append(Size.zero(), self.parent_allocator);
self.natural_sizes.append(Size.zero()); self.alignment_overrides.append(ALIGN_CENTER, self.parent_allocator);
self.alignment_overrides.append(ALIGN_CENTER); self.has_alignment_override.append(false, self.parent_allocator);
self.has_alignment_override.append(false); self.is_floating.append(false, self.parent_allocator);
self.is_floating.append(false); self.is_fill.append(false, self.parent_allocator);
self.is_fill.append(false); self.floating_positions.append(Point.zero(), self.parent_allocator);
self.floating_positions.append(Point.zero()); self.child_bounds.append(Frame.zero(), self.parent_allocator);
self.child_bounds.append(Frame.zero()); self.anim_sizes.append(Animated(Size).make(Size.zero()), self.parent_allocator);
self.anim_sizes.append(Animated(Size).make(Size.zero())); self.header_pressed.append(false, self.parent_allocator);
self.header_pressed.append(false); self.child_count += 1;
self.child_count += 1;
}
} }
} }

View File

@@ -179,6 +179,11 @@ GlyphCache :: struct {
last_shape_len: s64; last_shape_len: s64;
last_shape_size_q: u16; last_shape_size_q: u16;
// Allocator that owns every dynamically-grown buffer on this cache —
// entries list, hash table, shaped_buf. Captured at init time so growth
// never accidentally lands in a transient per-frame arena.
parent_allocator: Allocator;
// GPU protocol backend. When set, atlas creation + dirty uploads route // GPU protocol backend. When set, atlas creation + dirty uploads route
// through `gpu` instead of raw GL. // through `gpu` instead of raw GL.
gpu: ?GPU = null; gpu: ?GPU = null;
@@ -190,6 +195,7 @@ GlyphCache :: struct {
// Zero out the entire struct first (parent may be uninitialized with = ---) // Zero out the entire struct first (parent may be uninitialized with = ---)
memset(self, 0, size_of(GlyphCache)); memset(self, 0, size_of(GlyphCache));
self.gpu = saved_gpu; self.gpu = saved_gpu;
self.parent_allocator = context.allocator;
// Load font file // Load font file
file_size : s32 = 0; file_size : s32 = 0;
@@ -204,7 +210,7 @@ GlyphCache :: struct {
self.font_data_size = file_size; self.font_data_size = file_size;
// Init stbtt_fontinfo // Init stbtt_fontinfo
self.font_info = context.allocator.alloc(FONTINFO_SIZE); self.font_info = self.parent_allocator.alloc(FONTINFO_SIZE);
memset(self.font_info, 0, FONTINFO_SIZE); memset(self.font_info, 0, FONTINFO_SIZE);
stbtt_InitFont(self.font_info, font_data, 0); stbtt_InitFont(self.font_info, font_data, 0);
@@ -235,7 +241,7 @@ GlyphCache :: struct {
self.atlas_width = GLYPH_ATLAS_W; self.atlas_width = GLYPH_ATLAS_W;
self.atlas_height = GLYPH_ATLAS_H; self.atlas_height = GLYPH_ATLAS_H;
bitmap_size : s64 = xx self.atlas_width * xx self.atlas_height; bitmap_size : s64 = xx self.atlas_width * xx self.atlas_height;
self.bitmap = xx context.allocator.alloc(bitmap_size); self.bitmap = xx self.parent_allocator.alloc(bitmap_size);
memset(self.bitmap, 0, bitmap_size); memset(self.bitmap, 0, bitmap_size);
// Shelf packer init // Shelf packer init
@@ -251,10 +257,10 @@ GlyphCache :: struct {
// Init hash table (256 slots) // Init hash table (256 slots)
self.hash_cap = 256; self.hash_cap = 256;
hash_bytes : s64 = self.hash_cap * 4; // u32 per slot hash_bytes : s64 = self.hash_cap * 4; // u32 per slot
self.hash_keys = xx context.allocator.alloc(hash_bytes); self.hash_keys = xx self.parent_allocator.alloc(hash_bytes);
memset(self.hash_keys, 0, hash_bytes); memset(self.hash_keys, 0, hash_bytes);
val_bytes : s64 = self.hash_cap * 8; // s64 per slot (s32 would suffice but alignment) val_bytes : s64 = self.hash_cap * 8; // s64 per slot (s32 would suffice but alignment)
self.hash_vals = xx context.allocator.alloc(val_bytes); self.hash_vals = xx self.parent_allocator.alloc(val_bytes);
// Create the atlas texture. In GPU-protocol mode we create empty and // Create the atlas texture. In GPU-protocol mode we create empty and
// let the first `flush()` push the (zero-initialized) bitmap via // let the first `flush()` push the (zero-initialized) bitmap via
@@ -328,7 +334,7 @@ GlyphCache :: struct {
advance = advance advance = advance
} }
}; };
self.entries.append(entry); self.entries.append(entry, self.parent_allocator);
self.hash_insert(key, self.entries.len - 1); self.hash_insert(key, self.entries.len - 1);
return @self.entries.items[self.entries.len - 1].glyph; return @self.entries.items[self.entries.len - 1].glyph;
} }
@@ -371,7 +377,7 @@ GlyphCache :: struct {
advance = advance advance = advance
} }
}; };
self.entries.append(entry); self.entries.append(entry, self.parent_allocator);
self.hash_insert(key, self.entries.len - 1); self.hash_insert(key, self.entries.len - 1);
return @self.entries.items[self.entries.len - 1].glyph; return @self.entries.items[self.entries.len - 1].glyph;
} }
@@ -399,10 +405,10 @@ GlyphCache :: struct {
self.hash_cap = old_cap * 2; self.hash_cap = old_cap * 2;
hash_bytes : s64 = self.hash_cap * 4; hash_bytes : s64 = self.hash_cap * 4;
self.hash_keys = xx context.allocator.alloc(hash_bytes); self.hash_keys = xx self.parent_allocator.alloc(hash_bytes);
memset(self.hash_keys, 0, hash_bytes); memset(self.hash_keys, 0, hash_bytes);
val_bytes : s64 = self.hash_cap * 8; val_bytes : s64 = self.hash_cap * 8;
self.hash_vals = xx context.allocator.alloc(val_bytes); self.hash_vals = xx self.parent_allocator.alloc(val_bytes);
// Rehash // Rehash
mask := self.hash_cap - 1; mask := self.hash_cap - 1;
@@ -420,8 +426,8 @@ GlyphCache :: struct {
i += 1; i += 1;
} }
context.allocator.dealloc(old_keys); self.parent_allocator.dealloc(old_keys);
context.allocator.dealloc(old_vals); self.parent_allocator.dealloc(old_vals);
} }
// Upload dirty atlas to GPU. On the Metal path, defer the upload to // Upload dirty atlas to GPU. On the Metal path, defer the upload to
@@ -482,7 +488,7 @@ GlyphCache :: struct {
new_w := self.atlas_width * 2; new_w := self.atlas_width * 2;
new_h := self.atlas_height * 2; new_h := self.atlas_height * 2;
new_size : s64 = xx new_w * xx new_h; new_size : s64 = xx new_w * xx new_h;
new_bitmap : [*]u8 = xx context.allocator.alloc(new_size); new_bitmap : [*]u8 = xx self.parent_allocator.alloc(new_size);
memset(new_bitmap, 0, new_size); memset(new_bitmap, 0, new_size);
// Copy old rows into new bitmap // Copy old rows into new bitmap
@@ -494,7 +500,7 @@ GlyphCache :: struct {
y += 1; y += 1;
} }
context.allocator.dealloc(self.bitmap); self.parent_allocator.dealloc(self.bitmap);
self.bitmap = new_bitmap; self.bitmap = new_bitmap;
self.atlas_width = new_w; self.atlas_width = new_w;
self.atlas_height = new_h; self.atlas_height = new_h;
@@ -599,7 +605,7 @@ GlyphCache :: struct {
x = total, x = total,
y = 0.0, y = 0.0,
advance = adv advance = adv
}); }, self.parent_allocator);
total += adv; total += adv;
i += 1; i += 1;
} }
@@ -632,7 +638,7 @@ GlyphCache :: struct {
x = gx, x = gx,
y = gy, y = gy,
advance = adv advance = adv
}); }, self.parent_allocator);
total += adv; total += adv;
} }
} }

View File

@@ -132,7 +132,7 @@ UIPipeline :: struct {
} }
tick_with_body :: (self: *UIPipeline) { tick_with_body :: (self: *UIPipeline) {
build_arena : *Arena = if self.frame_index & 1 == 0 then self.arena_a else self.arena_b; build_arena : *Arena = if (self.frame_index & 1) == 0 then self.arena_a else self.arena_b;
build_arena.reset(); build_arena.reset();
// Reset render_tree nodes (backing is stale after arena reset) // Reset render_tree nodes (backing is stale after arena reset)

View File

@@ -24,10 +24,12 @@ StateEntry :: struct {
StateStore :: struct { StateStore :: struct {
entries: List(StateEntry); entries: List(StateEntry);
current_generation: s64; current_generation: s64;
parent_allocator: Allocator;
init :: (self: *StateStore) { init :: (self: *StateStore) {
self.entries = List(StateEntry).{}; self.entries = List(StateEntry).{};
self.current_generation = 0; self.current_generation = 0;
self.parent_allocator = context.allocator;
} }
get_or_create :: (self: *StateStore, id: s64, $T: Type, default: T) -> State(T) { get_or_create :: (self: *StateStore, id: s64, $T: Type, default: T) -> State(T) {
@@ -42,14 +44,14 @@ StateStore :: struct {
} }
// Create new entry // Create new entry
data : [*]u8 = xx context.allocator.alloc(size_of(T)); data : [*]u8 = xx self.parent_allocator.alloc(size_of(T));
memcpy(data, @default, size_of(T)); memcpy(data, @default, size_of(T));
self.entries.append(.{ self.entries.append(.{
id = id, id = id,
data = data, data = data,
size = size_of(T), size = size_of(T),
generation = self.current_generation generation = self.current_generation
}); }, self.parent_allocator);
State(T).{ ptr = xx data }; State(T).{ ptr = xx data };
} }

View File

@@ -1061,6 +1061,45 @@ main :: () {
} }
``` ```
#### Default Parameter Values
A parameter can declare a default value with `name: type = expr`. When a
caller omits the trailing positional argument, the compiler substitutes
the default expression at the call site:
```sx
greet :: (name: string, prefix: string = "Hello") {
print("{} {}!\n", prefix, name);
}
greet("world"); // prints "Hello world!"
greet("world", "Good morning"); // prints "Good morning world!"
```
The default expression is captured as an AST node at parse time and
re-lowered fresh at each call site, so runtime expressions like
`context.allocator` resolve in the **caller's** scope, not the callee's
definition site. This is the mechanism that lets stdlib containers like
`List(T)` expose an optional allocator argument that defaults to
`context.allocator` without requiring callers to thread one through:
```sx
// In std.sx:
List :: struct ($T: Type) {
append :: (list: *List(T), item: T, alloc: Allocator = context.allocator) {
// ... grows via `alloc.alloc(...)` ...
}
}
// Call sites:
list.append(42); // alloc = current context.allocator
list.append(42, self.parent_allocator); // alloc = the named long-lived owner
```
Defaults are only consulted for **trailing** missing positional args; once
a position is provided, all earlier positions must also be provided. There
is no named-argument syntax for skipping middle defaults.
### Enum Definition ### Enum Definition
```sx ```sx
@@ -2041,7 +2080,7 @@ struct_decl = IDENT '::' 'struct' '{' struct_member* '}'
struct_member = field_group | '#using' IDENT ';' struct_member = field_group | '#using' IDENT ';'
field_group = IDENT (',' IDENT)* ':' type ('=' expr)? ';' field_group = IDENT (',' IDENT)* ':' type ('=' expr)? ';'
params = param (',' param)* ','? params = param (',' param)* ','?
param = IDENT ':' type param = IDENT ':' type ('=' expr)?
block = '{' stmt* '}' block = '{' stmt* '}'
stmt = decl | assignment ';' | multi_assign ';' | return_stmt | defer_stmt | insert_stmt stmt = decl | assignment ';' | multi_assign ';' | return_stmt | defer_stmt | insert_stmt
| push_stmt | break_stmt | continue_stmt | expr ';' | push_stmt | break_stmt | continue_stmt | expr ';'

View File

@@ -5329,6 +5329,7 @@ pub const Lowering = struct {
const ret_ty = func.ret; const ret_ty = func.ret;
const params = func.params; const params = func.params;
self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty); self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty);
self.appendDefaultArgs(fd, &method_args);
const final_args = self.prependCtxIfNeeded(func, method_args.items); const final_args = self.prependCtxIfNeeded(func, method_args.items);
self.coerceCallArgs(final_args, params); self.coerceCallArgs(final_args, params);
return self.builder.call(fid, final_args, ret_ty); return self.builder.call(fid, final_args, ret_ty);
@@ -7971,6 +7972,23 @@ pub const Lowering = struct {
return false; return false;
} }
/// After args have been lowered, append the lowered values of any
/// `param: T = default_expr` defaults for positions past `args.items.len`.
/// Stops at the first param without a default. Used at method-dispatch
/// sites whose callee is a field_access (so `expandCallDefaults` can't
/// handle them up front). The default expression is lowered in the
/// caller's current scope, so identifiers like `context.allocator`
/// resolve to the caller's runtime context.
fn appendDefaultArgs(self: *Lowering, fd: *const ast.FnDecl, args: *std.ArrayList(Ref)) void {
if (args.items.len >= fd.params.len) return;
var i: usize = args.items.len;
while (i < fd.params.len) : (i += 1) {
const dflt = fd.params[i].default_expr orelse break;
const v = self.lowerExpr(dflt);
args.append(self.alloc, v) catch unreachable;
}
}
/// When a bare-identifier call omits trailing positional args and the /// When a bare-identifier call omits trailing positional args and the
/// callee's signature provides defaults for them, return a fresh Call /// callee's signature provides defaults for them, return a fresh Call
/// node with the defaults filled in. Returns null when no expansion is /// node with the defaults filled in. Returns null when no expansion is