Commit graph

306 commits

Author SHA1 Message Date
73f7d0b7a3 ludicc --emit-syntax: the language's vocabulary as one table beside the parser
selfhost/frontend/vocab.ludic holds every keyword with its role (declaration,
modifier, statement, operator, constant), every declaration's form, the built-in
types and phases, every attribute with what it goes on, its arguments and a
one-line doc, the operators and the literal forms; `ludicc --emit-syntax` prints
it as JSON ("syntax_version": 1) and exits before reading any program. The
parser dispatches on words where it meets them, so the table is held to it from
the emitter's side: a keyword's "reserved" is is_reserved_word's answer, a phase
must pass is_phase_name and a field attribute listed as read must pass
at_field_known, or the emitter refuses to print. Reseeded (both seeds assemble;
bootstrap-cfree: out.ll == seed.ll).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:59:02 +03:00
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
847048825a Merge commit '37338cb' into lang/foundations (itoa of INT_MIN: a 2^31 hex literal in 32-bit arithmetic kept its value); reseeded, bootstrap-cfree fixpoint holds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:09:09 +03:00
0a6485ca71 selfhost: the seed regenerated from the merged compiler (deps roots + chunked keys) - the merge commit 2c800ea took chunkkeys' seed and the reseed stayed in the working tree
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:07:45 +03:00
37338cb0f7 compiler: the most negative int is emitted as itself - itoa took its digits off -v, which overflows back to itself, so 0x80000000 in an int was written as a bare "-" (invalid IR); digits now come off v as it is, and the buffer holds a long's 20
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:00:49 +03:00
2c800ea84d Merge commit 'ec49207' into lang/foundations (deps: roots out of the reach ratchet); the seed regenerated from the merged compiler, bootstrap-cfree fixpoint holds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:56:57 +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
49e32f9b47 chunked tables: a key is unique across its map and the slot's own, not interned - a slot keeps row i's key in its own buffer i, rewritten when the slot is refilled (interning ~92k map-unique keys as a player walks would fill the bounded intern table and keep them all); ludicc --check refuses a key written in two of a map's chunk files, naming both; reseeded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:43:20 +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
81cb992fad attributes: @Material(field) resolved as @Node is; @OneOf on a string field takes words, and every registry row's value is checked against them - words on another field, or constants on a string, is an error; reseeded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:13:32 +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
b5bffe99fa attributes: @Tint(SLOT) on a colour field, into the schema - SLOT must be a constant that exists (a row of the program's tint-slot registry); reseeded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:51:27 +03:00
34c2ac2cb9 attributes: @Clip(field) resolved as @Node is, and @OneOf takes constants as well as a prefix - one argument ending in _ is a prefix, otherwise each is a constant that must exist; reseeded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:34:33 +03:00
ebfbabdfd8 attributes: @Node(field), @Derived, @Text, @Multiline and @Key on a field, into the schema - @Node's field must be @Asset("gltf") or an @Ref to a registry whose record has exactly one, every failure reported; reseeded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:31:17 +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
863b9712f9 reseed for the schema, the JSON diagnostics and the editor attributes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:06:54 +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
9cc3f24c56 ludic build on every core: the IR split with llvm-split and compiled by a clang per part at once
Most of a build was one clang -O2 on one .ll (the game: 32 s of a 41 s headless build, one core).
Both paths that assemble - the CLI's (build.ludic: ludicc --emit-llvm, then clang) and ludicc's own
(-o, which ludic bundle and the examples use) - now cut the program's IR into N parts with llvm-split
(externalizing what the parts share), compile them with one clang each in parallel (-x ir -O<opt>
-mmacosx-version-min=11.0, the link's own clang taking the objects where it took the .ll), and remove
the parts and objects after. N is $LUDIC_JOBS, else min(cores, free GB / 1.5).

It needs an llvm-split and a clang of the same LLVM (Homebrew's LLVM 22 writes attributes Apple's
clang 17 cannot read): $LUDIC_LLVM, else /opt/homebrew/opt/llvm/bin. With either missing, on Windows
(its shell cannot run the parts at once yet), with LUDIC_SPLIT=0, or when a part fails, it compiles the
.ll whole as before.

$LUDIC_OPT=1 is a developer's faster build; ludic bundle sets LUDIC_OPT=2 for its compile whatever the
shell says.

Measured before the compile-only rule (this Mac, 12 cores, one build at a time):
- the game headless: 37-41 s -> 13-15.5 s (8 parts / by free memory), peak 2.1 GB -> 1.0-1.1 GB;
- the lab headless: 43.1 s -> 12.7 s, peak 2.4 GB -> 1.0 GB;
- the game at LUDIC_OPT=1, split: 11.8 s (fps cost not measured).
Both built and linked clean; the goldens and a headless shot of the result are not run here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:12:27 +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
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
28ad6f62ea reseed after the site audit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:41:44 +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
61e057f73e reseed after intern overflow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:39:06 +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
48a8caa292 reseed after the intern store
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:34:35 +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
8c72437ecc reseed after the scan fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:27:50 +03:00
2f6386069d Merge branch 'lang/memory-scanfix' into lang/foundations
# Conflicts:
#	selfhost/ludicc.seed.ll
#	selfhost/ludicc.win.seed.ll
2026-09-28 18:26:57 +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
4e0c30488f reseed after the fence's declared bytes and the Math.* result types
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:04:07 +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
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