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

762 lines
38 KiB
Text

# emit_game.ludic — system functions and the frame loop. A system compiles to a
# void function; main() boots (Start systems), then runs the per-frame phases in
# order until the game stops. Runtime hooks (rt_init/poll/running/shutdown) are
# called only when the runtime defines them.
function emit_system_fn(sys: Node) -> void {
det_enter(sys.s)
fence_enter(sys.s)
g_cur_scene = sys.c # scene owning this handler (null if global) — for `become`
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0; nself = 0
ret_ty = "void"
let fbody = buf_new()
falloc = buf_new()
let saved = code
code = fbody
emit_block(sys.a)
if not g_term { emit(" br label %ret\n") }
emit("ret:\n ret void\n")
code = saved
emit("define void @sys_"); emit(sys.s); emit("() {\nentry:\n")
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
g_det_ctx = ""
}
# one enable-gated call to @sys_<d.s> (skipped while the handler is disabled).
function emit_call_one(d: Node) -> void {
let he = emit_bind(`load i32, ptr @HE_{d.s}`)
var hc = emit_bind(`icmp ne i32 {he}, 0`)
# a handler in a toggled layer also gates on its layer's @LE_ flag (byte-identical
# for layers that are never enabled/disabled, since d.b is only read when managed)
if (d.b != null) and is_toggled_layer(d.b.s) {
let le = emit_bind(`load i32, ptr @LE_{d.b.s}`)
let lc = emit_bind(`icmp ne i32 {le}, 0`)
hc = emit_bind(`and i1 {hc}, {lc}`)
}
# N5: an @Server handler (d.ival==1) runs only on the authority (@L_role==1).
# Unmarked and @Predicted handlers run on every peer. Offline @L_role defaults to
# 1, so the guard collapses to "run here" and a non-networked build is unchanged.
if d.ival == 1 {
let rv = emit_bind("load i32, ptr @L_role")
let rc = emit_bind(`icmp eq i32 {rv}, 1`)
hc = emit_bind(`and i1 {hc}, {rc}`)
}
let run = lbl("hrun"); let skip = lbl("hskip")
emit(" br i1 "); emit(hc); emit(", label %"); emit(run); emit(", label %"); emit(skip); emit("\n")
emit(run); emit(":\n call void @sys_"); emit(d.s); emit("()\n")
emit(" br label %"); emit(skip); emit("\n"); emit(skip); emit(":\n")
}
# Engine-owned systems (#43/#47): systems the compiler injects into the frame
# loop over a component the game merely declares and carries — the ECS hook the
# animation and lighting follow-ups both stand on. Each entry is (component,
# runtime fn, phase); the call is emitted only when the game declares that
# component (so systems.ludic was spliced and the fn exists). They run *after*
# the game's own handlers for the phase, so gameplay this frame is already
# settled when the engine advances animation / accumulates light. Keep the
# component list in sync with uses_engine_systems (emit_ecs.ludic).
function emit_one_engine_system(comp: pointer, fn: pointer) -> void {
if is_system_disabled(fn) { return } # lever 5 (#57): `disable system <fn>` drops the tick
if (find_comp(comp) != null) and (find_fn(fn) != null) {
emit(` call void {fn_sym(fn)}()\n`)
}
}
function emit_engine_systems_for_phase(phase: pointer) -> void {
# #62: registry-driven — core entries (seeded in parse_program) keep their
# historical per-phase order, and packages that appended via @EngineSystem run
# after them in the same phase. Each call is component-gated (no-op when the
# component is absent), so an unused registration is byte-identical.
var i = 0
while i < len(g_esys_comp) {
if (g_esys_phase[i] == phase) { emit_one_engine_system(g_esys_comp[i], g_esys_fn[i]) }
i += 1
}
if (phase == "Update") {
# Tween.* fluent handles (#48): advanced each Update tick when the game uses
# them (gated on g_uses_tween_rt rather than a declared component).
if g_uses_tween_rt and (find_fn("esys_tween") != null) { emit(" call void " + fn_sym("esys_tween") + "()\n") }
if has_countdowns() { emit(" call void @L_countdowns()\n") } # `countdown` fields tick toward 0
if g_uses_fx and (find_fn("fx_tick") != null) { emit(" call void " + fn_sym("fx_tick") + "()\n") } # Fx.* age and move
}
if (phase == "Render") {
if g_uses_fx and (find_fn("fx_draw") != null) { emit(" call void " + fn_sym("fx_draw") + "()\n") } # Fx.* draw after the sprites
}
}
# @L_countdowns(): every `countdown` field of every live entity that carries the
# component steps toward 0 once per Update (never below it). The timer idiom —
# "frames left" fields counted down by hand in a handler — becomes a field type.
function emit_countdown_system() -> void {
let me = itoa(MAX_ENT)
emit("define void @L_countdowns() {\nentry:\n")
emit(" %n = load i32, ptr @L_entc\n br label %loop\nloop:\n")
emit(" %i = phi i32 [ 0, %entry ], [ %i1, %next ]\n")
emit(" %go = icmp slt i32 %i, %n\n br i1 %go, label %body, label %done\nbody:\n")
let eb1 = ecs_base("L_alive")
emit(" %ap = getelementptr inbounds i32, ptr "); emit(eb1); emit(", i32 %i\n")
emit(" %al = load i32, ptr %ap\n %alive = icmp ne i32 %al, 0\n br i1 %alive, label %c0, label %next\n")
var k = 0
var i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_COMP {
var any = false
var f = 0
while f < len(d.kids) { if (d.kids[f].ty == "countdown") { any = true }; f += 1 }
if any {
let sk = itoa(k); let nk = itoa(k + 1)
emit("c"); emit(sk); emit(":\n")
let eb2 = ecs_base(`H_{d.s}`)
emit(" %h"); emit(sk); emit(" = getelementptr inbounds i8, ptr "); emit(eb2); emit(", i32 %i\n")
emit(" %hv"); emit(sk); emit(" = load i8, ptr %h"); emit(sk); emit("\n")
emit(" %has"); emit(sk); emit(" = icmp ne i8 %hv"); emit(sk); emit(", 0\n")
emit(" br i1 %has"); emit(sk); emit(", label %t"); emit(sk); emit(", label %c"); emit(nk); emit("\n")
emit("t"); emit(sk); emit(":\n")
let eb3 = ecs_base(`S_{d.s}`)
emit(" %s"); emit(sk); emit(" = getelementptr inbounds %Cmp_"); emit(d.s); emit(", ptr "); emit(eb3); emit(", i32 %i\n")
f = 0
while f < len(d.kids) {
if (d.kids[f].ty == "countdown") {
let fk = sk + ("_") + itoa(f)
emit(" %fa"); emit(fk); emit(" = getelementptr inbounds %Cmp_"); emit(d.s); emit(", ptr %s"); emit(sk); emit(", i32 0, i32 "); emit(itoa(f)); emit("\n")
emit(" %fv"); emit(fk); emit(" = load i32, ptr %fa"); emit(fk); emit("\n")
emit(" %fp"); emit(fk); emit(" = icmp sgt i32 %fv"); emit(fk); emit(", 0\n")
emit(" %fd"); emit(fk); emit(" = sub i32 %fv"); emit(fk); emit(", 1\n")
emit(" %fn"); emit(fk); emit(" = select i1 %fp"); emit(fk); emit(", i32 %fd"); emit(fk); emit(", i32 %fv"); emit(fk); emit("\n")
emit(" store i32 %fn"); emit(fk); emit(", ptr %fa"); emit(fk); emit("\n")
}
f += 1
}
emit(" br label %c"); emit(nk); emit("\n")
k += 1
}
}
i += 1
}
emit("c"); emit(itoa(k)); emit(":\n br label %next\n")
emit("next:\n %i1 = add i32 %i, 1\n br label %loop\ndone:\n ret void\n}\n\n")
}
# @L_prefab_<Name>(): spawn one prefab with its presets; @L_spawn_prefab(name):
# the runtime dispatch by name that Prefab.spawn(name:) calls (-1 for no such prefab).
function emit_prefab_fn(pre: Node) -> void {
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0; nself = 0
g_cur_scene = null
ret_ty = "int"
let fbody = buf_new()
falloc = buf_new()
let saved = code
code = fbody
let sp = node(S_SPAWN); sp.s = pre.s
let e = emit_spawn(sp)
emit(" ret i32 "); emit(e); emit("\n")
code = saved
emit("define i32 @L_prefab_"); emit(pre.s); emit("() {\nentry:\n")
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
}
function emit_prefab_fns() -> void {
var i = 0
while i < len(prog) { if prog[i].kind == N_PREFAB { emit_prefab_fn(prog[i]) }; i += 1 }
g_uses_str = true
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0; nself = 0
let fbody = buf_new()
falloc = buf_new()
let saved = code
code = fbody
i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_PREFAB {
let lit = node(E_STR); lit.s = d.s
let sv = emit_expr(lit)
let same = emit_bind(`call i32 @lp_str_eq(ptr %name, ptr {sv.code})`)
let hit = emit_bind(`icmp ne i32 {same}, 0`)
let yes = lbl("pf"); let no = lbl("pfn")
emit(" br i1 "); emit(hit); emit(", label %"); emit(yes); emit(", label %"); emit(no); emit("\n")
emit(yes); emit(":\n")
let r = emit_bind(`call i32 @L_prefab_{d.s}()`)
emit(" ret i32 "); emit(r); emit("\n")
emit(no); emit(":\n")
}
i += 1
}
emit(" ret i32 -1\n")
code = saved
emit("define i32 @L_spawn_prefab(ptr %name) {\nentry:\n")
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
}
# Global handlers run first, then the active scene's layer handlers in
# declaration (draw) order. The active scene is snapshotted once per phase, so a
# `become` mid-phase takes effect at the next phase boundary — exactly one scene
# is active within any single phase. Engine-owned systems for the phase run last.
# --- dynamic (mod-registered) systems, issue #64 -----------------------------
# A prebuilt binary module registers a `void()` system into a phase through the C
# ABI @ludic_register_system(fn, phase); the host's frame loop dispatches every
# registered system for a phase after its own handlers. This mirrors the EV6
# foreign event-listener array, and is the systems analogue of @ludic_register_prop
# (dynamic components). Emitted for every ECS program (the moddability baseline);
# with nothing registered the dispatch loop runs zero times, so a game that hosts
# no modules produces byte-identical output.
const SYS_CAP: int = 64
# stable phase ids shared with modules (see docs — a module passes these to
# @ludic_register_system). Keep in sync with the frame loop's phase order.
function phase_id(phase: pointer) -> int {
if phase == "Input" { return 0 }
if phase == "FixedUpdate" { return 1 }
if phase == "Update" { return 2 }
if phase == "LateUpdate" { return 3 }
if phase == "Render" { return 4 }
if phase == "Start" { return 5 }
if phase == "OnQuit" { return 6 } # the @OnQuit hook's internal slot, never written as `phase OnQuit`
if phase == "Overlay" { return 7 } # HUD/UI pass after the engine drew sprites
perr(`unknown phase '{phase}'`) # the parser rejects these; a desugaring bug if we get here
return 7
}
# the phases a handler may declare — the frame loop's buckets, in the order the
# frame runs them (Start once, then Input … Overlay every tick)
function is_phase_name(name: pointer) -> bool {
if name == "Start" or name == "Input" or name == "FixedUpdate" or name == "Update" { return true }
if name == "LateUpdate" or name == "Render" or name == "Overlay" { return true }
return false
}
# --- binary-module glue (issue #64) ------------------------------------------
# A module compiled with --emit-module carries no main and no world table (the
# consumer owns them). It references the host's reflection ABI, so we (1) declare
# every host-ABI symbol it may call — resolved from the host image at dylib load
# via -undefined dynamic_lookup — and (2) emit a load-time constructor that
# registers the module's @System functions and runs its `module_init` (where it
# registers its dynamic components via world_register_prop). Ludic source cannot
# take a function's address, so the compiler supplies @fn_<name> here.
function emit_module_glue() -> void {
emith("declare i32 @ludic_register_prop(ptr, i32)\n")
emith("declare i32 @ludic_register_system(ptr, i32)\n")
emith("declare i64 @ludic_get(i32, i32, i32)\n")
emith("declare void @ludic_set(i32, i32, i32, i64)\n")
emith("declare i32 @ludic_has(i32, i32)\n")
emith("declare void @ludic_attach_dyn(i32, i32)\n")
emith("declare void @ludic_detach_dyn(i32, i32)\n")
emith("declare i32 @ludic_prop_id(ptr)\n")
emith("declare i32 @ludic_field_id(i32, ptr)\n")
emith("declare i32 @ludic_entity_count()\n")
emith("declare i32 @ludic_kind(i32)\n")
emith("declare i32 @ludic_model_id(ptr)\n")
emith("declare i32 @ludic_spawn(i32)\n")
emith("declare i32 @ludic_query_next(i32, i32)\n")
emith("declare i32 @ludic_prop_count()\n")
emith("declare ptr @ludic_prop_name(i32)\n")
emith("declare i32 @ludic_field_count(i32)\n")
emith("declare ptr @ludic_field_name(i32, i32)\n")
emith("declare ptr @ludic_field_type(i32, i32)\n")
emit("define void @__ludic_mod_init() {\nentry:\n")
if (find_fn("module_init") != null) { emit(" call void @fn_module_init()\n") } # component registration + setup
var i = 0
while i < len(g_mod_sys_fn) {
emit(" call i32 @ludic_register_system(ptr @fn_"); emit(g_mod_sys_fn[i]); emit(", i32 "); emit(itoa(phase_id(g_mod_sys_phase[i]))); emit(")\n")
i += 1
}
emit(" ret void\n}\n\n")
# run @__ludic_mod_init at image load (dyld runs constructors before main)
emith("@llvm.global_ctors = appending global [1 x { i32, ptr, ptr }] [{ i32, ptr, ptr } { i32 65535, ptr @__ludic_mod_init, ptr null }]\n")
}
# the registry globals + the @ludic_register_system C-ABI entry (append fn+phase)
function emit_system_registry() -> void {
let cap = itoa(SYS_CAP)
emith("@sysreg_fn = global ["); emith(cap); emith(" x ptr] zeroinitializer\n")
emith("@sysreg_phase = global ["); emith(cap); emith(" x i32] zeroinitializer\n")
emith("@sysreg_count = global i32 0\n")
emit("define i32 @ludic_register_system(ptr %fn, i32 %phase) {\nentry:\n")
emit(" %n = load i32, ptr @sysreg_count\n")
emit(" %ok = icmp slt i32 %n, "); emit(cap); emit("\n")
emit(" br i1 %ok, label %add, label %drop\n")
emit("add:\n")
emit(" %fp = getelementptr inbounds ["); emit(cap); emit(" x ptr], ptr @sysreg_fn, i32 0, i32 %n\n")
emit(" store ptr %fn, ptr %fp\n")
emit(" %pp = getelementptr inbounds ["); emit(cap); emit(" x i32], ptr @sysreg_phase, i32 0, i32 %n\n")
emit(" store i32 %phase, ptr %pp\n")
emit(" %n1 = add i32 %n, 1\n store i32 %n1, ptr @sysreg_count\n ret i32 %n\n")
emit("drop:\n ret i32 -1\n}\n\n")
}
# the per-phase dispatch loop: call every registered system whose phase matches.
# phi-based (no alloca — this is emitted inline into @main / the tick helpers,
# which have no falloc entry block). Names/labels come from nreg()/lbl() so the
# five per-frame calls in one function never collide.
function emit_dyn_systems_for_phase(phase: pointer) -> void {
let cap = itoa(SYS_CAP)
let pid = itoa(phase_id(phase))
let iv = nreg(); let inext = nreg()
let pre = lbl("dsp"); let head = lbl("dsh"); let body = lbl("dsb"); let doo = lbl("dsdo"); let cont = lbl("dsc"); let done = lbl("dsn")
emit(" br label %"); emit(pre); emit("\n")
emit(pre); emit(":\n br label %"); emit(head); emit("\n")
emit(head); emit(":\n")
emit(" "); emit(iv); emit(" = phi i32 [ 0, %"); emit(pre); emit(" ], [ "); emit(inext); emit(", %"); emit(cont); emit(" ]\n")
let nn = emit_bind("load i32, ptr @sysreg_count")
let go = emit_bind(`icmp slt i32 {iv}, {nn}`)
emit(" br i1 "); emit(go); emit(", label %"); emit(body); emit(", label %"); emit(done); emit("\n")
emit(body); emit(":\n")
let pp = nreg(); emit(" "); emit(pp); emit(" = getelementptr inbounds ["); emit(cap); emit(" x i32], ptr @sysreg_phase, i32 0, i32 "); emit(iv); emit("\n")
let ph = emit_bind(`load i32, ptr {pp}`)
let m = emit_bind(`icmp eq i32 {ph}, {pid}`)
emit(" br i1 "); emit(m); emit(", label %"); emit(doo); emit(", label %"); emit(cont); emit("\n")
emit(doo); emit(":\n")
let fp = nreg(); emit(" "); emit(fp); emit(" = getelementptr inbounds ["); emit(cap); emit(" x ptr], ptr @sysreg_fn, i32 0, i32 "); emit(iv); emit("\n")
let fn = emit_bind(`load ptr, ptr {fp}`)
emit(" call void "); emit(fn); emit("()\n")
emit(" br label %"); emit(cont); emit("\n")
emit(cont); emit(":\n "); emit(inext); emit(" = add i32 "); emit(iv); emit(", 1\n br label %"); emit(head); emit("\n")
emit(done); emit(":\n")
}
function emit_calls_for_phase(phase: pointer) -> void {
var i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_SYS and (d.ty == phase) and (d.c == null) { emit_call_one(d) }
i += 1
}
# any scene-owned handlers in this phase? gate them on one @L_scene snapshot.
var has_sc = false
i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_SYS and (d.ty == phase) and (d.c != null) { has_sc = true }; i += 1 }
if has_sc {
let cs = emit_bind("load i32, ptr @L_scene")
i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_SYS and (d.ty == phase) and (d.c != null) {
let ce = emit_bind(`icmp eq i32 {cs}, {itoa(d.c.ival)}`)
let run = lbl("scrun"); let skip = lbl("scskip")
emit(" br i1 "); emit(ce); emit(", label %"); emit(run); emit(", label %"); emit(skip); emit("\n")
emit(run); emit(":\n")
emit_call_one(d)
emit(" br label %"); emit(skip); emit("\n"); emit(skip); emit(":\n")
}
i += 1
}
}
emit_engine_systems_for_phase(phase) # engine-owned systems run after every user handler
emit_dyn_systems_for_phase(phase) # #64: mod-registered systems run last
if (phase == "Overlay") { emit_scene_menu_render() } # a scene's `shows` menu paints last
if len(g_act_names) > 0 { emit(` call void {fn_sym("drain_actions")}()\n`) } # 0.R: this phase's actions, reduced
}
# the menu of the live `scene X shows Menu`, drawn on top of everything else
function emit_scene_menu_render() -> void {
var any = false
var i = 0
while i < len(g_scenes) { if (g_scenes[i].ty != null) { any = true }; i += 1 }
if not any { return }
let cs = emit_bind("load i32, ptr @L_scene")
i = 0
while i < len(g_scenes) {
let sc = g_scenes[i]
if (sc.ty != null) {
let ce = emit_bind(`icmp eq i32 {cs}, {itoa(sc.ival)}`)
let run = lbl("mrun"); let skip = lbl("mskip")
emit(" br i1 "); emit(ce); emit(", label %"); emit(run); emit(", label %"); emit(skip); emit("\n")
emit(run); emit(":\n call void " + fn_sym("rt_ui_render") + "()\n")
emit(" br label %"); emit(skip); emit("\n"); emit(skip); emit(":\n")
}
i += 1
}
}
# on enter / on exit compile to void functions @scene_enter_<Name> /
# @scene_exit_<Name>, called at the transition point (and enter at boot for the
# start scene). Emitted for every scene, empty body when the hook is absent.
function emit_scene_fn(name: pointer, kind: pointer, body: Node, menu: pointer) -> void {
fence_enter(`scene {name}`)
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0; nself = 0
ret_ty = "void"
let fbody = buf_new()
falloc = buf_new()
let saved = code
code = fbody
# `scene X shows Menu`: a free cursor and the menu open on enter, closed on exit
if (menu != null) and (kind == "enter") {
if (find_fn("input_cursor_mode") != null) { emit(" call void " + fn_sym("input_cursor_mode") + "(i32 0)\n") }
emit(" call void " + fn_sym("rt_ui_open") + "(i32 "); emit(itoa(ui_index_of("UI_" + menu))); emit(")\n")
}
if (menu != null) and (kind == "exit") { emit(" call void " + fn_sym("rt_ui_close") + "()\n") }
if (body != null) { emit_block(body) }
# EV1: a `public` scene fires scene_<S>_enter / scene_<S>_exit after its block
let sev = `scene_{name}_{kind}`
if (not g_term) and (find_event(sev) != null) { emit(" call void @ev_"); emit(sev); emit("()\n") }
if not g_term { emit(" br label %ret\n") }
emit("ret:\n ret void\n")
code = saved
emit("define void @scene_"); emit(kind); emit("_"); emit(name); emit("() {\nentry:\n")
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
}
function emit_scene_hooks() -> void {
var i = 0
while i < len(g_scenes) {
let sc = g_scenes[i]
g_cur_scene = sc
emit_scene_fn(sc.s, "enter", sc.a, sc.ty)
emit_scene_fn(sc.s, "exit", sc.b, sc.ty)
i += 1
}
g_cur_scene = null
if len(g_scenes) > 0 { emit_scene_leave_fn() } # a program without scenes has no @L_scene
}
# @L_scene_leave(): run the on-exit hook of whichever scene is live — what a
# `become` outside any scene's own handlers (a global handler, an @On listener,
# a plain function) calls, since it cannot know the leaving scene at compile time.
function emit_scene_leave_fn() -> void {
emit("define void @L_scene_leave() {\nentry:\n")
emit(" %s = load i32, ptr @L_scene\n")
var i = 0
while i < len(g_scenes) {
let sc = g_scenes[i]
let k = itoa(i)
emit(" %is"); emit(k); emit(" = icmp eq i32 %s, "); emit(itoa(sc.ival)); emit("\n")
emit(" br i1 %is"); emit(k); emit(", label %leave"); emit(k); emit(", label %next"); emit(k); emit("\n")
emit("leave"); emit(k); emit(":\n call void @scene_exit_"); emit(sc.s); emit("()\n ret void\n")
emit("next"); emit(k); emit(":\n")
i += 1
}
emit(" ret void\n}\n\n")
}
# @OnDespawn(Model) hooks compile to `@on_despawn_<Model>(entity, reason)`
# functions that bind the model's properties and run the body — dispatched by
# kind at `despawn`. LC1: the `i32 %reason` parameter carries an EndReason; when
# the hook declared `reason: r`, `r` is bound as an int local reading it.
function emit_despawn_hooks() -> void {
var i = 0
while i < len(g_ondespawn) {
let hk = g_ondespawn[i]
let model = find_arch(hk.s)
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0; nself = 0
ret_ty = "void"
let fbody = buf_new()
falloc = buf_new()
let saved = code
code = fbody
if (hk.ty != null) { # bind the reason: r name to %reason
let rslot = emit_alloca("i32")
emit(" store i32 %reason, ptr "); emit(rslot); emit("\n")
loc_push(hk.ty, rslot, "int")
}
emit_bind_props(model, "%e")
emit_block(hk.a)
if not g_term { emit(" br label %ret\n") }
emit("ret:\n ret void\n")
code = saved
emit("define void @on_despawn_"); emit(hk.s); emit("(i32 %e, i32 %reason) {\nentry:\n")
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
i += 1
}
emit_despawn_all_fn()
}
# #84 — @fn_world_despawn(e): the runtime-callable despawn, used by the
# world-bounds kill policy and World.despawn(e). Same per-kind @OnDespawn dispatch
# as the `despawn` statement (emit_despawn) but reading a runtime %e, then the
# entity-scoped-listener sweep and the slot free. Emitted from emit_program's tail
# only when a world_despawn call was emitted (g_uses_world_despawn), after all uses
# are seen — so a program that never despawns by id is byte-identical.
function emit_world_despawn_fn() -> void {
emit("define void @fn_world_despawn(i32 %e) {\nentry:\n")
if len(g_ondespawn) > 0 {
let me = itoa(MAX_ENT)
let eb4 = ecs_base("L_kind")
emit(" %kp = getelementptr inbounds i32, ptr "); emit(eb4); emit(", i32 %e\n")
emit(" %k = load i32, ptr %kp\n")
var i = 0
while i < len(g_ondespawn) {
let mname = g_ondespawn[i].s
let si = itoa(i)
emit(" %c"); emit(si); emit(" = icmp eq i32 %k, "); emit(itoa(find_arch_id(mname))); emit("\n")
emit(" br i1 %c"); emit(si); emit(", label %hit"); emit(si); emit(", label %next"); emit(si); emit("\n")
emit("hit"); emit(si); emit(":\n call void @on_despawn_"); emit(mname); emit("(i32 %e, i32 0)\n") # reason = Despawned
let dev = `model_{mname}_despawn`
if (find_event(dev) != null) { emit(" call void @ev_"); emit(dev); emit("(i32 %e, i32 0)\n") }
emit(" br label %next"); emit(si); emit("\n")
emit("next"); emit(si); emit(":\n")
i += 1
}
}
if len(g_events) > 0 { emit(" call void @ludic_sweep_entity(i32 %e)\n") } # EV5: drop entity-scoped listeners
emit(" call void @L_free_entity(i32 %e)\n")
emit(" ret void\n}\n\n")
}
# LC1 "no silent deaths": at program shutdown every still-live entity's despawn
# hook fires with reason Quit, so teardown that must run on exit is not skipped.
# @L_despawn_all(reason) walks the live set and dispatches each entity by kind —
# the same per-model dispatch as `despawn`, but without freeing (the process is
# ending). Emitted only when the program has @OnDespawn hooks, so despawn-free
# programs are byte-for-byte unchanged.
function emit_despawn_all_fn() -> void {
if len(g_ondespawn) == 0 { return }
let me = itoa(MAX_ENT)
emit("define void @L_despawn_all(i32 %reason) {\nentry:\n br label %loop\n")
emit("loop:\n %i = phi i32 [ 0, %entry ], [ %i1, %cont ]\n")
emit(" %n = load i32, ptr @L_entc\n %go = icmp slt i32 %i, %n\n")
emit(" br i1 %go, label %body, label %fin\n")
emit("body:\n")
let eb5 = ecs_base("L_alive")
emit(" %ap = getelementptr inbounds i32, ptr "); emit(eb5); emit(", i32 %i\n")
emit(" %al = load i32, ptr %ap\n %isa = icmp ne i32 %al, 0\n")
emit(" br i1 %isa, label %do, label %cont\n")
emit("do:\n")
let eb6 = ecs_base("L_kind")
emit(" %kp = getelementptr inbounds i32, ptr "); emit(eb6); emit(", i32 %i\n")
emit(" %k = load i32, ptr %kp\n")
var i = 0
while i < len(g_ondespawn) {
let mname = g_ondespawn[i].s
let si = itoa(i)
emit(" %c"); emit(si); emit(" = icmp eq i32 %k, "); emit(itoa(find_arch_id(mname))); emit("\n")
emit(" br i1 %c"); emit(si); emit(", label %hit"); emit(si); emit(", label %next"); emit(si); emit("\n")
emit("hit"); emit(si); emit(":\n call void @on_despawn_"); emit(mname); emit("(i32 %i, i32 %reason)\n")
let dev = `model_{mname}_despawn` # EV1: @Public despawn event at shutdown
if (find_event(dev) != null) { emit(" call void @ev_"); emit(dev); emit("(i32 %i, i32 %reason)\n") }
emit(" br label %next"); emit(si); emit("\n")
emit("next"); emit(si); emit(":\n")
i += 1
}
emit(" br label %cont\n")
emit("cont:\n %i1 = add i32 %i, 1\n br label %loop\n")
emit("fin:\n ret void\n}\n\n")
}
# EV0: each declared `event E` compiles to a `@ev_<E>(payload…)` function whose
# body is (1) its `@On(E)` listeners concatenated in declaration order — the
# closed, compile-time half — then (2) a loop over a runtime listener array, the
# open half a mod in another language joins through the C ABI. The payload fields
# arrive as params (%p0, %p1, …), bound by name so a listener body reads them bare
# (like a query/hook binding). Emitted only when g_events is non-empty, so an
# event-free program is byte-for-byte unchanged.
#
# The runtime half is the deliberate opt-in exception to "no dispatch tables":
# %Ev_<E> — the POD payload struct passed by pointer to foreign listeners
# @evL_<E> — a fixed-capacity [16 x ptr] array of foreign callbacks
# @evN_<E> — how many are registered (registration order = dispatch order)
# @ludic_on_<E>(ptr cb) -> i32 — the C ABI: a mod appends its callback
# A native Ludic listener costs a direct call; a foreign one costs one indirect
# call. With no foreign listeners registered the loop runs zero times (one branch).
const EV_CAP: int = 16
# EV6 — re-entrant emit is bounded: a listener may `emit` another event, but the
# nesting is capped so an event cycle traps as an early return instead of hanging
# the frame. @ev_depth counts the live dispatch nesting; past the cap a dispatch
# returns immediately (a cancellable event returns "not cancelled").
const EV_DEPTH_CAP: int = 32
function emit_event_fns() -> void {
emith("@ev_depth = global i32 0\n") # EV6 re-entrancy counter
var e = 0
while e < len(g_events) {
let ev = g_events[e]
let en = ev.s
let cap = itoa(EV_CAP)
# --- module-level: payload struct + the foreign listener registry (into head)
# A cancellable event's payload carries a trailing i32 `cancelled` flag that a
# listener (native or foreign) can set; the caller reads it back.
emith("%Ev_"); emith(en); emith(" = type { ")
var t = 0
while t < len(ev.kids) {
if t > 0 { emith(", ") }
emith(llty(ev.kids[t].ty))
t += 1
}
if ev.ival == 1 { if len(ev.kids) > 0 { emith(", ") }; emith("i32") }
emith(" }\n")
emith("@evL_"); emith(en); emith(" = global ["); emith(cap); emith(" x ptr] zeroinitializer\n")
emith("@evN_"); emith(en); emith(" = global i32 0\n")
# EV5: a parallel owner array — -1 = program-scoped (never swept), >=0 = the
# entity that owns the listener (swept when that entity despawns).
emith("@evO_"); emith(en); emith(" = global ["); emith(cap); emith(" x i32] zeroinitializer\n")
# --- @ludic_on_<E>(cb): append a program-scoped callback, return a token
emit("define i32 @ludic_on_"); emit(en); emit("(ptr %cb) {\nentry:\n")
emit(" %n = load i32, ptr @evN_"); emit(en); emit("\n")
emit(" %full = icmp slt i32 %n, "); emit(cap); emit("\n")
emit(" br i1 %full, label %add, label %drop\n")
emit("add:\n %slot = getelementptr inbounds ["); emit(cap); emit(" x ptr], ptr @evL_"); emit(en); emit(", i32 0, i32 %n\n")
emit(" store ptr %cb, ptr %slot\n")
emit(" %oslot = getelementptr inbounds ["); emit(cap); emit(" x i32], ptr @evO_"); emit(en); emit(", i32 0, i32 %n\n")
emit(" store i32 -1, ptr %oslot\n")
emit(" %n1 = add i32 %n, 1\n store i32 %n1, ptr @evN_"); emit(en); emit("\n ret i32 %n\n")
emit("drop:\n ret i32 -1\n}\n\n") # registry full: reject (token -1)
# --- @ludic_on_entity_<E>(owner, cb): append an entity-scoped callback
emit("define i32 @ludic_on_entity_"); emit(en); emit("(i32 %owner, ptr %cb) {\nentry:\n")
emit(" %n = load i32, ptr @evN_"); emit(en); emit("\n")
emit(" %full = icmp slt i32 %n, "); emit(cap); emit("\n")
emit(" br i1 %full, label %add, label %drop\n")
emit("add:\n %slot = getelementptr inbounds ["); emit(cap); emit(" x ptr], ptr @evL_"); emit(en); emit(", i32 0, i32 %n\n")
emit(" store ptr %cb, ptr %slot\n")
emit(" %oslot = getelementptr inbounds ["); emit(cap); emit(" x i32], ptr @evO_"); emit(en); emit(", i32 0, i32 %n\n")
emit(" store i32 %owner, ptr %oslot\n")
emit(" %n1 = add i32 %n, 1\n store i32 %n1, ptr @evN_"); emit(en); emit("\n ret i32 %n\n")
emit("drop:\n ret i32 -1\n}\n\n")
# --- @ludic_off_<E>(token): remove a listener (tombstone the slot to null)
emit("define void @ludic_off_"); emit(en); emit("(i32 %tok) {\nentry:\n")
emit(" %n = load i32, ptr @evN_"); emit(en); emit("\n")
emit(" %lo = icmp sge i32 %tok, 0\n %hi = icmp slt i32 %tok, %n\n %ok = and i1 %lo, %hi\n")
emit(" br i1 %ok, label %do, label %skip\n")
emit("do:\n")
emit(" %slot = getelementptr inbounds ["); emit(cap); emit(" x ptr], ptr @evL_"); emit(en); emit(", i32 0, i32 %tok\n")
emit(" store ptr null, ptr %slot\n br label %skip\n")
emit("skip:\n ret void\n}\n\n")
# --- @ev_<E>(payload): fire compile-time listeners, then foreign ones
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0; nself = 0
g_cur_scene = null # a listener belongs to no scene: `become` leaves the live one
ret_ty = "void"
let fbody = buf_new()
falloc = buf_new()
let saved = code
code = fbody
# EV6: bound re-entrant emit — past EV_DEPTH_CAP, return without dispatching
emit(" %evd = load i32, ptr @ev_depth\n")
emit(" %evover = icmp sge i32 %evd, "); emit(itoa(EV_DEPTH_CAP)); emit("\n")
emit(" br i1 %evover, label %evcap, label %evgo\n")
emit("evcap:\n")
if ev.ival == 1 { emit(" ret i32 0\n") } else { emit(" ret void\n") }
emit("evgo:\n")
emit(" %evd1 = add i32 %evd, 1\n store i32 %evd1, ptr @ev_depth\n")
# a stack copy of the payload, passed by pointer to every foreign listener
let pl = emit_alloca(`%Ev_{en}`)
# bind each field: store the param into the payload struct AND a name slot the
# compile-time listener bodies read bare.
var f = 0
while f < len(ev.kids) {
let fd = ev.kids[f]
let lt = llty(fd.ty)
let pa = nreg(); emit(" "); emit(pa); emit(" = getelementptr inbounds %Ev_"); emit(en); emit(", ptr "); emit(pl); emit(", i32 0, i32 "); emit(itoa(f)); emit("\n")
emit(" store "); emit(lt); emit(" %p"); emit(itoa(f)); emit(", ptr "); emit(pa); emit("\n")
let slot = emit_alloca(lt)
emit(" store "); emit(lt); emit(" %p"); emit(itoa(f)); emit(", ptr "); emit(slot); emit("\n")
loc_push(fd.s, slot, fd.ty)
f += 1
}
# cancellable: zero the flag and expose its address to `cancel` in the listeners
var caddr = null
if ev.ival == 1 {
caddr = nreg(); emit(" "); emit(caddr); emit(" = getelementptr inbounds %Ev_"); emit(en); emit(", ptr "); emit(pl); emit(", i32 0, i32 "); emit(itoa(len(ev.kids))); emit("\n")
emit(" store i32 0, ptr "); emit(caddr); emit("\n")
g_cancel_addr = caddr
}
let base = nloc # listeners share the params but not each other's locals
var i = 0
while i < len(g_onlisten) {
if (g_onlisten[i].s == en) {
# a `return` ends this listener, not the dispatch: the next one and the foreign ones still run
nloc = base
g_term = false
let next = lbl("evnext")
g_ret_label = next
g_err_file = g_onlisten[i].file
vis_check(ev, en)
fence_enter(`@On({en})`)
emit_block(g_onlisten[i].a)
g_ret_label = "ret"
if not g_term { emit(` br label %{next}\n`) }
emit(`{next}:\n`)
g_term = false
}
i += 1
}
# the open half: walk the foreign callback array in registration order
if not g_term {
let ci = emit_alloca("i32"); emit(" store i32 0, ptr "); emit(ci); emit("\n")
let L = lbl("evl"); let B = lbl("evb"); let D = lbl("evd")
emit(" br label %"); emit(L); emit("\n")
emit(L); emit(":\n")
let iv = emit_bind(`load i32, ptr {ci}`)
let nn = emit_bind(`load i32, ptr @evN_{en}`)
let go = emit_bind(`icmp slt i32 {iv}, {nn}`)
emit(" br i1 "); emit(go); emit(", label %"); emit(B); emit(", label %"); emit(D); emit("\n")
emit(B); emit(":\n")
let sp = nreg(); emit(" "); emit(sp); emit(" = getelementptr inbounds ["); emit(cap); emit(" x ptr], ptr @evL_"); emit(en); emit(", i32 0, i32 "); emit(iv); emit("\n")
let cb = emit_bind(`load ptr, ptr {sp}`)
let cbn = emit_bind(`icmp eq ptr {cb}, null`) # EV5: a removed (off) listener is null — skip it
let doc = lbl("evdo"); let skp = lbl("evsk")
emit(" br i1 "); emit(cbn); emit(", label %"); emit(skp); emit(", label %"); emit(doc); emit("\n")
emit(doc); emit(":\n")
emit(" call void "); emit(cb); emit("(ptr "); emit(pl); emit(")\n")
emit(" br label %"); emit(skp); emit("\n")
emit(skp); emit(":\n")
let i2 = emit_bind(`add i32 {iv}, 1`)
emit(" store i32 "); emit(i2); emit(", ptr "); emit(ci); emit("\n")
emit(" br label %"); emit(L); emit("\n")
emit(D); emit(":\n")
emit(" br label %ret\n")
}
emit("ret:\n")
emit(" %evdd = load i32, ptr @ev_depth\n %evdd1 = sub i32 %evdd, 1\n store i32 %evdd1, ptr @ev_depth\n") # EV6: leave one nesting level
if ev.ival == 1 { # return the (possibly set) cancelled flag
let cv = emit_bind(`load i32, ptr {caddr}`)
emit(" ret i32 "); emit(cv); emit("\n")
} else { emit(" ret void\n") }
g_cancel_addr = null # leaves listener scope
code = saved
var rt = "void"; if ev.ival == 1 { rt = "i32" }
emit("define "); emit(rt); emit(" @ev_"); emit(en); emit("(")
var g = 0
while g < len(ev.kids) {
if g > 0 { emit(", ") }
emit(llty(ev.kids[g].ty)); emit(" %p"); emit(itoa(g))
g += 1
}
emit(") {\nentry:\n")
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
e += 1
}
# EV5: @ludic_sweep_entity(owner) — remove every entity-scoped listener owned by
# a despawning entity, across all events. Called from `despawn`, so a listener
# bound to an entity cannot outlive it (the Node listener-leak footgun, gone).
let capS = itoa(EV_CAP)
emit("define void @ludic_sweep_entity(i32 %owner) {\nentry:\n %ci = alloca i32\n store i32 0, ptr %ci\n br label %sw0\n")
var e2 = 0
while e2 < len(g_events) {
let en2 = g_events[e2].s; let sk = itoa(e2)
emit("sw"); emit(sk); emit(":\n")
emit(" %swi"); emit(sk); emit(" = load i32, ptr %ci\n")
emit(" %swn"); emit(sk); emit(" = load i32, ptr @evN_"); emit(en2); emit("\n")
emit(" %swg"); emit(sk); emit(" = icmp slt i32 %swi"); emit(sk); emit(", %swn"); emit(sk); emit("\n")
emit(" br i1 %swg"); emit(sk); emit(", label %swb"); emit(sk); emit(", label %swd"); emit(sk); emit("\n")
emit("swb"); emit(sk); emit(":\n")
emit(" %swop"); emit(sk); emit(" = getelementptr inbounds ["); emit(capS); emit(" x i32], ptr @evO_"); emit(en2); emit(", i32 0, i32 %swi"); emit(sk); emit("\n")
emit(" %swov"); emit(sk); emit(" = load i32, ptr %swop"); emit(sk); emit("\n")
emit(" %swm"); emit(sk); emit(" = icmp eq i32 %swov"); emit(sk); emit(", %owner\n")
emit(" br i1 %swm"); emit(sk); emit(", label %swh"); emit(sk); emit(", label %swx"); emit(sk); emit("\n")
emit("swh"); emit(sk); emit(":\n")
emit(" %swlp"); emit(sk); emit(" = getelementptr inbounds ["); emit(capS); emit(" x ptr], ptr @evL_"); emit(en2); emit(", i32 0, i32 %swi"); emit(sk); emit("\n")
emit(" store ptr null, ptr %swlp"); emit(sk); emit("\n br label %swx"); emit(sk); emit("\n")
emit("swx"); emit(sk); emit(":\n")
emit(" %swinc"); emit(sk); emit(" = add i32 %swi"); emit(sk); emit(", 1\n store i32 %swinc"); emit(sk); emit(", ptr %ci\n br label %sw"); emit(sk); emit("\n")
emit("swd"); emit(sk); emit(":\n store i32 0, ptr %ci\n")
if (e2 + 1) < len(g_events) { emit(" br label %sw"); emit(itoa(e2 + 1)); emit("\n") }
else { emit(" ret void\n") }
e2 += 1
}
emit("}\n\n")
}