Commit graph

220 commits

Author SHA1 Message Date
a299117858 frame keeps: a model's records filled in place, kept event numbers, list spares, Json.read_file
From the fence's kept frames in 22 minutes of play:

- A component field that is a record or a list of records was `value_put(o, k, view_val_T(x))`,
  a whole new tree every frame (HudPrompt's notifications). view_fill.ludic generates view_set_T /
  view_set_list_T / view_fill_T that fill the object and list under the key in place.
- value_list_fit dropped the items it cut off and value_item made new ones as the list grew back,
  a Value per item per regrowth (value_item / value_set_strs); the cut-off items are now the
  list's spares (Val.spare), and an item of another kind is turned rather than replaced.
- ludic.ui: a scroll box's "scroll" and a slider's "change" fired a fresh Value.float a frame
  (sc_walk, scroll.ludic:36); ui_fire_float takes one from a ring kept with the state (fired.ludic).
  ui_object_fit_into is exported, for a draw that keeps its list.
- Json.read_file(path): read, parsed, and the file's text given back - Json.parse(Fs.read_text())
  kept the whole file on every read (Maroon Lake's settings peeks).

Golden value_list_regrow: a list alternating 6 and 2 items every frame keeps nothing (the toolchain
before this fails it: +128 B new Val from value_item). Game compiles; ludic.i18n/settings/hints/
base tests pass (ludic.ui has none); arena and fence goldens unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:22:13 +03:00
6ab98292d2 fence: every runtime call is its line's site, and site 0 comes back when it returns
A block took whatever @lp_site held when it was made. Only malloc, calloc, realloc, concat, the number
texts, the float text, a substring and Text.* set one, so every other runtime helper - intern,
Text.repeat and the string builders, the Fs, Os, unicode, uuid and crypto helpers - was charged to
whichever line had allocated last (walk 9 blamed gvk_tex_storage, m4_new, kept_push$int and
survey_op_reward for intern's 16 B copies).

- Any `call ptr @lp_*` now takes a site of its own, its kind the callee's name when no better one is
  known (intern, str_repeat, fs_list, ...). What the helper makes, in however many blocks and
  through whichever helpers it calls in turn, is that line's.
- When the call returns, @lp_site goes back to site 0, now named "(runtime) (no site) unsited": a
  block made with no site of its own says so instead of borrowing the last one.
- The ECS stores' grows and a mod's registered stores - the only allocations emitted outside
  emit_bind - take a site each.

Golden alloc_fence_sites: two lines take turns keeping memory, Text.repeat and a record, every frame
judged. Each report names its own line (25 x +32 B str_repeat at :11, 25 x +16 B new Box at :12);
the toolchain before this charges 23 of the 32 B texts to the new Box line. The other fence and arena
goldens are unchanged; ludic.base's tests pass; the game's frame ratchets are 0 on main 7cb2b164.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:40:28 +03:00
0f04b63516 fence: declared but unbounded, and a rewarm that keeps the warm-up's deadline
Two blind spots from the windowed walk, where the heap grew about 1 MB a minute and the census read
`frames 0 bad 0 kept 0`:

- @alloc_ok memory was never held to its reason. Every R3D_ALLOC_DWIN judged frames (600) each site's
  declared bytes are set against their high-water mark: a new high adds to a streak, a flat window
  takes one off, a fall ends it. R3D_ALLOC_DRISE (6) is "declared but unbounded", said once per site
  with its line; fail mode exits 86. The census adds `unbounded N` and a `dsite` row per declared
  site by its growth since judging began. A list pushed forever grows by doubling, rising too seldom
  to make a streak; a record or text made every time (a re-mount's defaults) is what it catches.
- Mem.play() (every screen opened) restarted the warm-up, so memory kept every frame was never flat,
  the cap never came, and nothing was ever judged. A rewarm now keeps the first deadline, and past it
  has R3D_ALLOC_REWARM frames (120) of grace.

Goldens: alloc_fence_unbounded (a record a frame under @alloc_ok: exit 86, named, census unbounded 1);
alloc_fence_rewarm (kept every frame, Mem.play() every 360: judged and failed at frame 3000 - the
toolchain before this runs all 6000 frames and exits 0). alloc_fence_leak, _declared, _auto,
alloc_ok_private and the four arena goldens unchanged; the game's frame ratchets 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:36:08 +03:00
de6d78bef5 escape (fix): kept memory is HEAP, so what is pushed through an alias of it is kept
render3d's stream_new holds its pool through a local (`let live = s.chunks; push(live, new
Chunk)`): the flow edge from s.chunks to live carried ESC to nothing, the Chunk records were
LOCAL, and the arena reset them under the stream - the row and horse scenarios' crash at
0xdddd... in fn_stream_update. An ESC class is now HEAP too, so every alias of kept memory is,
and a value stored through it is kept. Bidirectional alias edges were tried first and over-kept
through returns (el_place, rim).

- examples/lang/arena_alias.ludic: the stream_new shape; poisoned it read 3 3000, now 3 1518
- examples/modules/alloc_ok_private.ludic: @alloc_ok on a module's private function and on a
  statement in its private generic, declared at run time (it already passes: a guard)
- the game: frame_allocs, frame_keeps, owned_leaks 0; the lab builds under `arena strict`; the row
  scenario poisoned (R3D_ARENA_CHECK=1, 2400 frames) runs clean, bad 0 kept 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:15:32 +03:00
5d34d0fd09 escape (fix): a function taken as a value keeps what it returns, and an entry is walked
Two more holes of the arena's family, found from ECS's births:
- a function called through a function value (UiClass.make's cmp_x_new, a step list, a System's tick)
  has its result flow nowhere the analysis can see, so what it returned looked LOCAL - and a caller
  keeping it (ludic.ui's instance table) would keep scratch. Every function taken as a value (fn f)
  now has its result kept.
- an entry block has no name, and the analysis only walked named declarations: what an entry stored
  was never seen. It is walked now.
examples/lang/arena_fnval.ludic (a factory in a field, its records kept by a pool across frames)
crashed poisoned before and prints '5 1053' as the heap does now; in ludic-dev test. Maroon Lake:
component constructors are kept, not births (birth_leaks 115 -> 103); what fn values return is kept
(frame_allocs 194 -> 210, frame_keeps 151 -> 162); 5293 sites local, 6586 kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:10:18 +03:00
1381c6c903 Merge branch 'lang/dispatch-check' into lang/foundations
# Conflicts:
#	selfhost/ludicc.seed.ll
#	selfhost/ludicc.win.seed.ll
2026-09-28 18:03:09 +03:00
5aa7c03022 fence: declared bytes are never judged nor listed; the scan's sites sorted by bytes, as many as asked, and all to a file
What @alloc_ok covers (a function and its callees, a statement, a statement in a generic's body on
every instance) was counted apart in the frame's verdict, but its sites still carried the bytes the
report and the census rank by, so a declared site was listed as if the frame failed for it. Declared
bytes now have their own per-site counter and never enter live, a site's row or the verdict:
examples/lang/alloc_fence_declared.ludic, all three forms after warm-up, passes the failing fence
('bad 0 kept 0', 1488 bytes declared), with and without the arena, and an undeclared site in the same
frame is still the one listed.

The reachability scan's sites are now the largest first (R3D_ALLOC_SCAN_TOP, 24 by default), and
R3D_ALLOC_SCAN_FILE=<file> appends every site that holds unreachable bytes: the whole table to triage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:03:00 +03:00
bad7c4a255 escape (fix): a node walked more than once is scratch only if every walk found it LOCAL
A field's default is one expression, walked at every 'new' of its record: marked LOCAL by a frame's
temporary, it stayed marked when a record a pool keeps was made from it, and that record's list came
from the frame's scratch. The marks are now taken off any node one walk found kept.
examples/lang/arena_defaults.ludic is the case (a pool's record made in frame 3, a temporary of the
same type every frame): foundations 1315baf crashes on it poisoned; this prints '497 124747', as the
heap does. In ludic-dev test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:17:33 +03:00
c8a588b2de escape (fix): the arena took ludic.ui's pooled nodes - a generic's call and an unknown callee now keep what they are handed
memory_final's gate crashed in all 13 scenarios at the first frame of play, R3D_ARENA_CHECK=1 reading
0xDD in ludic.ui's nd_take: a node the pool keeps had come from the frame's scratch. Two holes:

- a call to a generic (ui_kept(list, n)) names the generic, and the analysis knows only its instances
  (ui_kept$UiNode), so the callee looked unknown - and an unknown callee was taken to keep nothing.
  A generic's call now reaches every instance, and an unknown callee keeps everything it is handed,
  but for a short list of intrinsics known to keep nothing; view() shares its list's storage.
- a push's growth into a parameter's list was LOCAL whenever the list was not seen kept, though a
  parameter may be a state's list. A site is LOCAL now only when its class is neither ESC nor HEAP.

examples/lang/arena_pool.ludic is the shape (a pool keeping records across frames through a generic
push): built with --arena it prints '7 3498' poisoned on every reset and with the arena off, in
ludic-dev test. On the valley every ludic.ui pool site is kept; 5480 sites local, 6431 kept.

Also, from ECS: a record's field defaults are stored into it when it is made, a global's initializer
is kept, and a component's own functions are frame roots (they run while its page is open).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:13:05 +03:00
467c3f1bdd Merge branch 'r3d/vk-alloc' into lang/leaks2 2026-09-28 15:47:08 +03:00
df6ea046c3 render3d: VkAllocationCallbacks counted (R3D_ALLOC_VK), pipeline create infos freed, actor pool at init
Plan 25.1c: vk_mac.ll's @lvk_ac (posix_memalign under a 16-byte header of scope/offset/size, atomic
counters) passed at every render3d create/destroy (49 sites; the caps probe keeps its own null pair);
lvk_ac_bytes/_peak/_allocs/_scope_bytes for the fence, Vk.alloc_bytes. vk_win.ll: null and 0.
MoltenVK 1.4.2 counted 0 live bytes through them in steady. Fence findings: gvk_pipeline freed its 17
create infos (and reuses one bufs list); actor_init fills ac_spare with 512 records (actor_fresh,
m4_new, v3_new at first placement in play). Compiled, not run (the user's call).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:46:19 +03:00
84155274c4 Merge branch 'lang/memory-fence' into lang/leaks2
# Conflicts:
#	selfhost/ludicc.seed.ll
#	selfhost/ludicc.win.seed.ll
#	tools/ludic-cli/test.ludic
2026-09-28 15:46:11 +03:00
edb33400b3 Merge branch 'lang/json-out' into lang/leaks2 2026-09-28 15:40:00 +03:00
691964877b fence (25.1c): the census reads the heap outside Ludic's blocks; blocks counted at malloc's own size
The census's native line is malloc's live bytes over every zone since judging began less what
Ludic's tracked blocks kept - the libraries' and drivers' growth, read before the census file is
opened. A tracked block counts malloc_size(), not the size asked for, so kept is what the heap pays
and the residual carries no rounding. @malloc_zone_statistics is declared once, by the fence or by
Os.heap_bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:38:13 +03:00
a8d54e9878 fence (25.1): every allocation goes through the fence - sites, frame judging, census, callers
Every allocation the compiler emits goes through @lp_malloc/@lp_calloc/@lp_realloc/@lp_free, and a
Ludic-level one first stores its site (function, file, line, kind) in @lp_site. Off, that is one load
and a predictable branch (30 M allocations: 0.87-0.91 s against 0.87-0.90 s on leaks2).

On (the default in a headless build, and windowed under R3D_DEV), tracking starts at the first frame
on its own and judging once R3D_ALLOC_WARM frames in a row kept nothing (600) or R3D_ALLOC_WARM_MAX
after (re)start; Mem.play()/Mem.rewarm() sends a load back to its warm-up. A judged frame that ends
holding more than it began with is reported by site with its callers (the unwinder, taken only once
judging) and fails the run with exit 86 (R3D_ALLOC_FENCE=off|count|warn|fail). R3D_ALLOC_CENSUS
writes the totals and top sites at exit. The build's defaults are --fence=, --fence-warm=,
--fence-census= or a fence line in the program's package.ludic; the environment overrides them.

The runtime is IR (emit_fence_ir.ludic, generated from a template); tracking is a side table in one
calloc'd region, so no block carries a header and pointers crossing to natives stay safe. Examples
alloc_fence, alloc_fence_leak and alloc_fence_auto with cases in ludic-dev test; reseeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:35:29 +03:00
4499cedcb8 runtime: Json.write_file, and Json.encode through a kept buffer
The encoder appends into RtJsonState's buffer (grown only past the biggest document yet): ints and
Q16.16 fixeds written as digits in place, floats through string() and freed. Json.encode copies the
answer out once; Json.write_file hands the buffer to Fs.write_text (.tmp + rename) and keeps nothing.
json_saves.ludic: exact text, the file equals encode, parse round-trips, 1000 saves grow 0; clean
under MallocScribble. json_quote (the + builder) is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:32:36 +03:00
6cdef20cc2 ludic.ui: an unmounted component is mounted again rather than made again - its record renewed (a generated renew), its props and model kept; an action's call answers into a ring
A prompt that comes and goes as a player walks (co-op's netleak: in_get, cmp_*_new, bd_class, value_slot/put)
made a new record, props and model on every mount, and an action's call answered into a new Val (ev_call_with).
examples/library/ui_remount: two thousand comings and goings hold the heap at 0, and the counter starts at 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:25:55 +03:00
8e113f749a fix(check): a Math.* call has the type the emitter gives it
Math.max, Math.sqrt and the rest (and the bare min/max/abs/clamp) are computed inline by the
emitter, and L4 gave each the unknown type, which agrees with everything: Maroon Lake's trail
put Math.max(5, n) into Notify's string field a1 and it failed in LLVM ("%t63 defined with type
i32 but expected ptr"). It was never about two dispatches on a line - one is enough. The checker
now mirrors the emitter: a float/double first argument gives that type (sign an int); otherwise
min/max/abs/clamp keep the first argument's type, sign/floor/ceil/round/posmod/wrap/ping_pong are
ints, the rest fixed. Named arguments or an argument it cannot type leave it unknown.

rejected/math_into_text is the case. Reseeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:12:28 +03:00
254097657e runtime: Log, DateTime.format, Input.text, Path, Mime, Fs, Os and Text keep nothing per call
Found by reading every builtin (Os.platform's 8 KB per call started it). Log builds its line only at
or above the threshold and frees it; DateTime.format folds through + so its pieces go; Input.text
encodes into one buffer; Path/Mime/Fs/Os free their temporaries on every path; string results of
Text/Path/Mime/DateTime/Os dirs are fresh and Text frees a fresh argument. Reseeded.
runtime_temps.ludic: 19.8 MB -> 0 over 20,000 rounds, 64 KB -> 0 over 200 of file work; clean under
MallocScribble. string_temps still 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:55:49 +03:00
0141f99dd1 Os.platform/arch uname once; render3d primes a new buffer's Metal buffer
lp_os_platform and lp_os_arch malloc'd 8 KB per call (the uname buffer) and kept none of it:
string_temps now asks both every round, 327 MB over 20,000 before, 0 after. Reseeded. render3d:
MoltenVK made a mapped buffer's MTLBuffer at its first bind (fn_gvk_draw +4 blocks in the boat
window); gvk_buf_reserve queues it and the next frame's command buffer copies 4 bytes out of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:34:45 +03:00
7e9fa4473c render3d: a stream's cache is made with the stream - records and an arena - and evicts in place
stream_update made a Chunk and a words copy per new chunk (fn_stream_update +39 blocks / 13.2 KB a
window with the hiker in the drifting boat). The pool holds STREAM_MAX_CHUNKS records; the arena
is twice the layer's cap; eviction compacts it; a chunk that cannot be kept is gathered from the
scratch. stream_clear_all frees records, lists and streams. steady: 300 new cells, cap 256: 156 KB
before, 0 / -4.9 KB after, and every kept chunk's data checked against its cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:26:59 +03:00
b4774a46ee render3d: turn MoltenVK's command pooling off; steady ramps the draw count
The pools kept every command object ever recorded, so the heap grew each time a frame drew more than
any before (fn_gvk_draw under vkCmdBindVertexBuffers/BindIndexBuffer/Draw in the leakcheck; ~650 B a
draw). Off: 3.7 ms a frame either way over 520 actors. steady: 20 to 200 actors grows 5-7 KB with it
off and 120 KB with it on (bound 16 KB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:57:08 +03:00
22d1e2d66a Os.heap_bytes: the heap without the renderer; string_temps reads it
Vk.heap_bytes pulled the Vk module - and on lang/uifree the GL window path - into a plain program,
which then failed to link (_cgl_offscreen, lgl_GetError). Os.heap_bytes is malloc_zone_statistics
through a weak reference (0 where there is none, and on Windows). Reseeded. Docs for it and for
Json.free / Json.free_all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:31:42 +03:00
1adce5b58b render3d: free a texture's create infos and the .dds path it looked for; steady's model at 0
gvk_tex_storage freed none of ici, out, req and vci (8 blocks a two-texture model); tex_load_ex kept
dds_path_of's string. Found with malloc_history over 400 load/release rounds (712 bytes a round, all
of it these). steady: the model's bound is 4 KB over 200 (was 1.6 MB), and the frame is the least of
three settled windows - a valley self-test beside it read 87 KB once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:25:02 +03:00
328dee77c8 compiler: a string slice is a fresh temporary too
s[a .. b] is always a copy, so it is freed once a +, a comparison or print has read it. Reseeded.
string_temps.ludic adds a slice compared and a slice concatenated each round (960 KB over 20,000
before, 0 after) and a kept slice read after its +.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:25:02 +03:00
47e32eacd1 Merge branch 'r3d/recycle' into lang/uifree 2026-09-28 12:19:16 +03:00
d9c574f107 Merge branch 'lang/str-temps' into lang/uifree 2026-09-28 12:19:16 +03:00
9660587e10 compiler: free a string an expression made once it has been used
The left half of a + chain, a template's pieces and holes, a number's text and a side made only to be
compared are marked fresh and freed after the +, ==, != or print that reads them. lp_int_str and
lp_long_str move their digits to the start of the buffer, so the pointer they return is the one
malloc gave. Reseeded. examples/lang/string_temps.ludic: kept intermediates stay good, and 20,000
rounds grow the heap 0 bytes (2.9 MB before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:06:45 +03:00
5ec13a073f steady: read the heap once the device is idle and two reads agree
Under the suite's parallel load a frame read ~80 KB high: MoltenVK's completion handlers release a
finished command buffer on their own thread and lagged. Settled, 12 of 12 runs four at a time pass;
the frame's bound drops from 64 KB to 4 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:04:09 +03:00
db3fa68052 Merge branch 'r3d/recycle' into lang/uifree 2026-09-28 12:01:33 +03:00
9b482d609a render3d: recycle texture and buffer ids, free a released model whole, actor_release
A freed texture or buffer id goes on a spare list the next one takes; tex_note_size keeps sizes by id.
model_release frees prims, meshes, material names, the skin and leaves gltf_cached. actor_release
takes an actor off the stage and actor_new reuses its record (ECS's Things come and go all day).
steady.ludic: an actor round at 0 bytes over 2000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:00:53 +03:00
08a6fc8a3f Merge branch 'lang/chunks' into lang/uifree - the ground's heights by chunk and its Jolt ground per chunk; both new render checks kept (steady, chunks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:55:55 +03:00
a465984c52 fix(render3d, runtime): a glTF document is freed whole, layouts found by number, block records reused
- Json.free_all (value_free_all): a parsed tree's nodes, lists and strings. render3d frees each
  glTF document that way at the next load; the names kept out of it are copies (a primitive's
  material, a skin's joints, an animation clip's name in ludic.anim) - a model's strings were
  ~640 KB left behind per load
- jp_number made a digits list per decimal in a document and never freed it
- gvk_layout_id matches a mesh's layout as numbers in a scratch made once, against the layouts
  known end to end; a new mesh no longer builds a key string
- gvk_mem_new puts a new block into the record of one given back rather than appending, so a
  buffer made again every few frames no longer grows the block lists

steady.ludic adds a glTF parsed and freed whole 200 times: 0 bytes (38,400 before the digits fix),
beside the buffer path and the frame, still 0. A model loaded and let go still keeps ~2 KB a round
(texture and buffer handles are not reused yet); it is bounded at 8 KB a round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:36:42 +03:00
ff658633fb feat(render3d): terrain_chunk_heights - the ground's B-spline heights a chunk at a time (23.5)
n x n samples of chunk (i, j) of a size_m grid from the terrain's corner, row-major into the
caller's buffer with nothing allocated, each the (1 4 1) / 6 B-spline filter of the texels under it
(ter_spline_at): what ludic.physics' jph_shape_heightfield_bspline makes of the whole map, so a
chunk's physics ground matches the drawn one and its neighbours' to the bit. The read-back lives
for the whole map (terrain_generate to terrain_unload). examples/rendering/chunks.ludic, in the
suite, checks the shared edges, a sample against the filter by hand, and a short buffer refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:25:21 +03:00
866037a0d7 fix(render3d): nothing allocated in the steady state - the Vulkan allocator reuses its records
gvk_mem_new made a one-slot []pointer per allocation, and turned the requirement's size and
alignment into strings to read them as ints; gvk_list_drop_last rebuilt the spare-record list to
drop its last entry; gvk_mem_id did the string round trip on every free. One slot is kept
(gvk_map_slot), int() truncates a long, the spare list pops. Every Text.to_int(string(x)) in
render3d is int(x) now.

Vk.heap_bytes() (vk_mac.ll: malloc_zone_statistics' size_in_use; 0 on Windows) and
examples/rendering/steady.ludic, in the suite: a buffer released and made again 5000 times and
600 whole frames gain 0 bytes each - the allocator before this, 1,120,000 over the 5000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:14:56 +03:00
8cc4bdf66b feat(render3d): 23.3 - a .dds beside a .png is uploaded BC-compressed with its whole mip chain
The device's textureCompressionBC is asked for and remembered (gvk_has_bc). tex_load_ex prefers a
DX10 .dds with the full chain beside the .png (not for an edge-padded cut-out atlas):
texture_dds.ludic reads BC7 / BC5 / BC4, gpu_tex_compressed makes the image with every level and
no colour-attachment use (a compressed image is only sampled and copied into), and
gvk_tex_upload_blocks copies each level's blocks from one staging buffer. A colour map is BC7
sampled as sRGB, a data map BC7 read as it is. examples/rendering/bc.ludic holds it, in the suite;
ludic.lab's plate carries its .dds (its three shots render at 56-60 dB against the .png's).

ludic-dev test 307/307, no Vulkan SDK in the environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:23:45 +03:00
5d9cbddb72 Merge branch 'lang/foundations' into lang/uifree 2026-09-27 23:46:28 +03:00
e634177ca4 feat(render3d): 23.4 - model_release, shared textures counted, and scatter layers sized to what they hold
- model_release(model) frees each primitive's mesh, and each texture once no other model uses it:
  the glTF texture cache counts the primitives using each texture (a path loaded again, and a LOD
  chain borrowing its LOD0's material, each take a reference); the last one frees the texture and
  forgets it along with any remembered material naming it, so a later load is a fresh one
- a scatter layer's instance and sort arrays start at 256 and double as layer_add or a stream
  fills them, to the layer's cap, instead of the whole cap up front (0.4 GB in Maroon Lake)
- gvk_tex_read's copy struct comes from the scratch ring
- examples/rendering/release.ludic holds it (RELEASE OK on Vulkan and OpenGL), in the suite

smooth renders pixel-identical before and after (max |d| 0). ludic-dev test 306/306.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:34:14 +03:00
978f128755 Merge branch 'lang/foundations' into lang/uifree 2026-09-27 23:33:48 +03:00
d1c9359e96 ludic.ui: a screen's nodes and envs are pooled and used again - two generations a screen, reset in place, their own lists emptied not replaced; a tree is good until its screen is built twice more (benchmark 256 -> 110 KB a frame)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:33:48 +03:00
cfb7fa5ca9 Merge branch 'lang/foundations' into lang/ecs 2026-09-27 23:08:24 +03:00
80764f85c1 fix(render3d): the Vulkan renderer stops leaking as it loads and draws
- every texture upload malloc'd a CPU copy of its pixels and never freed it (236 MB over the
  valley's boot, and again for every model loaded later): the pixels are converted straight
  into the mapped staging buffer
- the per-level and per-layer views gvk_view_of made outlived their texture, and on MoltenVK a
  view keeps its Metal texture alive: a released texture takes its views with it, and the cache
  is keyed by numbers instead of a string built on every call
- the Vulkan structs filled for a draw, pass, barrier, descriptor set, buffer, allocation or
  upload (about sixty call sites) come from a reused 1 MB scratch ring (gvk_tmp)
- the descriptor-set cache and the retired buffers are emptied in place, not replaced; the grass
  cull's dispatch arguments are made once
- macOS drains an autorelease pool each frame (Vk.frame_pool, lvk_frame_pool in vk_mac.ll)
- R3D_VK_PROF reports Vulkan objects made and destroyed by kind, and every cache's length
- examples/rendering/smooth presents through render3d, so it runs on Vulkan too

The full valley on headless Vulkan loads to 2.18 GB and holds (it passed 8 GB while loading
before). ludic-dev test 305/305, selfhost-test 33/33.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:03:38 +03:00
7834b26ef8 feat(compiler): a Job.parallel_for worker may read a state but not change one
Every pool thread runs the worker at once with the same states, so a mut state in a worker was a
race nothing reported. check_worker_ref refuses a worker whose leading states include a mut one;
threads.ludic's total moves into the words the worker is handed, under the mutex. The seeds are
regenerated (ludic-dev reseed). ludic-dev test 305 passed, selfhost-test 33 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:04:59 +03:00
e53f3197ac refactor(render3d): phase 16 - the collider store is gone; still things are bodies in ludic.physics
collide.ludic (circles in a cell grid that could not be removed) and its Render3dState fields are
deleted, and the reload example stops asserting colliders. A game still calling col_* must move to
ludic.physics first (Maroon Lake's src/solid).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:08:36 +03:00
5df0747642 feat(cli): 0.R5 - ludic deps says what a function can come to change (widest_write_reach, --wreach N); a port member and a registry field reach only themselves
A reach through Port.member() follows that member's binding (or its default), and Registry[i].field -
or a local holding Registry[i] - follows that field in each entry, so a question asked of a port or a
table that also holds verbs no longer reaches the verbs. In Maroon Lake that took the valley's
'what is this Thing called' from 47 states it could change to 1. examples/state/write_reach.ludic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:19:54 +03:00
c64f8750e2 feat(lang): 0.R5 - a reducer writes one state and may read others, declared between its state and the action
What only becomes known inside the drain - a value another reducer just set, the map in play - no longer
has to be faked into the action, so a verb that reads several systems while it changes one is a reducer
instead of an act handed its states. A second state to write is still refused, and the message says the
way out. examples/actions/reads.ludic; reseeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:23:48 +03:00
259b4d9b35 fix(ui): 0.R4 - an action a UI button dispatches is reduced before the frame is presented
ludic.ui runs a frame's presses after drawing it, and what they dispatched waited for the end of the
phase - after the host had presented - so a button's change showed a frame late. Decided: drain, not a
phase per action. ui_show and ui_press drain the queue once the presses have run, so the code after
them and the frame presented next see the change; a host that runs presses some other way calls
drain_actions() before it presents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:08:26 +03:00
d46f0adb5e fix(lang): 0.R4 - ludic.ui's UiAct is its own, and a name that meets a package's export says whose it is
A game's exported UiAct collided with ludic.ui's, which nothing outside ludic.ui uses: it is private
to ludic.ui now, and a private record of one spelling in two modules never clashed. A real clash - a
type named like one a package exports - is still refused, and the message names the package and the
way out (`ludic_ui exports it, and exported names are one namespace - rename this one, or declare it
without export inside a module of your own`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 06:57:35 +03:00
eb1e780733 feat(cli): 0.R4 - ludic deps sees through fn values (widest_reach) and lists the widest functions (--widest N, --reach N)
A step list or a registry of fn values takes no state and still reaches every state its steps take.
The compiler now writes `reach <n> <function>` - every state a function can come to by a call, a
`fn f` it writes or a global holding fn values it reads, to a fixed point - and ludic deps reports
widest_reach beside widest_function, with how many of those states the function does not take
(Maroon Lake: app_boot, 72, all 72 through fn values). --widest N lists the N functions that take the
most states with what each reaches; --reach N orders them by reach. A baseline without widest_reach
does not hold it until rewritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 06:24:46 +03:00
2eefae0618 fix(check): 0.R4 - a misspelled type in a parameter, a result or a field is refused where it is written
`function kind_of(f: CharFact)` for a CharacterFact was taken on trust and failed in the code writer
as "member access on non-aggregate". A capitalised type - plain, in a slice, or a generic's argument -
must name a declared property, record, state, event, enum, action or packed value type, or a type
parameter of its declaration: `kind_of's parameter f: there is no type CharFact`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 05:58:35 +03:00