Commit graph

176 commits

Author SHA1 Message Date
af90a1a334 @Asset(kind, map): a path under each map's directory - ludicc --check looks for it in every map (a @PerMap row's in its own, a game-wide row's in all), unless @Asset(kind, map, optional); the schema marks it scope map; reseeded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:13:14 +03:00
ec49207533 ludic deps: widest_reach and widest_write_reach leave out the ROOTS - a function that reads fn values out of a table (a step list's walker, a registry of systems) reaches every state by definition - and count every other function through its calls only, not through a dispatcher nor a fn value it writes into a list; the roots are listed on a line of their own and marked in --reach / --wreach; reseeded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:51:50 +03:00
d8baf4e579 tests: the full suite's seven failures on lang/foundations - one compiler fault and six stale expectations
- the test runner re-makes every state between tests again: since 12fdc07 a state is made by its getter on
  first use, so re-running L_init_globals left the last test's state in place (state/tested failed its
  second test); @L_reset_states forgets every lazy state, and the runner calls it before each test
- diag_json_case counts errors, and an absent @Ref / @Tint / @OneOf target is a warning since d82dc31:
  ref_unknown is 1 error and node_bad 8
- schema_hash.ludic prints 1: a bool is 1 or 0 as text (random_plain's 1 1 1)
- permap_check_case looks for the unit warning without the quotes the JSON escapes
- baked_test's inputs-hash test makes its directory: each test has a temp directory of its own
- ludic deps prints phase 25's five counters too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:19:00 +03:00
2f4860ae71 @PerMap and @Chunked(n) registries: rows read per map, a state and verbs written, every map checked
- @PerMap registry R of T from "file.lres" reads <maps root>/<map>/file.lres at run time
  (package.ludic's new `maps` line, default assets/maps); @Chunked(n) one file per chunk,
  {cx}/{cz} in its name. No as PREFIX, no constants, no def, never open.
- permap_gen: state R, r_load/_clear/_find/_path; chunked: RChunk, r_in/_out/_slot/_find/
  _clear/_path; a typed reset and fill per record over lres.ludic, rows, lists and list
  records pooled per table / chunk slot, @alloc_ok on what grows at high water.
- permap_consts: lres_consts__(), the program's int and float constants by name.
- permap_check: ludicc --check reads every map directory (fields, types, constants, fn,
  @OneOf/@Range/@Ref, cross-row @Ref keys in the same map, a key twice); --no-maps skips.
- @Ref(PerMapTable) is refused on a non-string field; the schema says scope/chunk per
  registry and scope map on such a Ref, and lists the canonical @Unit spellings; any other
  @Unit spelling is a warning naming the canonical one.
- reseeded (mac + win seeds; bootstrap-cfree out.ll == seed.ll).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:03:56 +03:00
dd25267502 templates: {s} with one string hole and nothing else is a new string, not s itself - it was the one template that passed its string through (string() does), and code that wrote it as a copy kept what it then freed; the mark rides the callee, since escape analysis writes the call's uns; reseeded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:14:58 +03:00
281ebc23e1 ludic schema: a components section and a natives list (R4)
Each UI component: module, place, doc, xml and lss paths, props and state
(type, default as written, place, doc), states_read (the header's states),
derived fields with their types, functions and events as the template calls
them (states and instance stripped), and the native tags its template uses.
natives: every ui_native / ui_native_input call with a literal tag - tag, via,
handler, place. schema_version stays 1; the lists are additions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:36:04 +03:00
d82dc3162f attributes: a target the program does not have at all (an @Ref registry, an @Tint / @OneOf constant) is a warning and "unresolved" in the schema, not an error - a package names the game's registry without importing it; a name of another kind is still an error; reseeded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:10:45 +03:00
dacc0f7280 Build.schema_hash(): FNV-1a 64 of the program's own schema (the bytes ludic schema prints), worked out only when a program names it, 0 under ludicc --release, which ludic bundle now passes (Mac and Windows); reseeded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:05:28 +03:00
b647964839 schema: every function a fn value can name, not only zero-argument ones - fn_type is the type a field sees with the states stripped, spelled as a field's type is (fn(A,B)->R), with params beside states; reseeded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:15:04 +03:00
42deb76c28 schema: ludicc --emit-schema / ludic schema, --check --diagnostics=json, and editor attributes
--emit-schema FILE writes the compiler's resolved view once the program type-checks: every
record (fields, types, defaults as written, docs, places, attributes), every registry with its
entries in their final order after the open-registry merge (key, constant, index, file:line:col
of the entry and of each field value, and which file contributed which keys), every const, and
the zero-argument functions a fn value can name. Deterministic, schema_version 1; the runtime is
left out. `ludic schema [file] [-o FILE]` wraps it.

--check --diagnostics=json prints every error as one JSON array on stdout: the checker's and the
module rules' all, a parse or lowering error as the last. Tokens and nodes now carry a column.

Fields take several @attributes; @Ref(Registry), @OneOf(PREFIX_), @Range(lo, hi), @Unit("..."),
@Asset("..."), @Color on a field and @AppendOnly / @ByKey on a registry change nothing but go into
the schema, and @Ref naming no registry is an error (every one reported). Fixtures:
examples/lang/attributes.ludic, examples/rejected/ref_unknown.ludic, cases in ludic-dev test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:06:54 +03:00
e1585de9b6 map packs: Fs.mount / Fs.unmount at run time, and a map's bakes under assets/baked/maps/<map>/
Maps ship outside the game, each one a content-addressed pack (.lmap: the .lpak format) downloaded to
the save root. The runtime already served reads from packs mounted at boot (packs.index); now one can
come and go while the game runs:

- Fs.mount(path) -> bool maps a pack over the ones mounted before it (searched first, as a later
  packs.index line is); Fs.unmount(path) -> bool gives the mapping back (munmap, UnmapViewOfFile on
  Windows) and closes the gap in the search order. Each slot keeps its length and path for it. A
  FILE* still open over one of its entries (a baked_open_range) is closed first. Still 8 packs at most.
- baked_path(map, file) is assets/baked/maps/<map>/<file> for a map's bake - what its .lmap carries
  and the game's own pack never does - and assets/baked/<file> for the rest.

The IR assembles for macOS and Windows (llvm-as). Compile-only: nothing mounted or run here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:27:06 +03:00
e958d00373 Os.heap_relief(): the allocator's freed-but-cached memory handed back, once after a load
Beside Os.heap_bytes: malloc_zone_pressure_relief(NULL, 0) on macOS (weak, so a libc without it reads
0) and HeapCompact(GetProcessHeap(), 0) on Windows - kernel32 only, so the Windows build imports
nothing new; the bytes it says it released. Once after a load, never per frame.

Measured, for the record: on macOS it does NOT reach the large-block cache. A C program that frees six
15 MB blocks still holds 90 MB of MALLOC_LARGE (empty) after relief on every zone (it returns 0); only
MallocLargeCache=0 in the environment AT PROCESS START turns the cache off (read at malloc's init -
set later, it does nothing). Maroon Lake's watcher sets it for the processes it spawns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:30:20 +03:00
84d754a4e9 a render3d program's window is opened by its renderer, not by the runtime before main
rt_init opened every windowed program's window before main, and render3d's gvk_open then only
retitled it. win_open makes the window when there is none (cocoa.ll and win32.ll alike), so for a
program that has gvk_open the runtime now leaves it (window_later(), an intrinsic: windowed and
render3d present): a process that never reaches the renderer - Maroon Lake's launcher watcher, which
only spawns the game and waits - never makes a window, an NSApplication or AppKit's heap.

Audited every window native reachable before the renderer opens (settings, telemetry, rescue, the
watcher reach App.* and Input.*): on macOS each that loads W_win / W_app / W_view / W_mtl / W_glctx
checks it for null; win_close, win_running, win_text, win_held, win_cursor_mode, win_gl_scale and the
pad and touch reads load none. On Windows each that loads W_hwnd / W_hdc checks it; the rest load none.

Measured, windowed, R3D_DEV=1 R3D_PLAYTEST=2, both killed after:
- the game straight to play: the window, the Vulkan swapchain (1920x1080) and the valley's models
  come up, alive at 30 s;
- the launcher (R3D_GAME=launcher): the watcher 11 MB -> 3.7 MB, its launcher window alive at 10 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:19:10 +03:00
12fdc0717e a program's states made on first injection, and the ECS stores started at 8 slots
Maroon Lake's launcher watcher - a process that only spawns the game and waits - held 58 MB, 48 MB of
it MALLOC_SMALL. Measured with malloc stack logging it was not the state defaults but L_grow: every
program sized every property type's per-entity store to MAX_ENT (1024) slots at start, 1,583 stores,
entities or none. And every state record was made with its defaults in L_init_globals before Boot.

- emit_lazy.ludic: a program's (not the runtime's) state global is left out of L_init_globals, and
  every read of it calls @S_<global>(), which makes it on first call from its own initializer - after
  every registry and plain global, so a default may read them (the init-order crash cannot come back
  through a state). What a getter makes is declared (@lp_fdecl): a state first touched in play is made
  once and not judged as a frame's keep. A function value's trampoline calls the getter too.
- L_grow starts the stores at ECS_FIRST (8) and doubles as entities come, as it always did past MAX_ENT.

The watcher (with the game's watch step moved before the systems' defs): 57 MB -> 11 MB; the only
state it makes is UiState (14 KB). What is left: AppKit's window, opened by rt_init before main for any
windowed program (~4 MB), and the runtime's font, image and 2D inits (~1.2 MB).

Goldens: the arena, fence, value and json goldens; the 36 ui examples; 30 of the 32 ECS test cases
(sprite_render and sprite_atlas time out under a plain runner with the toolchain before this too).
Package tests: ludic.base, save, settings, i18n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:14:05 +03:00
b989f653a0 Merge branch 'lang/memory-sites' into lang/foundations
# Conflicts:
#	selfhost/ludicc.seed.ll
#	selfhost/ludicc.win.seed.ll
2026-09-29 00:40:53 +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
25c8c9279c intern: past its store or its table, the fence's overflow - said once
Past the 4 MB store lp_copystr falls back to the heap, and past the table
(49152 texts, or 64 probes) lp_intern copies on every call: either way a
program interning without bound would grow unseen. Both paths now call
lp_intern_over, which reports through lp_cap_over once ("intern (4 MB of
text, 49152 distinct texts) is full"), so warn says it and fail stops the
run (exit 87) like any capacity past its promise.

Checked with a compiler built from these sources (selfhost-build): 60000
distinct texts under R3D_ALLOC_FENCE=warn print the line once and every text
comes back right; under fail the run exits 87.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:37:56 +03:00
47bb0e5d4d intern: texts copied into one store in the binary, not a malloc each; number texts interned too
Walk 9's fence named 256 frames of +16 B at gvk_tex_storage:244 and
m4_new:92, always under HudDay's and HudGuide's models. Neither site
makes 16 B: the blocks were intern's copies (lp_copystr), which never set
lp_site and so were charged to whatever allocated last. The HUD's clock
and the guide's distance are a new text every few seconds, and each first
one was a malloc kept for good.

lp_copystr now copies into @lp_istore, 4 MB in the binary (untouched pages
cost nothing), and falls back to the heap only past it; lp_free ignores a
pointer into the store, so a text freed after it was interned is no fault.
value_num_text - the text a screen shows for a number, dropped whenever
the number changed - is interned the same way and its string() given back.

Checked: a program interning 100000 texts gets every one back right, the
same text as the same pointer, a freed one harmless; the headless valley
compiles, with frame_allocs, frame_keeps and birth_leaks at 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:31:34 +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
4d3ecfb72c globals initialized in the order their initializers need each other
A state's field default reading a registry (jn_life_sp: []int = jn_life_species_new(), which reads
Species[sp].population) ran before the registry was filled, because globals were initialized in
declaration order: every gate scenario crashed in L_init_globals. Each global's initializer is now
followed - through the functions it calls and a record's field defaults - to the globals it reads,
and those are initialized first (a depth-first post-order; the source order kept between globals
that need nothing of each other, and in a cycle). Putting every state last is not enough: some
tables read a state's instance too. A test (a state whose default reads a registry declared after
it) crashes on d483c92 and prints '2 4' now; Maroon Lake's headless game loads and plays 180 frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:35:09 +03:00
d483c9283c frame allocs: a local shadowing a function is no edge, the drain's calls are no edges, HEAP keeps only a growth off the arena, and the arena starts at its first use
- the call graph is by name, and a local or a parameter named as a function (a float bd, part, bx)
  linked to that function: a name the function binds itself is never an edge now.
