Commit graph

155 commits

Author SHA1 Message Date
9b3d3e3298 ludic.ui: the pointer looks through a box with no size; a control's parts answer :hover
From the user's play, reproduced on Maroon Lake's customisation screen with the pointer scripted over
each control (the lab's R3D_CLICKS): nothing on it was ever pressed or hovered.

- A screen's root <div> round a positioned panel (a component's root holding the kit Screen's
  modal) lays out to 0x0, and the hit test (fr_pick) and :hover (fr_under) only walked into a child
  whose box held the point - so nothing under it was reachable. A box with no size holds no point and
  clips nothing: both look through it now (fr_empty), without hovering it.
- A control's parts (a select's < and >, a range's thumb) are made by ct_part, which never set
  `hovered`: `select .ui-prev:hover` could not match anywhere. It is set as an element's is.

Golden ui_pointer_through: the panel's button pressed and hovered through the empty root, and a
select's > hovered (before this: pressed 0, hovered 0). The 36 ui examples pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:26:33 +03:00
e476a9d975 ludic.ui: emit click reaches on-click, and a select's < steps back
From the user's play: clicking an item in the pack opened nothing, and the wardrobe's picks did
not respond - both are a component whose button says `emit click` (ItemCell, LookCell, ListRow),
answered by its user's on-click. An on-* attribute's name is kept as a browser would have it, so
on-click is on-press (tpl_event), and the emit looked for "click" and found nothing. An emitted
name now goes through the same tpl_event.

And a settings cycler's left arrow did the right one's job: any press let go on a select stepped it
forward. ct_activate_at steps back when it is let go over the select's .ui-prev; Enter and the rest
of the select still step forward.

Goldens ui_emit_click (the mouse and a press both reach on-click; with the toolchain before this,
neither does) and ui_select_arrows (1 -> 0 on <, then 2 on > >; before this, 1 -> 2 on <). The 33
ui examples pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:32:45 +03:00
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
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
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
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
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
dd55945670 Merge branch 'lang/memory-fence' into lang/foundations
# Conflicts:
#	selfhost/ludicc.seed.ll
#	selfhost/ludicc.win.seed.ll
2026-09-28 15:51:27 +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
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
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
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
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
9425bdc4e5 feat(render3d): 22.10 - ludic.render3d carries MoltenVK, so a Mac program has its Vulkan driver
native/build.sh builds MoltenVK v1.4.2 from its pinned, checksummed tag (its dependencies at the
commits its ExternalRevisions pins), thinned to arm64, id @rpath/libMoltenVK.dylib, signed ad hoc;
its licence goes in native/LICENSE-MoltenVK. Every render3d program links it through its rpath -
the package's lib/ while developing, Contents/Frameworks in a bundle, where ludic bundle puts and
signs it - and vk_mac.ll also looks for @rpath/libMoltenVK.dylib (after an SDK loader, so the
validation layer still stacks in development). The suite's SDK stand-in (vk_env) is gone: the
render checks draw on the MoltenVK the package carries. gpu_is_gl() removed; nothing calls it.

ludic-dev test 306/306 with no Vulkan SDK in the environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:16:26 +03:00
9a4137e9da wip(render3d): plan 22.14 - the OpenGL backend removed (suite 306/306, not yet handed over)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:30:11 +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
cc384efee0 feat(render3d): the meadow's blades are culled on the GPU - under 1.5 ms of a MoltenVK frame for all the grass
- grass_cull.comp decides each candidate blade once a frame (place, ground, density, water,
  slope, frustum, colour field) and writes survivors into three bands by distance, one indirect
  draw each; grass_inst.vert only bends and places the vertices. OpenGL keeps grass.vert's
  per-vertex path; R3D_GRASS_GPU=0 compares
- compute programs take sampled textures after their buffers (gpu_compute_tex / gpu_dispatch_tex),
  and every dispatch now records a compute-to-draw memory barrier
- the blades as a sward: 4 m cells, bands with five, three and one-quad blades, spacing doubling
  every 18 m to 70 m, never narrower than a pixel; lit facing the sun and leaning to the sky,
  shadow looked up above the ground (it read the terrain as its caster), a colour ramp that
  leaves only the sheath dark, clumps, dry patches and a tussock shade worked out per blade
