From 231715bf98e58becc185a643f8c9cacebf504ee9 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 23:51:37 +0300 Subject: [PATCH 1/3] 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 --- packages/ludic.base/tick.ludic | 8 ++++++++ packages/ludic.render3d/shadow.ludic | 12 +++++++----- packages/ludic.vehicles/horse.ludic | 6 +++--- packages/ludic.vehicles/index.ludic | 1 + packages/ludic.vehicles/moved.ludic | 19 +++++++++++++++++++ packages/ludic.vehicles/ports.ludic | 4 ++++ packages/ludic.vehicles/ride.ludic | 2 +- packages/ludic.vehicles/system.ludic | 1 + .../ludic.vehicles/tests/vehicles_test.ludic | 4 +++- 9 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 packages/ludic.vehicles/moved.ludic diff --git a/packages/ludic.base/tick.ludic b/packages/ludic.base/tick.ludic index dfb1f039..8ce45a2d 100644 --- a/packages/ludic.base/tick.ludic +++ b/packages/ludic.base/tick.ludic @@ -14,6 +14,14 @@ export const PH_COMMIT: int = 4 export const PH_PRESENT: int = 5 export const PH_COUNT: int = 6 +# the same Tick filled again: the frame loop keeps one and hands it on, rather than a new one a frame +export function tick_set(t: Tick, dt: float, frame: int, hours: float) -> Tick { + t.dt = dt + t.frame = frame + t.hours = hours + return t +} + export function tick_new(dt: float, frame: int, hours: float) -> Tick { let t = new Tick t.dt = dt diff --git a/packages/ludic.render3d/shadow.ludic b/packages/ludic.render3d/shadow.ludic index d9b60358..307789f7 100644 --- a/packages/ludic.render3d/shadow.ludic +++ b/packages/ludic.render3d/shadow.ludic @@ -232,8 +232,10 @@ function shadow_dump(render3d_st: mut Render3dState) -> void { } # a uniform array's location: some drivers only answer to the "[0]" spelling -function sh_loc(render3d_st: Render3dState, prog: int, name: string) -> int { - var loc = gpu_uniform(render3d_st, prog, name + "[0]") +# an array uniform by its first element's name, else its bare one; both names are literals, because +# building `name + "[0]"` here made a string per program per pass per frame that was never freed +function sh_loc(render3d_st: Render3dState, prog: int, first: string, name: string) -> int { + var loc = gpu_uniform(render3d_st, prog, first) if loc < 0 { loc = gpu_uniform(render3d_st, prog, name) } return loc } @@ -252,10 +254,10 @@ function shadow_bind(render3d_st: mut Render3dState, prog: int) -> void { if loc < 0 { loc = gpu_uniform(render3d_st, prog, "u_cascade_vp") } if render3d_st.r3d_debug_shadow and not render3d_st.sh_printed { render3d_st.sh_printed = true; print(`cascade vp loc {loc} / {gpu_uniform(render3d_st, prog, "u_cascade_vp")} split loc {gpu_uniform(render3d_st, prog, "u_cascade_split")} shadow loc {gpu_uniform(render3d_st, prog, "u_shadow")}`) } u_mat4n(render3d_st, loc, SHADOW_CASCADES, render3d_st.sh_vp) - u_fv(render3d_st, sh_loc(render3d_st, prog, "u_cascade_split"), SHADOW_CASCADES, render3d_st.sh_split) + u_fv(render3d_st, sh_loc(render3d_st, prog, "u_cascade_split[0]", "u_cascade_split"), SHADOW_CASCADES, render3d_st.sh_split) if render3d_st.r3d_debug_shadow and not render3d_st.sh_printed3 { render3d_st.sh_printed3 = true; print(`range {fixed(render3d_st.sh_range[0])} {fixed(render3d_st.sh_range[1])} {fixed(render3d_st.sh_range[2])} {fixed(render3d_st.sh_range[3])} texel*1000 {fixed(render3d_st.sh_texel[0] * 1000.0)} {fixed(render3d_st.sh_texel[1] * 1000.0)} {fixed(render3d_st.sh_texel[2] * 1000.0)} {fixed(render3d_st.sh_texel[3] * 1000.0)} locs {gpu_uniform(render3d_st, prog, "u_cascade_range")} {gpu_uniform(render3d_st, prog, "u_cascade_texel")}`) } - u_fv(render3d_st, sh_loc(render3d_st, prog, "u_cascade_range"), SHADOW_CASCADES, render3d_st.sh_range) + u_fv(render3d_st, sh_loc(render3d_st, prog, "u_cascade_range[0]", "u_cascade_range"), SHADOW_CASCADES, render3d_st.sh_range) if r3d_env_has(render3d_st, "R3D_FORCE") { render3d_st.sh_force = Text.to_int(r3d_env(render3d_st, "R3D_FORCE")) } u_i(render3d_st, gpu_uniform(render3d_st, prog, "u_force_cascade"), render3d_st.sh_force) - u_fv(render3d_st, sh_loc(render3d_st, prog, "u_cascade_texel"), SHADOW_CASCADES, render3d_st.sh_texel) + u_fv(render3d_st, sh_loc(render3d_st, prog, "u_cascade_texel[0]", "u_cascade_texel"), SHADOW_CASCADES, render3d_st.sh_texel) } diff --git a/packages/ludic.vehicles/horse.ludic b/packages/ludic.vehicles/horse.ludic index 50ace149..16c7b7a6 100644 --- a/packages/ludic.vehicles/horse.ludic +++ b/packages/ludic.vehicles/horse.ludic @@ -5,7 +5,7 @@ const VE_HORSE_GALLOP: float = 11.0 const VE_HORSE_ACC: float = 4.0 const VE_HORSE_TURN: float = 1.6 -function ve_ride_horse(vehicles_st: VehiclesState, v: Vehicle, ix: float, iz: float, run: bool, dt: float) -> void { +function ve_ride_horse(vehicles_st: mut VehiclesState, v: Vehicle, ix: float, iz: float, run: bool, dt: float) -> void { var top = VE_HORSE_WALK if run and v.fuel > 5.0 { top = VE_HORSE_GALLOP } v.speed = v.speed + Math.clamp(iz * top - v.speed, -VE_HORSE_ACC * dt, VE_HORSE_ACC * dt) @@ -29,7 +29,7 @@ function ve_ride_horse(vehicles_st: VehiclesState, v: Vehicle, ix: float, iz: fl # the legs carry it from where it is; what they covered along its heading is its speed now, never # more than was asked, so a trunk or a rise it cannot take brings it to a stand -function ve_legs_walk(vehicles_st: VehiclesState, v: Vehicle, vx: float, vz: float, dt: float) -> void { +function ve_legs_walk(vehicles_st: mut VehiclesState, v: Vehicle, vx: float, vz: float, dt: float) -> void { if not VehicleLegs.walk(v.nid, v.x, v.y, v.z, vx, vz, dt) { return } let dx = VehicleLegs.x() - v.x let dz = VehicleLegs.z() - v.z @@ -39,7 +39,7 @@ function ve_legs_walk(vehicles_st: VehiclesState, v: Vehicle, vx: float, vz: flo let got = (dx * -Math.sin(v.yaw) + dz * -Math.cos(v.yaw)) / dt if v.speed > 0.0 { v.speed = Math.clamp(got, 0.0, v.speed) } else { v.speed = Math.clamp(got, v.speed, 0.0) } let moved = Math.sqrt(dx * dx + dz * dz) - if moved > 0.0 { ve_say(vehicles_st, VEHICLE_MOVED, v, moved) } + if moved > 0.0 { ve_moved_add(vehicles_st, v, moved) } } # a gallop spends stamina and a walk gives it back; every metre costs hay diff --git a/packages/ludic.vehicles/index.ludic b/packages/ludic.vehicles/index.ludic index 087da740..4f51ef00 100644 --- a/packages/ludic.vehicles/index.ludic +++ b/packages/ludic.vehicles/index.ludic @@ -13,6 +13,7 @@ import "call.ludic" import "keep.ludic" import "mount.ludic" import "ride.ludic" +import "moved.ludic" import "horse.ludic" import "legs.ludic" import "system.ludic" diff --git a/packages/ludic.vehicles/moved.ludic b/packages/ludic.vehicles/moved.ludic new file mode 100644 index 00000000..2efeaee1 --- /dev/null +++ b/packages/ludic.vehicles/moved.ludic @@ -0,0 +1,19 @@ +# moved.ludic - the ground the ridden vehicle covers, added up rather than said a frame at a time: a +# fact a frame was a new record a frame, and Ludic frees nothing. The game takes the sum when it asks. +function ve_moved_add(vehicles_st: mut VehiclesState, v: Vehicle, d: float) -> void { + vehicles_st.ve_moved += d + vehicles_st.ve_moved_kind = v.kind + vehicles_st.ve_moved_x = v.x + vehicles_st.ve_moved_z = v.z +} + +# the metres covered since the last take (0 when nothing moved), and the sum starts again +export function vehicle_moved_take(vehicles_st: mut VehiclesState) -> float { + let d = vehicles_st.ve_moved + vehicles_st.ve_moved = 0.0 + return d +} +# what covered them, and where it was when it last moved +export function vehicle_moved_kind(vehicles_st: VehiclesState) -> int { return vehicles_st.ve_moved_kind } +export function vehicle_moved_x(vehicles_st: VehiclesState) -> float { return vehicles_st.ve_moved_x } +export function vehicle_moved_z(vehicles_st: VehiclesState) -> float { return vehicles_st.ve_moved_z } diff --git a/packages/ludic.vehicles/ports.ludic b/packages/ludic.vehicles/ports.ludic index 3add3590..861d186a 100644 --- a/packages/ludic.vehicles/ports.ludic +++ b/packages/ludic.vehicles/ports.ludic @@ -43,6 +43,10 @@ export state VehiclesState { ve_kept_boat: bool = false ve_kept_food: float = 40.0 ve_ids: int = 0 + ve_moved: float = 0.0 # metres the ridden vehicle covered since the game last took them + ve_moved_kind: int = 0 + ve_moved_x: float = 0.0 + ve_moved_z: float = 0.0 ve_sx: float = 0.0 ve_sz: float = 0.0 ve: VehicleTable = ve__new() diff --git a/packages/ludic.vehicles/ride.ludic b/packages/ludic.vehicles/ride.ludic index b1962282..8a663d91 100644 --- a/packages/ludic.vehicles/ride.ludic +++ b/packages/ludic.vehicles/ride.ludic @@ -20,7 +20,7 @@ function ve_row(vehicles_st: mut VehiclesState, v: Vehicle, ix: float, iz: float let take = ve_swell(vehicles_st, v, iz, dt) VehicleHull.row(v.nid, iz * VE_ROW * (1.0 - take), -ix * VE_ROW_TURN) let d = Math.abs(v.speed) * dt - if d > 0.0 { ve_say(vehicles_st, VEHICLE_MOVED, v, d) } + if d > 0.0 { ve_moved_add(vehicles_st, v, d) } } # where the hull is now, and how fast it goes along its bow diff --git a/packages/ludic.vehicles/system.ludic b/packages/ludic.vehicles/system.ludic index 619ef7ab..6a4c9510 100644 --- a/packages/ludic.vehicles/system.ludic +++ b/packages/ludic.vehicles/system.ludic @@ -11,6 +11,7 @@ export function vehicles_reset(vehicles_st: mut VehiclesState) -> void { tb_clear(vehicles_st.ve.tab) imap_clear(vehicles_st.ve.nids) vehicles_st.ve_cur = null + vehicles_st.ve_moved = 0.0 vehicles_st.ve_have_dock = false vehicles_st.ve_swell_said = 0.0 vehicles_st.ve_kept_horse = false diff --git a/packages/ludic.vehicles/tests/vehicles_test.ludic b/packages/ludic.vehicles/tests/vehicles_test.ludic index 653caf78..60cb5357 100644 --- a/packages/ludic.vehicles/tests/vehicles_test.ludic +++ b/packages/ludic.vehicles/tests/vehicles_test.ludic @@ -236,7 +236,9 @@ program VehiclesTest { vehicle_mount(vehicles_st, h) ride(physics_st, vehicles_st, 0.0, 1.0, false, 3.0) expect_near(h.speed, 4.5, 0.05) - expect(count(facts(vehicles_st), VEHICLE_MOVED) > 100) + expect(vehicle_moved_take(vehicles_st) > 10.0) # 3 s at 4.5 m/s, added up + expect_eq(vehicle_moved_kind(vehicles_st), VEHICLE_HORSE) + expect_near(vehicle_moved_take(vehicles_st), 0.0, 0.0001) ride(physics_st, vehicles_st, 0.0, 1.0, true, 3.0) expect_near(h.speed, 11.0, 0.1) expect(h.fuel < 100.0) From bd998748ee5362ec4f6bc203f65484fb0a7e4708 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 23:55:07 +0300 Subject: [PATCH 2/3] fix(leaks): the compass keeps its marks - two lists swapped a frame, a pool of marks reused Co-Authored-By: Claude Opus 5.5 --- packages/ludic.compass/gather.ludic | 35 ++++++++++++++++++++++++----- packages/ludic.compass/marks.ludic | 3 +++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/ludic.compass/gather.ludic b/packages/ludic.compass/gather.ludic index bec0f109..03b6524c 100644 --- a/packages/ludic.compass/gather.ludic +++ b/packages/ludic.compass/gather.ludic @@ -1,30 +1,55 @@ # gather.ludic - a frame's marks: begin, every provider marks, end sorts and culls. While a # capture is open the marks go to whoever is listening instead, whatever the tier. -export function compass_begin(compass_st: mut CompassState) -> void { compass_st.cmp_list = new []CompassMark } +# a frame's list and marks are the state's own, emptied and reused rather than made again +export function compass_begin(compass_st: mut CompassState) -> void { + if compass_st.cmp_list == null { + compass_st.cmp_list = new []CompassMark + compass_st.cmp_back = new []CompassMark + compass_st.cmp_pool = new []CompassMark + } + List.clear(compass_st.cmp_list) + compass_st.cmp_used = 0 +} # a provider's verb: a thing at (x, z), shown from compass `tier` up export function compass_mark(compass_st: mut CompassState, x: float, z: float, icon: int, colour: int, tier: int, prio: int, label: string) -> void { if compass_st.cmp_list == null { compass_begin(compass_st) } - let m = cmp_new(x, z, icon, colour, tier, prio, label) if compass_st.cmp_capturing { - push(compass_st.cmp_caught, m) + push(compass_st.cmp_caught, cmp_new(x, z, icon, colour, tier, prio, label)) # kept by the listener return } if CompassWorld.tier() < tier { return } - push(compass_st.cmp_list, m) + push(compass_st.cmp_list, cmp_take(compass_st, x, z, icon, colour, tier, prio, label)) } # highest priority first, the providers' order kept within one; then the cap export function compass_end(compass_st: mut CompassState) -> void { if compass_st.cmp_list == null { compass_begin(compass_st) } - let out = new []CompassMark + let out = compass_st.cmp_back + List.clear(out) for p in 0 .. 3 { let want = COMPASS_TRACKED - p for i in 0 .. len(compass_st.cmp_list) { if cmp_band(compass_st.cmp_list[i].prio) == want and len(out) < compass_st.cmp_cap { push(out, compass_st.cmp_list[i]) } } } + compass_st.cmp_back = compass_st.cmp_list compass_st.cmp_list = out } +# the pool's next mark, filled (one made only when the pool is short) +function cmp_take(compass_st: mut CompassState, x: float, z: float, icon: int, colour: int, tier: int, prio: int, label: string) -> CompassMark { + if compass_st.cmp_used >= len(compass_st.cmp_pool) { push(compass_st.cmp_pool, new CompassMark) } + let m = compass_st.cmp_pool[compass_st.cmp_used] + compass_st.cmp_used += 1 + m.x = x + m.z = z + m.icon = icon + m.colour = colour + m.tier = tier + m.prio = prio + m.label = label + return m +} + function cmp_band(p: int) -> int { return Math.clamp(p, COMPASS_PLAIN, COMPASS_TRACKED) } # once a frame: ask every registered provider, in the registry's order diff --git a/packages/ludic.compass/marks.ludic b/packages/ludic.compass/marks.ludic index ec2a546d..1d053def 100644 --- a/packages/ludic.compass/marks.ludic +++ b/packages/ludic.compass/marks.ludic @@ -25,6 +25,9 @@ export open registry CompassProviders of CompassProvider as COMPASS_FROM export state CompassState { cmp_cap: int = 64 cmp_list: []CompassMark = null # this frame's, sorted and culled + cmp_back: []CompassMark = null # the list the sort fills, then swapped with cmp_list + cmp_pool: []CompassMark = null # a frame's marks, reused: Ludic frees nothing + cmp_used: int = 0 cmp_capturing: bool = false cmp_caught: []CompassMark = null # what providers said while someone was listening cmp_ranges: []float = null From d8fa2ce6b00e3ca853e7528754c3af3f1abda7bb Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 23:56:07 +0300 Subject: [PATCH 3/3] fix(render3d): a scatter layer allocates its whole capacity up front again e634177 started a layer at 256 instances and grew it in layer_add and the stream gather, but a game writes l.inst directly (Maroon Lake's track prints, trees and rocks), past what had been grown: "index out of range: 2048, len 2048" in play. layer_new takes its cap up front as before; layer_reserve(l, n) is exported for a caller that writes l.inst itself once layers start small again, opt-in. Co-Authored-By: Claude Opus 5.5 --- packages/ludic.render3d/scatter.ludic | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/ludic.render3d/scatter.ludic b/packages/ludic.render3d/scatter.ludic index 773a1b91..1a3aefdc 100644 --- a/packages/ludic.render3d/scatter.ludic +++ b/packages/ludic.render3d/scatter.ludic @@ -387,9 +387,9 @@ function layer_new(render3d_st: mut Render3dState, model: Model, cap: int, folia l.near = near l.cull = cull l.tint = v3_new(1.0, 1.0, 1.0) - # room for what the layer holds, not for what it might: 45 layers at full capacity up front were - # 0.4 GB, most of it never filled (plan 23 of maroon-lake) - l.have = min(cap, 256) + # the whole capacity up front: a game writes l.inst directly (Maroon Lake's prints, trees and + # rocks do), so a layer cannot start smaller than it may be written to + l.have = cap l.inst = floats(l.have * INST_FLOATS) l.scratch = floats(l.have * INST_FLOATS) l.last_cam = v3_new(100000.0, 0.0, 0.0) @@ -402,7 +402,9 @@ function layer_new(render3d_st: mut Render3dState, model: Model, cap: int, folia return l } -# room for n instances (at most the layer's cap), doubling what there is so a fill costs a few copies +# room for n instances (at most the layer's cap), doubling what there is so a fill costs a few copies; +# a caller writing l.inst itself reserves first +function layer_reserve(l: Layer, n: int) -> void { layer_room(l, n) } function layer_room(l: Layer, n: int) -> void { if n <= l.have { return } var want = max(l.have * 2, n)