- drain_actions, generated, calls every reducer; a reducer is reached from its action's dispatch,
  so the drain's calls are not edges.
- a fresh value flowing into a local that also holds kept memory is still the frame's (storing it
  anywhere kept would have made it ESC): HEAP now keeps only a push's growth off the arena.
- the arena starts at its first use rather than at the first frame mark, so boot's temporaries are
  scratch too - dead once the Start handlers return - and a scratch site is never a birth.
Plus ludic.hints' rail and three of ludic.update's one-off lines declared. The arena goldens pass
poisoned. Maroon Lake (d79d189f): 39/11/66 -> 30/9/0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:23:28 +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
b12b66e89a frame allocs: what the arena takes is not counted; a scratch site is a birth only when boot reaches it
With the arena on, a site the escape analysis proves LOCAL is the frame's scratch - made and gone
with the frame - so frame_allocs now counts only what frame code still takes from the heap. And a
scratch site is the arena's whenever the game's frames run (a frame, a click handler, a reducer), so
it is a leak at birth only when boot's code (a Start handler) reaches it, before the first frame.
Maroon Lake: frame_allocs 337 -> 194 with the game's own fixes, birth_leaks 189 -> 115.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:06:04 +03:00
3b9b4a589b frame allocs: a click is not a frame, a reducer is its dispatch's, a capped push is bounded; ludic.photo at 0
The analysis made every component function a frame root, event handlers (cmp_x_on_delete) too, and
every reducer, whether its action is dispatched every frame or once a trip: a component's 'on'
handlers are no longer roots, and a dispatch is an edge to its action's reducers, so a reducer
counts only when frame code dispatches it. A push into a field declared @max(n) is bounded by the
fence's own check and no longer counted. Maroon Lake: frame_allocs 395 -> 337, frame_keeps 188 -> 169.