- scatter layers flagged grass are skipped while the blades draw (and under R3D_NOGRASS);
  R3D_BLADES, R3D_NOBLADES win over the game's setting; R3D_GRASS_S0/D0 for measuring

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:47:36 +03:00
1fc01df39c feat(bundle): a native library's licence ships with it - each linked package's native/LICENSE* beside the .exe, in Contents/Resources on macOS
Checked on both machines with a bundled Jolt probe: the licence and the library in place, the app
signed (Mac) and the probe's ball landing at the same height on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:43:16 +03:00
b247603b7e merge lang/foundations into lang/native-jolt (seeds regenerated)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 03:20:44 +03:00
38d4ea72b1 feat(render3d): Vulkan in a macOS window through MoltenVK; window built-ins take a slice's elements; the NEAR_FADE SPIR-V
- a CAMetalLayer on the view (cocoa.ll win_metal_layer), VK_EXT_metal_surface, QuartzCore linked
  with Vk.*; the drawable measured after the layer sets the backing scale
- vk_mac.ll opens MoltenVK directly after any loader: a bundle ships only libMoltenVK.dylib
- a covered window is not presented to (win_visible); one frame in flight on macOS
  (R3D_VK_INFLIGHT), with images, buffers and descriptor pools held until it is done
- win_held / win_mouse / win_pad / win_touch / win_text / win_present pass slice elements (arg_buf):
  every windowed program died on its first input poll
- variants.list and SPIR-V for the three NEAR_FADE foliage programs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:52:45 +03:00
b44b88e00e merge lang/foundations into lang/native-jolt (seeds regenerated)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:22:50 +03:00
54f5efb23f feat(migrate): 0.R5 - ludic migrate state --tighten takes mut off every state parameter nothing down the chain writes; --root DIR lets the edits reach a directory without running its programs; a write through a local holding part of a mut state counts as a write to it
Maroon Lake: 149 parameters became read-only. What stays mut is a real write - in the packages mostly a
lazy start inside a question (things_all, gear__ensure), which is what to take out next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:20:44 +03:00
5d3a799e33 feat(pkg): phase 15 - a package can carry a native library
native "<target>" "<path>" in a package's package.ludic; the compiler records the libraries of
every package a program imports and writes them into the IR (; ludic-native:), so ludicc -o,
ludic build, ludic test and ludic bundle all link one list. macOS: an rpath to the package and to
Contents/Frameworks, where ludic bundle copies and signs each library and drops the build
machine's rpath. Windows: the import library, the .dll copied beside the exe (--natives-out for
the bundle). tools/native/lib.sh builds from a pinned, checksummed source with clang on both
machines; ludic.nativeecho is the worked example; the shim rules are in packages/README.md.
Linked at build time rather than dlopen (docs/PACKAGES.md says why). Reseeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:35:45 +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
c35481f344 fix(migrate): 0.R4 - --prune keeps a state declared after a plain parameter; a parameter named twice is refused
On Maroon Lake the prune gave home_keep_records(base_app_st, home_st, r: RunRecords, save_app_st) a
second save_app_st at the front, and every call a second argument: a state declared after a plain
parameter was not counted as declared. It is now, and a call to such a function is never given the
state again. `ludic build --check` let the duplicate through and clang refused it; the checker now
refuses a function that names two parameters alike (`add names two parameters n`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 05:32:08 +03:00
71735b10a2 feat(base): 0.R4 - a queue keeps its own count, so every package verb takes only its own state; ludic migrate state --prune
ludic.base's Queue<T> carries a QueueTag (its name and pending count): queue_new(name), q_push(q, v),
q_drain(q), q_clear(q) take no BaseState, and core_undrained(tags) names the given queues still holding
facts. A reducer on a package's state can now call that package's verbs (wallet_earn(wallet_st, n)).

ludic migrate state --prune (ludicc --migrate-prune) takes out each state parameter a function no
longer uses, nor anything it calls, and the argument that fills it - including an argument for a
parameter the callee has dropped, which is taken out before the call is checked, so a generic's T is
told by the argument that says it. A reducer keeps its state. --dry-run now counts the edits it would
make. Every package was moved with it: 608 base_st parameters and their arguments, 1889 edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 04:19:56 +03:00