A prebuilt tool, bin/ludic-ui-preview (built by ludic-dev build, shipped beside ludic-lsp, run as
`ludic ui-preview [--font DIR]`): ludic.ui with a backend that records every draw call as a line,
and mock component classes the studio describes (load / model / calls). frame t runs ui_show at
interface time t; text is measured on the CPU with the game's font.json metrics; locale goes
through ludic.i18n; tree / box / rules inspect the frame. No game code, no GPU, no network. The wire
protocol is frozen as tools/ui-preview/protocol-v1.md; smoke.txt is a transcript to run.
ludic.ui gains, all additive: UiClass.make_of, UiBackend.said / emitted, UiRule.text / at (with
comment line breaks kept so rule lines count true, and a class's styles read under its .lss path),
and ui_root, ui_find, ui_rules_of, ui_rule_value, ui_building, ui_class, ui_class_load,
ui_file_forget, ui_errors_clear; ui_nine_cuts_into exported.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The arrows, a press on the select and the pad step past a disabled option (ct_opt_step), wrapping
as before.
- A value naming a disabled option is shown as the nearest enabled one (the lower of two as near) and
set to it once the build is done: an event fired while the tree is built is cleared with the frame's,
so the clamp is queued (ct_clamp_n / _i) and fired after nt_fired_clear.
- The < or > whose next option is disabled is drawn disabled (its part's :disabled), so a cycler shows
where the options stop being on; it still steps past them.
- ct_build_select moves to controls_opts.ludic with the rest (controls_build.ludic 119 -> 104 lines).
Golden ui_select_disabled: options 3 and 4 disabled, a value of 4 clamps to 2, > is drawn disabled
there, > wraps to 0 and < from 0 steps back to 2. The 38 ui examples pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Master's windowed fog profile failed the fence at walk t 35 s: +512 B from value_new / value_put /
value_slot / value_lists under bd_class and cmp_keycap_model - a KeyCap first shown mid-walk.
It is a first build, bounded, not a per-show leak: an unmounted instance goes to in_free and the next
show of its class reuses it (in_reuse, `renew`), its props and model objects kept and filled in place.
Only a NEW instance makes them - bounded by how many of that class are up at once, and the ones
unmounted less than two builds ago. in_reuse already declared the record; the props and model objects
and their first fill, made afterwards in bd_class, were not. They are now: in_reuse marks a new record
`fresh`, and bd_class's first fill of it goes through bd_first_fill (@alloc_ok) - a reused instance's
fill stays judged, so a real per-show leak would still fail.
Golden ui_first_show: a component first shown at frame 700, once the fence is judging, then hidden and
shown twice more: bad 0 (the toolchain before this fails frame 701: +464 B value_new from value_slot
under cmp_cell_model, value_put grow - the profile's failure). The 37 ui examples pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ludic.ui cv_join (units_pieces.ludic:93): a var() value's pieces were joined by +, a text left
behind per piece on every memo miss; it is written at its length at once (units_join.ludic), and
the miss is its own declared function (cs_vars_miss), bounded by the memo.
- ludic.hints hn_slots (rail.ludic:41): hints_reset made a new rail every time; the rail is
emptied in place, made again only for another count (rail_slots.ludic).
- value_spare_put (value.ludic:138): a list's spares grow only past the most it has ever cut off;
declared as such.
- ludic.save: save_read reads, parses and frees the file's text - the tree's strings are the
parser's own - and says whether the text was whole (SaveRead.intact) for the words of a refusal.
Maroon Lake's trip and home reads kept the whole file on every load and every menu card read.
Tests: ludic.save (10), ludic.hints (8); the 36 ui examples; value_list_regrow, json_saves and
alloc_fence_declared unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
The last kept bytes of the leak gate's pack, shop and journal screens (memory_final, count mode).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
'@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>
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>