Commit graph

643 commits

Author SHA1 Message Date
bcdcc8fe8c Merge commit '5d9cbdd' into lang/ecs 2026-09-27 23:55:07 +03:00
bd998748ee fix(leaks): the compass keeps its marks - two lists swapped a frame, a pool of marks reused
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:55:07 +03:00
a071196f89 Merge branch 'lang/foundations' into lang/nav 2026-09-27 23:55:04 +03:00
b1fff7c921 ludic.wildlife: a climb is judged over half a metre ahead, not one frame's step
17.10 found almost every animal turn-back was for a climb, where the navmesh (40-45 degrees) is stricter than the step's 1.2 per metre. So the frame-sized test was refusing a few centimetres of steep ground: a pebble a walker steps across. It also refused sooner the faster the frame rate. wl_climb takes the ground half a metre ahead along the way the step actually goes. climb_test holds it at 60 and 240 Hz: a pebble is crossed and a wall is not. Both tests fail under the old rule. wildlife 24/24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:52:49 +03:00
231715bf98 fix(leaks): what play allocated every frame and never freed - a shadow uniform's name, the Tick, a ridden vehicle's moves
Found with malloc_history over 1200 frames of play without the interface (the first Ludic function
on each allocating stack, live bytes at two marks): sh_loc built `name + "[0]"` per program per pass
per frame - 24.6 MB of 33 in the window; its callers pass both names as literals now. tick_set fills
the frame loop's one Tick instead of tick_new making one a frame. A ridden boat or horse said
VEHICLE_MOVED as a new fact every frame; the metres are added up in VehiclesState and taken once
(vehicle_moved_take / _kind / _x / _z). Play's growth without the interface: 92 -> 14.7 MB a minute
(maroon-lake tests/leakcheck.sh); what is left is phys_push (Physics' fix on lang/nav) and the HUD's
strings. Packages 407, ludic-dev test 305 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:51:37 +03:00
5d9cbddb72 Merge branch 'lang/foundations' into lang/uifree 2026-09-27 23:46:28 +03:00
51a5e06f5c ludic.ui: props kept an instance, a call's arguments a depth, a loop's index shared, an absent attribute's text without a Value; runtime Value.clear - benchmark 36 -> 20 KB a frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:46:18 +03:00
74006849b5 Merge branch 'lang/foundations' into lang/nav 2026-09-27 23:43:42 +03:00
46888b6716 runtime, ludic.ui: a scalar Value is one allocation, not three (lists only for a list or an object); layout and cascade lists kept a depth; small numbers' text made once - benchmark 63 -> 36 KB a frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:43:25 +03:00
1d40f6d95a ludic.ui: splits, words, gradients and keys are made once per text (a bounded memo), sides without a list - benchmark 110 -> 63 KB a frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:38:57 +03:00
d9b20f6eec Merge branch 'lang/foundations' into lang/nav 2026-09-27 23:37:52 +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
70a92a8862 fix(render3d): a refused pipeline is not retried every draw, and an evicted chunk is freed whole
gvk_pipeline_fast cached only pipelines it made, so one the driver refused was attempted again on
every draw, each attempt building its key string and create structs anew; the refusal is cached
too (a Vulkan program's variants are fixed when it is made, so it would fail the same way).
stream_evict made a new chunk list per eviction and never freed the evicted chunks' records; it
compacts the list in place and frees each evicted chunk with its data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:34:13 +03:00
6dd1810038 fix(render3d): a map generated over another releases what it replaces
terrain_generate, terrain_use_dem and terrain_use_ortho each overwrote the previous height
texture (256 MB of RGBA32F at 4096^2), the heights read back (64 MB), the DEM texture and the
orthophoto's texture and pixels. terrain_reload unloads first, so the world swap was already
clean; any other caller building a map over a live one now lets the old go, and the read-back
array is reused, being the same size for every map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:34:13 +03:00
978f128755 Merge branch 'lang/foundations' into lang/uifree 2026-09-27 23:33:48 +03:00
d1c9359e96 ludic.ui: a screen's nodes and envs are pooled and used again - two generations a screen, reset in place, their own lists emptied not replaced; a tree is good until its screen is built twice more (benchmark 256 -> 110 KB a frame)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:33:48 +03:00
83200a453b ludic.ui: string matching compares in place - at_alias, lk_in, colours and every s[a..b] == p made a string per position tried, per rule, per element, per frame (a benchmark page: ~3 MB -> 256 KB a frame)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:27:02 +03:00
c33522d1c7 ludic.wildlife (17.10): the turned-back steps counted by reason (water ahead, a climb, the rest a push)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:25:46 +03:00
f048ffb6ac ludic.physics: queries fill a record the caller keeps, and allocate nothing
phys_pose, phys_ray, phys_push and phys_walker_pose each made a new record per call, some several times a frame, and Ludic never gives one back. They now fill the caller's PhysPose / PhysHit / PhysPush / PhysWalk and return whether they found anything. phys_overlap returns a count; phys_overlap_id(i) reads each id back. The package's own uses (phys_resolve, phys_row, phys_walker_move) read the values buffer directly. Tests take small allocating helpers. physics 20, character 15, vehicles 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:18:26 +03:00
8fbb1f7d03 build: reseeded after merging lang/foundations (vk/leaks) into lang/ecs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:08:51 +03:00
cfb7fa5ca9 Merge branch 'lang/foundations' into lang/ecs 2026-09-27 23:08:24 +03:00
e2626a0687 perf(compiler): the generated drain_actions allocates nothing - an empty queue returns at once, and a drained one is cleared in place instead of replaced by two new lists (seven drains a frame, never freed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:08:24 +03:00
c97e64c3c0 perf(render3d): an actor knows its row - removal swaps the last one in (O(1)) instead of building a new list of every other actor, never freed; actor_keep puts one back after a world swap; actor_clear_all clears in place
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:08:02 +03:00
8808dda605 Merge branch 'vk/leaks' into lang/foundations 2026-09-27 23:04:23 +03:00
80764f85c1 fix(render3d): the Vulkan renderer stops leaking as it loads and draws
- every texture upload malloc'd a CPU copy of its pixels and never freed it (236 MB over the
  valley's boot, and again for every model loaded later): the pixels are converted straight
  into the mapped staging buffer
- the per-level and per-layer views gvk_view_of made outlived their texture, and on MoltenVK a
  view keeps its Metal texture alive: a released texture takes its views with it, and the cache
  is keyed by numbers instead of a string built on every call
- the Vulkan structs filled for a draw, pass, barrier, descriptor set, buffer, allocation or
  upload (about sixty call sites) come from a reused 1 MB scratch ring (gvk_tmp)
- the descriptor-set cache and the retired buffers are emptied in place, not replaced; the grass
  cull's dispatch arguments are made once
- macOS drains an autorelease pool each frame (Vk.frame_pool, lvk_frame_pool in vk_mac.ll)
- R3D_VK_PROF reports Vulkan objects made and destroyed by kind, and every cache's length
- examples/rendering/smooth presents through render3d, so it runs on Vulkan too

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:03:38 +03:00
b6c4d703a2 Merge branch 'lang/foundations' into lang/nav 2026-09-27 23:02:39 +03:00
b9d0cca281 Merge branch 'lang/foundations' into lang/ecs 2026-09-27 22:56:57 +03:00
c74099f5f8 fix(base): a queue drain allocates nothing - two lists, reused
The rest of the leak Physics found: a drain that carried facts gave its list away and made a new
one, every queue every frame something happened. The queue keeps two lists and hands one out while
the other fills; a drained list is good until the next drain of that queue (nothing in the game or
the packages keeps one past it). With ab34f82's empty drain and in-place clear, a queue allocates
nothing in steady state. queue_test holds the reuse; every package (403) and lab/unit (90) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:55:43 +03:00
8384ad3215 feat(compiler): the built-in ECS's stores grow - 100 000 entities, where 1024 was the wall
Every per-entity store (@S_ components, @H_ flags, alive, kind, freelist, owners) is a heap block
L_grow doubles from 1024 as L_alloc hands out a slot past it, the new slots zeroed; each site loads
the store's base where it indexes it (ecs_base, its registers %ecsb* so a raw function's t0 labels
cannot collide). Prop.has bounds against @L_cap, Pool.capacity answers it, a mod's registered
stores grow with the rest, every main grows the stores once before anything reads them. A snapshot
records its slot count first and a load grows to it before reading back. The overflow stop of
1c7ce84 is gone with the wall. ludic-dev test 305 passed, selfhost-test 33 passed; 1000 / 5000 /
100000 entities spawn and count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:53:24 +03:00
e227b75472 ludic.save, ludic.telemetry: a parsed tree they refuse is freed (23.2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:51:06 +03:00
f65eda8b47 ludic.nav (17.10): the time paths take, counted in the shim
nav_us() gives the microseconds spent in nav_path across the loaded meshes. The shim times each path with the OS's monotonic clock (clock_gettime, or QueryPerformanceCounter from KERNEL32), so the DLL still needs no C++ runtime. Both libraries are rebuilt, and 13 tests pass on the Mac and the PC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:41:30 +03:00
297578705e ludic.wildlife (17.10): count the steps refused and turned from
The count is wildlife_turned(), the 75-degree fallback an animal takes when the way was not enough. It is a measure only and is not saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:38:37 +03:00
ab84a5f936 ludic.npc (17.7): a person's fallback is a fact
A step round what is in the way, a target planned again after twenty seconds without progress, and a target given up are each pushed as NPC_F_STUCK (how, x, z), so the game can see where the way failed a person. The lake test expects one when a walker turns back from the water; the way test expects none when the world gives the way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:33:25 +03:00
61f802e9ad ludic.wildlife (17.6): a fleeing or wary animal goes by the world's way
It sets a target straight away from the threat (40 m when it flees, 20 m when it is wary) and heads for it with wl_head, so the way port takes it round water and cliffs. Where the world gives no way, it still runs straight away as before. way_test now has a flee round the wall's end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:31:07 +03:00
f1153f59a2 Merge branch 'lang/foundations' into lang/nav 2026-09-27 22:26:11 +03:00
0c6235e55b feat(ecs): thing_nearest_within - the nearest of a kind strictly within a radius, from the grid or the kind's list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:25:34 +03:00
1c7ce84881 fix(compiler): the built-in ECS stops at its 1024th entity instead of writing past every store
L_alloc handed out a fresh slot without a bound, so the 1025th spawn wrote past the end of every
component array. It stops with a located message naming the store's size and where many things
belong (ludic.base's Table). Growable stores are plan 24.8: the save, rollback snapshot and mod
table write the stores whole at a compile-time size, so that is a file-format change. Reseeded;
ludic-dev test 305 passed, selfhost-test 33 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:22:45 +03:00
9539f7bcec perf(json): 23.2 - a parsed tree can be let go (Value.free / Json.free: its nodes and lists, not its strings), a string is parsed in one allocation instead of one per character, the parser's cursor is freed, and gltf_load frees the last file's tree and the text it parsed
300 parses of a 446 KB glTF: 2118 MB before, 528 MB with the one-allocation string, 94 MB freed.
A headless Maroon Lake at play: 2334 -> 2195 MB by footprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:17:27 +03:00
649a65d854 feat(ecs): things_now / things_changed - a consumer keeps the tick it read up to and asks only for the Things written since (untouched 64-row blocks skipped)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:13:45 +03:00
61a6ab2e7b Merge branch 'lang/foundations' into lang/ecs 2026-09-27 22:08:40 +03:00
50aa535f88 docs(base): the table's observers, groups and chunks, sparse index values, block change stamps and the parallel-worker rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:08:19 +03:00
f3fec88888 feat(ecs): chunks as a grouping - IntIndex takes sparse values; ludic.things' chunk column and things_drop_chunk
A streamed world loads and lets go of a chunk's entities together, so a chunk is a first-class
group: chunk_of(x, z, size) packs a chunk's cell into an int, tb_remove_all(tb, ix, v) removes every
row an index files under one value (the observers told of each). IntIndex kept one list per value
up to the largest, which a packed chunk number (hundreds of millions) turned into hundreds of
millions of empty lists and a 12 GB test run; a value past 1024 now gets its slot through an IntMap,
so a sparse value costs one list.

ludic.things files every Thing under its 256 m chunk (TH_CHUNK), kept by every move;
things_chunk_count and things_drop_chunk (the port and the facts told of each removal, as
thing_remove tells them) are what the memory budget's streamed chunks ask. The moves are their own
file (moves.ludic). Tests: ludic.base 35, ludic.things 8, all under a 2 GB cap (24 MB peak).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:07:26 +03:00
5e46afdb0f feat(nav): 17.10's measure - paths asked for, found and cut short since the meshes loaded (nav_asked / nav_found / nav_short)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:05:07 +03:00
7834b26ef8 feat(compiler): a Job.parallel_for worker may read a state but not change one
Every pool thread runs the worker at once with the same states, so a mut state in a worker was a
race nothing reported. check_worker_ref refuses a worker whose leading states include a mut one;
threads.ludic's total moves into the words the worker is handed, under the mutex. The seeds are
regenerated (ludic-dev reseed). ludic-dev test 305 passed, selfhost-test 33 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:04:59 +03:00
de9c92b360 feat(nav): nav_next_corner - a way's next corner read back as the nearest point is, so a walker's question and a goal share one answer and a game needs no state of its own for them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:01:42 +03:00
6ccbed923e build(nav): the Windows library with the staged file read, rebuilt on the PC - 11 tests there
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:57:53 +03:00
ee0768bf3e feat(npc, nav): 17.5 - a person walks by the world's way where it has one (NpcWorld.way: the next corner toward the target, asked every half second, on reaching it or for a new target; the errand begins only at the target itself; the step round and the give-up stay the fallback), way_test round a wall; and ludic.nav reads a mesh file into the shim's own memory and frees it once parsed, so loading a map leaves nothing in Ludic's never-freed heap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:57:34 +03:00
7ceefa8c50 feat(ecs): wildlife_near / wildlife_near_of into the caller's list, wildlife_alive_of
The living animals around a point from the grid (a rare species from its own list), into a list
the caller keeps in its own state, so a frame's proximity question walks the cells it covers
rather than every animal ever made. wildlife_alive_of is the species index's count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:54:39 +03:00
4add35b4ca feat(wildlife): 17.5 - an animal walks by the world's way where it has one: WildlifeWorld.way (the next corner toward where it is going, asked every half second or on reaching it) steers wandering and going to water or forage, and goal (a spot it can reach) is a wander's target, seeded by the same two draws so the dice do not move; the defaults are the straight line and the old target. way_test: round a wall by its corners, and a wander's target where the world says
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:51:35 +03:00
045bf22e10 feat(ecs): ludic.vehicles on a Table<Vehicle>
The vehicles are rows of a ludic.base Table with kind and owner as columns, every player's own by
an owner index and a nid map: vehicle_of walks only that player's rows, vehicle_by_nid is a map
lookup, and a player who leaves has their rows removed instead of the whole list rebuilt. ve_add
takes the owner, so the owner is filed once and never assigned around the table. Positions stay
on the record: a boat moves every frame and there are at most two a player. vehicles_test holds
the table (9 tests); the game builds against it and lab/unit passes (88 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:50:23 +03:00