ludic/selfhost/backend/game/emit_ecs.ludic
Orkuncakilkaya 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

224 lines
11 KiB
Text

# emit_ecs.ludic — ECS storage and the entity allocator. For each component:
# a %Cmp_ layout, a dense @S_ store, and an @H_ "has this component" array.
# Entities are integer handles; L_alloc reuses freed slots. Mirrors the ECS
# parts of compiler/back/ir_decl.c.
const MAX_ENT: int = 1024
function has_systems() -> bool {
var i = 0
while i < len(prog) { if prog[i].kind == N_SYS { return true }; i += 1 }
# a game with no hand-written handler but a well-known engine component still
# runs a frame loop — the engine owns the system that ticks that component
# (#43/#47). Treat it as a systems game so the loop / tick helpers are emitted.
if uses_engine_systems() { return true }
return false
}
function has_models() -> bool {
var i = 0
while i < len(prog) { if prog[i].kind == N_ARCH { return true }; i += 1 }
return false
}
# does the program declare a well-known engine component? Each one is auto-ticked
# by an engine-owned system (systems.ludic, wired in emit_engine_systems_for_
# phase). Answering this drives the systems.ludic splice (parse.ludic) and the
# reflection-ABI force-emit (emit_decl) — a game with none is byte-for-byte the
# same as before the feature existed. Keep this list in sync with the phase table
# in emit_engine_systems_for_phase.
function uses_engine_systems() -> bool {
# #62: any registered engine-system component present (core seeds SpriteAnim /
# Motion / Light2D; packages append via @EngineSystem), or an Occluder (which
# feeds the Light2D pass without an esys of its own).
var i = 0
while i < len(g_esys_comp) { if find_comp(g_esys_comp[i]) != null { return true }; i += 1 }
if find_comp("Occluder") != null { return true }
return false
}
# N5: does the program have an `entry` block? A game with both handlers and an
# `entry` drives its own loop (calling tick_fixed/tick_render), instead of the
# compiler's auto frame loop. A game with handlers and no entry uses the auto-loop.
function has_entry() -> bool {
var i = 0
while i < len(prog) { if prog[i].kind == N_MAIN { return true }; i += 1 }
return false
}
# Does this program run the ECS? A property alone no longer answers that — the
# same `property` keyword also declares plain `new`-allocated records (the merged
# `struct`). A program uses the ECS when it has a handler or a model; a tool that
# only declares record types and functions does not, and gets no entity storage,
# allocator, snapshot or runtime splice.
function has_ecs() -> bool { return has_systems() or has_models() }
function emit_ecs_storage() -> void {
emith("@L_running = internal global i32 1\n")
emith("@L_key = internal global i32 0\n")
emith("@L_frame = internal global i32 0\n")
if len(g_scenes) > 0 { emith("@L_scene = internal global i32 0\n") } # active base scene id
emith("@L_entc = internal global i32 0\n")
let me = itoa(MAX_ENT)
# the per-entity stores are heap blocks L_grow makes and doubles: @L_cap slots each, from MAX_ENT
emith("@L_cap = internal global i32 0\n")
emith("@L_snapcap = internal global i32 0\n") # a snapshot's slot count, read before its stores
emith("@L_alive = internal global ptr null\n")
emith("@L_kind = internal global ptr null\n")
emith("@L_freelist = internal global ptr null\n")
emith("@L_freen = internal global i32 0\n")
# NETWORKING role registers (N3/N5): a runtime sets these; offline they hold the
# single-player default — @L_role=1 (this peer is the authority), local id 0.
# Emitted only when a networking feature is used, so non-networked builds are
# byte-identical (§8). @L_owner_arr is the per-entity network owner (N3, @Owned).
if net_any() {
emith("@L_role = internal global i32 1\n") # 1 = server/authority (offline default)
emith("@L_localid = internal global i32 0\n") # this peer's id
}
if net_has_owned() { emith("@L_owner_arr = internal global ptr null\n") }
var i = 0
while i < len(prog) {
let c = prog[i]
# per-entity storage for a property (its %Cmp_ layout is emitted in the
# header). Every property in an ECS program is a component today; a property
# used only via `new` would not need these, but no such program mixes the two.
if c.kind == N_COMP {
emith(`@S_{c.s} = internal global ptr null\n`)
emith(`@H_{c.s} = internal global ptr null\n`)
}
# one enabled-flag global per model and per handler (default enabled)
if c.kind == N_ARCH { emith(`@ME_{c.s} = internal global i32 1\n`) }
if c.kind == N_SYS { emith(`@HE_{c.s} = internal global i32 1\n`) }
i += 1
}
# one enabled-flag global per toggled layer (default shown)
var li = 0
while li < len(g_toggled_layers) { emith(`@LE_{g_toggled_layers[li]} = internal global i32 1\n`); li += 1 }
}
# L_reset(e): clear every has-flag and the archetype kind for entity e
function emit_ecs_allocator() -> void {
let me = itoa(MAX_ENT)
emit_ecs_grow()
emit("define void @L_reset(i32 %e) {\nentry:\n")
var i = 0
while i < len(prog) {
if prog[i].kind == N_COMP {
let hn = `%h{itoa(i)}`
let eb1 = ecs_base(`H_{prog[i].s}`)
emit(" "); emit(hn); emit(" = getelementptr inbounds i8, ptr "); emit(eb1); emit(", i32 %e\n")
emit(" store i8 0, ptr "); emit(hn); emit("\n")
}
i += 1
}
let eb2 = ecs_base("L_kind")
emit(" %k = getelementptr inbounds i32, ptr "); emit(eb2); emit(", i32 %e\n")
emit(" store i32 0, ptr %k\n")
# N3: reset an @Owned entity's network owner to -1 (unowned) on alloc/free
if net_has_owned() {
let eb3 = ecs_base("L_owner_arr")
emit(" %ow = getelementptr inbounds i32, ptr "); emit(eb3); emit(", i32 %e\n")
emit(" store i32 -1, ptr %ow\n")
}
emit(" ret void\n}\n\n")
emit("define i32 @L_alloc() {\nentry:\n")
emit(" %fn = load i32, ptr @L_freen\n")
emit(" %has = icmp sgt i32 %fn, 0\n")
emit(" br i1 %has, label %reuse, label %fresh\n")
emit("reuse:\n")
emit(" %fn1 = sub i32 %fn, 1\n")
emit(" store i32 %fn1, ptr @L_freen\n")
let eb4 = ecs_base("L_freelist")
emit(" %fp = getelementptr inbounds i32, ptr "); emit(eb4); emit(", i32 %fn1\n")
emit(" %re = load i32, ptr %fp\n")
emit(" br label %done\n")
emit("fresh:\n")
emit(" %ec = load i32, ptr @L_entc\n")
emit(" %ec1 = add i32 %ec, 1\n")
emit(" call void @L_grow(i32 %ec1)\n") # a slot past the stores doubles them first
emit(" store i32 %ec1, ptr @L_entc\n")
emit(" br label %done\n")
emit("done:\n")
emit(" %e = phi i32 [ %re, %reuse ], [ %ec, %fresh ]\n")
emit(" call void @L_reset(i32 %e)\n")
let eb5 = ecs_base("L_alive")
emit(" %ap = getelementptr inbounds i32, ptr "); emit(eb5); emit(", i32 %e\n")
emit(" store i32 1, ptr %ap\n")
emit(" ret i32 %e\n}\n\n")
emit("define void @L_free_entity(i32 %e) {\nentry:\n")
let eb6 = ecs_base("L_alive")
emit(" %ap = getelementptr inbounds i32, ptr "); emit(eb6); emit(", i32 %e\n")
emit(" store i32 0, ptr %ap\n")
emit(" call void @L_reset(i32 %e)\n")
emit(" %fn = load i32, ptr @L_freen\n")
let eb7 = ecs_base("L_freelist")
emit(" %fp = getelementptr inbounds i32, ptr "); emit(eb7); emit(", i32 %fn\n")
emit(" store i32 %e, ptr %fp\n")
emit(" %fn1 = add i32 %fn, 1\n")
emit(" store i32 %fn1, ptr @L_freen\n")
emit(" ret void\n}\n\n")
}
# the base of a per-entity store, loaded where it is indexed (a store moves when L_grow doubles it)
function ecs_base(store: pointer) -> pointer {
let r = `%ecsb{itoa(ll_t)}` # its own prefix: raw functions name blocks t0, t1, ...
ll_t += 1
emit(` {r} = load ptr, ptr @{store}\n`)
return r
}
# L_grow(need): every per-entity store holds at least `need` slots - doubled from MAX_ENT as the
# entities outgrow them, the new slots zero, the mods' registered stores with them. Nothing is
# ever moved back down: an entity's slot is its handle for its whole life.
function emit_ecs_grow() -> void {
emit("define void @L_grow(i32 %need) {\nentry:\n %cap = load i32, ptr @L_cap\n")
emit(" %ok = icmp sle i32 %need, %cap\n br i1 %ok, label %done, label %grow\ngrow:\n")
emit(` %c0 = icmp eq i32 %cap, 0\n %start = select i1 %c0, i32 {itoa(MAX_ENT)}, i32 %cap\n br label %dbl\n`)
emit("dbl:\n %nc = phi i32 [ %start, %grow ], [ %nc2, %dbl2 ]\n %enough = icmp sge i32 %nc, %need\n")
emit(" br i1 %enough, label %apply, label %dbl2\ndbl2:\n %nc2 = mul i32 %nc, 2\n br label %dbl\n")
emit("apply:\n %cap64 = sext i32 %cap to i64\n %nc64 = sext i32 %nc to i64\n")
emit_grow_store("L_alive", "4", 0)
emit_grow_store("L_kind", "4", 1)
emit_grow_store("L_freelist", "4", 2)
if net_has_owned() { emit_grow_store("L_owner_arr", "4", 3) }
var k = 4
var i = 0
while i < len(prog) {
if prog[i].kind == N_COMP {
let sz = `%gs{itoa(k)}`
emit(` {sz} = ptrtoint ptr getelementptr (%Cmp_{prog[i].s}, ptr null, i32 1) to i64\n`)
emit_grow_store(`S_{prog[i].s}`, sz, k)
emit_grow_store(`H_{prog[i].s}`, "1", k + 1)
k += 2
}
i += 1
}
emit_grow_dyn()
emit("fin:\n store i32 %nc, ptr @L_cap\n br label %done\ndone:\n ret void\n}\n\n")
}
# one store to nc slots of `esz` bytes: reallocated (8 bytes spare, so a field-less tag is never
# a zero-byte block), its new tail zeroed
function emit_grow_store(store: pointer, esz: pointer, k: int) -> void {
let n = itoa(k)
emit(` %gp{n} = load ptr, ptr @{store}\n %gb{n} = mul i64 %nc64, {esz}\n %gr{n} = add i64 %gb{n}, 8\n`)
emit(` %gn{n} = call ptr @lp_realloc(ptr %gp{n}, i64 %gr{n})\n %go{n} = mul i64 %cap64, {esz}\n`)
emit(` %gt{n} = getelementptr i8, ptr %gn{n}, i64 %go{n}\n %gz{n} = sub i64 %gr{n}, %go{n}\n`)
emit(` %gm{n} = call ptr @memset(ptr %gt{n}, i32 0, i64 %gz{n})\n store ptr %gn{n}, ptr @{store}\n`)
}
# the stores a mod registered at run time (ludic_register_prop), grown the same way
function emit_grow_dyn() -> void {
emit(" br label %dynh\ndynh:\n %di = phi i32 [ 0, %apply ], [ %di1, %dynb ]\n %dcn = load i32, ptr @dyn_count\n")
emit(" %dmore = icmp slt i32 %di, %dcn\n br i1 %dmore, label %dynb, label %fin\ndynb:\n")
emit(" %dfp = getelementptr inbounds [32 x i32], ptr @dynF, i32 0, i32 %di\n %dnf = load i32, ptr %dfp\n")
emit(" %dnf4 = mul i32 %dnf, 4\n %desz = sext i32 %dnf4 to i64\n")
emit_grow_slot("dynS", "%desz", "s")
emit_grow_slot("dynH", "1", "h")
emit(" %di1 = add i32 %di, 1\n br label %dynh\n")
}
function emit_grow_slot(table: pointer, esz: pointer, t: pointer) -> void {
emit(` %d{t}p = getelementptr inbounds [32 x ptr], ptr @{table}, i32 0, i32 %di\n %d{t}o = load ptr, ptr %d{t}p\n`)
emit(` %d{t}b = mul i64 %nc64, {esz}\n %d{t}r = add i64 %d{t}b, 8\n %d{t}n = call ptr @lp_realloc(ptr %d{t}o, i64 %d{t}r)\n`)
emit(` %d{t}f = mul i64 %cap64, {esz}\n %d{t}t = getelementptr i8, ptr %d{t}n, i64 %d{t}f\n %d{t}z = sub i64 %d{t}r, %d{t}f\n`)
emit(` %d{t}m = call ptr @memset(ptr %d{t}t, i32 0, i64 %d{t}z)\n store ptr %d{t}n, ptr %d{t}p\n`)
}