ludic.photo: the roll's order and a page of it are kept lists refilled in place (the pack's page
asked for both every frame), its kept lists say @max(256), and a shot's tags, a photograph's fact
and a new roll are declared (once per shot, sale or trip). 18 allocs and 9 keeps -> 0 and 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:02:47 +03:00
dd20cb2d5d fence: free and realloc stay on the fast path with the arena on - the arena's range checked inline
With the arena running every free and every realloc took the slow path (a call to check the range,
then the fence's own test). Now lp_free checks the arena's range inline and hands anything else to
libc unless the fence is tracking; lp_realloc goes slow only for a scratch request, a tracked run or
an arena block. Ready for when the final run's medians ask for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:37:45 +03:00
91f91716b5 scan: follow a word only if it could be a heap block's start - 16-aligned, in the user address space, not in the arena
The exit scan crashed two of the gate's scenarios (world, swim: SIGBUS and SIGSEGV in lp_mem_scan at
0x0e00000c65800000 and 0x04000004e461c000): a word of data with its high bits set was handed to
malloc_size, and a zone faulted looking it up. A candidate must now be 16-aligned, at or above 4 GB
(macOS's page zero), below 2^47, and outside the frame arena before malloc_size sees it; a block's
own words are read only once malloc_size has said it is one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:21:37 +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
5e80db327d arena: a LOCAL mark is honoured only while emitting a function the escape analysis walked
A default's node is emitted wherever its record is made, some of it in code the analysis never walks
(a scene's body, a test's); a mark from a walk elsewhere took effect there unchecked. The emitter now
asks for scratch only inside a function or @On body the analysis walked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:18:55 +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
a6364199da census by owner (25.5c): which state holds what, and how much it grew
The escape analysis now follows which state a kept value is stored into (a state parameter, a state
global - each its own class - through the flows to and from it), and every heap site in the fence's
table carries that owner. The census writes, per owning state, what its sites hold and how much that
grew since judging began: 'owner NotesState holds 6400 (+5600 since judging began)'. A keep() or
intern() is a site of its own for this, never scratch and never reported as a keep or a birth. It
needs the analysis, so the arena's (or --escape-report's) build; this is what a soak watches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:05:53 +03:00
b87ee96805 owned fields (25.5e): @owns(Kind) on a record's field, and owned_leaks
A field marked @owns(PhysShape) holds a handle its record owns. A function that releases one owned
field of a record (body_free(w, s.body)) and neither releases nor hands on another owned field of
the same record type (s.shape) gives the first back and loses the second - the phys_remove bug, at
compile time. ludic deps --resources (or --owned) lists them; owned_leaks is a number --check
ratchets. A test: the function that frees a solid's body alone is the one found; the one that frees
both is not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:01:28 +03:00
e009ea313b resources (25.5e, first half): @creates(Kind) / @releases(Kind), and resource_drops
A function marked @creates(PhysShape) makes a handle one marked @releases(PhysShape) gives back.
ludic deps --resources lists every creating call whose handle is thrown away, or bound to a local
that is never released, passed on, stored or returned, and resource_drops is a number --check
ratchets. A test: a thrown-away create and one bound and never handed on are the two found; one
stored in a state and one released are not. A record's owned fields and a borrow form (a shape
used by several scaled ones) are the second half, with a resource type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:54:27 +03:00
1cc507e181 escape: a value of a primitive type holds no reference - no flow, no store
The analysis gives each local its declared or inferred type (a record's field, a list's element,
a call's result, words/floats) and takes a value whose type is a number or a bool out of every flow
and store: a float copied out of a frame's floats into a state's no longer makes the frame's list
kept (shadow_fit, water_reflection_pass, layer_partition_lods). frame_keeps 190 -> 181 on the game;
birth_leaks 564 -> 581, the lists that copy was hiding now seen as made and dropped outside a frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:52:24 +03:00
39af9cabc0 reachability scan (25.5b) and exit accounting; free() and print release what they are handed
Mem.scan() and R3D_ALLOC_SCAN=<frame> (at that frame's mark, when no function is running) walk
every heap block reachable from the program's globals - the states among them - conservatively: each
word that is a heap block's start (malloc_size says so) is followed, blocks made before tracking too.
A tracked block nothing reaches is a leak whatever a frame's totals say; they are summed by site and
printed ('alloc-scan: frame 39 - 32 bytes in 2 blocks ... reachable from no global or state', then
the sites). R3D_ALLOC_EXIT=1 runs the same scan as the program quits. A test: two records dropped in
frame 20 are the two found, the one pushed into a state is not.

The escape analysis now takes free(x) as giving x back (ES_FREED, flowing to what reached x) and a
print's argument as used up, so ludic deps --births lists only what is never given back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:49:27 +03:00
6fb5118afd capacities (25.5a): @max(n) on a list field, and a full table is a failure
'@max(64) boxes: []Box' on a property's or a state's field is a promise: the grow path of a push to
that field (only the grow path, so nothing is paid until it doubles) checks it, and growing past n is
reported by the fence - 'PoolState.boxes grew past its @max(16) (it holds 16)' - counted under
count, said under warn, and under fail (a headless or dev build's default) the run ends with exit 87.
Mem.over("what") is the same for a package's own table: ludic.base's StrTable past its most
(sb_intern) and ludic.ui's memo past three quarters of MM_CAP no longer quietly copy per call. The
census counts overflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:43:02 +03:00
ce2699dfee leak at birth (25.2d): an allocation nothing keeps, made where the arena does not take it
ludic deps --births lists every site the escape analysis finds kept by nothing and not the frame
arena's - boot and load code, a function spanning frames, frame code with the arena off - which is
made and dropped and never given back; birth_leaks is a number --check ratchets. A text used up by +
or == where it is made is freed at once and not counted; nor is what @alloc_ok covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:37:50 +03:00
db3a3d80d1 frame allocs: the action queue's generated takers are its kept records, not frame allocations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:34:59 +03:00
a0b030290b region rule (25.3c): keep() and intern(), frame_keeps, and --arena-strict
keep(x) copies a string, a slice (header and elements) or a record (shallow) onto the heap; intern(s)
hands back one heap string per distinct text from a fixed table in the runtime (FNV-1a, 65536 slots,
copied the first time; past 49152 only copied). Both are how frame code keeps what it made on purpose:
the escape analysis takes the copy as the heap's and leaves the argument LOCAL.

The analysis now records why a class escapes (the store, the event, the global it reached) and
ludic deps lists every allocation frame code makes and keeps - fkeep lines, 'ludic deps --keeps',
the frame_keeps number --check ratchets - leaving out what is under @alloc_ok and a push's growth
(25.5's capacities). --arena-strict (or 'arena strict') makes each an error naming the store, before
anything is emitted. A test: a template stored into a state is the one error; keep and intern of the
next two, an @alloc_ok push and a scratch temporary are not; 195 frames of arena resets under
R3D_ARENA_CHECK=1 later the kept and interned texts read as made, and intern gives the same string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:33:53 +03:00
bef0d6fbca arena (25.3b): LOCAL sites allocate from the frame's scratch; dispatch is not an allocation; @frame by property
Behind ludicc --arena (or 'arena on' in the program's package.ludic): the escape analysis runs and a
LOCAL site's allocation raises @lp_want for that one call, so it comes from the frame's arena. Two
halves in one mmap reservation (R3D_ARENA_MB each, 256 by default), bump-allocated with a 16-byte
size header, flipped at each frame mark: a frame's scratch is good through the next frame, then its
half is started again (R3D_ARENA_CHECK=1 fills it with 0xDD first). The heap takes over when no frame
is running, off the main thread, or past the half's end; lp_free ignores an arena block and
lp_realloc copies one out. R3D_ARENA=0 turns it off at run time; the census reports each half's
high-water mark. A program that builds text, a list and a record per frame: 29998 heap blocks made
and 18002 freed without it, 8 and 8 with it and 544 bytes of scratch a frame, the same output.

A dispatch's 'new' fills the queue's kept record (E_NEW.b), so 25.2 no longer counts it and 25.3
treats its fields as kept. '@frame' is keyed by property and field: a 'run' field is a root only in
a property that marks it, and 'tick' stays a System's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:25:32 +03:00
8ca18725b6 escape (25.3a): which allocations never outlive their frame - the analysis, behind --escape-report; @alloc_ok on generics
emit_escape.ludic: every value is in a class, joined by flow edges (a let, an assignment, an argument
into its parameter, a result into the call) and store edges (a field, an element, a push). HEAP (a
parameter, a state, a global, what an unknown call hands back) flows forward; ESC (stored into
something HEAP, into a global, into an event's fields or named values, handed to an unknown callee)
flows backward, and from an ESC or HEAP target along a store. A load is its base's class. A site that
is neither ESC nor in a function reaching Mem.frame is LOCAL (Node.uns = ES_SCRATCH). ludicc
--escape-report prints each site and the totals; nothing is emitted differently yet - the arena that
allocates the LOCAL sites is next.

@alloc_ok on a generic now covers its instances (kept_push$NetFact is under kept_push's), and a
statement's @alloc_ok is carried on the node (Node.uns), so a generic's clone keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:05:07 +03:00
1e2a6bab5b frame allocs (25.2): @frame on a step list's field, @alloc_ok on a statement, and on an exported function
A field declared '@frame run: fn(...)' makes every function stored in it a frame root, as a System's
tick is. @alloc_ok("why") before a statement takes that statement out of frame_allocs and makes what
it allocates declared at run time; a function holding one keeps the fence's scope depth and puts it
back at its return, so a return inside the statement cannot leave the scope open. @alloc_ok above
'export function' was lost - export parses the declaration one call down - and is now carried to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:56:32 +03:00
b35409254e fence (25.1c/25.2): natives by library in the census, and @alloc_ok's allocations declared at run time
The census reads ludic.physics' jph_heap_bytes/_peak and the Vulkan runtime's lvk_ac_bytes/_peak by
name (dlsym, so nothing a package declares is declared twice) and prints jolt, vulkan and the rest
of the heap apart. An @alloc_ok function counts a scope in and out (@lp_fdecl): what it and its
callees make is marked declared in the side table, reported as 'declared' in the census and left
out of a frame's verdict and of Mem.kept().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:54:08 +03:00
76b1bd20ae frame allocs (25.2): what a frame can come to allocate, counted and ratcheted
deps_reach's graph gains the handlers and each @On body (an emit reaches its event's listeners).
Roots: a handler in a frame phase, every reducer, an @On body, and a function stored as a System's
tick. Every allocating construct in what they reach - new, a list literal, push (grow), text built
by + or a template, words/floats/buffer/bytes - is a falloc line with the shortest chain from a
root (root>..>last six), and the program's count is frame_allocs. @alloc_ok("why") on a function or
a handler takes it and what only it reaches out; the reason is required. ludic deps --allocs lists
them, and frame_allocs is a number --check ratchets. Maroon Lake starts at 2661.

Not yet: @frame on a step list's field (only 'tick' is a root field so far), statement-level
@alloc_ok, the three lints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:50:09 +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
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
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