feat(compiler): dynamic system registration + --emit-module for binary packages (#64)

The runtime + compiler foundation for prebuilt binary packages, over the
existing reflection C-ABI (EV0–EV8 already gives dynamic components via
ludic_register_prop).

- ludic_register_system(fn, phase) + a registry the frame loop dispatches after
  each phase's own handlers — the systems analogue of ludic_register_prop,
  mirroring the EV6 foreign event-listener array. Emitted for every ECS program;
  a zero-length registry means a non-hosting game is output-identical.
- --emit-module: compile a package to a position-independent module — no main,
  no world table — that declares the host reflection ABI it calls and carries a
  load-time constructor which registers its @System(Phase) functions and runs
  module_init (where it registers its dynamic components). Built as a dylib with
  -undefined dynamic_lookup, it binds ludic_* back to the host image at load.
- @System(Phase) annotation marks a module function as a runtime-registered
  system; the compiler supplies its address (Ludic source cannot take one).
- The reflection ABI (world table) is now emitted for every ECS program, so any
  game can host binary modules with no flag; unused defs dead-strip at -O2, so
  golden renders stay byte-identical and the C-free bootstrap fixpoint holds.

Proven end-to-end: a module dylib registers a component + an Update system; a
host game that never saw its source links it and the system mutates the shared
world each frame. Full suite 87/0, test-tools 30/0, fixpoint intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-01 07:44:18 +03:00
parent 035e6dfe41
commit e1537d2d45
7 changed files with 18543 additions and 17872 deletions

View file

@ -18,6 +18,7 @@ var loc_reg: []pointer
var loc_ty: []pointer var loc_ty: []pointer
var loc_mut: []int # 1 = mutable (var / param / loop-var), 0 = immutable (let) var loc_mut: []int # 1 = mutable (var / param / loop-var), 0 = immutable (let)
var nloc: int = 0 var nloc: int = 0
var g_emit_module: bool = false # --emit-module: a prebuilt binary module (issue #64) — no main/world; emit host-ABI declares + a load-time registration ctor
var g_uses_str: bool = false # a `str + str` / `str == str` was emitted -> emit the prelude var g_uses_str: bool = false # a `str + str` / `str == str` was emitted -> emit the prelude
var g_uses_intstr: bool = false # `string(int)` was emitted -> emit the int->string prelude var g_uses_intstr: bool = false # `string(int)` was emitted -> emit the int->string prelude
var g_uses_strslice: bool = false # `s[a..b]` was emitted -> emit the substring prelude var g_uses_strslice: bool = false # `s[a..b]` was emitted -> emit the substring prelude

View file

@ -166,7 +166,7 @@ function emit_program() -> void {
i = i + 1 i = i + 1
} }
if len(g_events) > 0 { emit_event_fns() } # EV0: @ev_<E> event-dispatch functions if len(g_events) > 0 { emit_event_fns() } # EV0: @ev_<E> event-dispatch functions
if has_ecs() and (len(g_events) > 0 or g_uses_query or g_uses_reflect or g_uses_esys) { emit_world_table() } # EV2/EV8: the mod reflection ABI (also powers Query.* / Reflect.* / engine systems) if has_ecs() { emit_world_table() } # EV2/EV8: the mod reflection ABI (powers Query.* / Reflect.* / engine systems, and lets a binary module (#64) link against a shared world). Unused defs dead-strip at -O2, so a game that touches none is output-identical.
if has_ecs() { emit_ecs_allocator(); emit_snapshot() } if has_ecs() { emit_ecs_allocator(); emit_snapshot() }
if has_ecs() { emit_net() } # N2/N3: @Sync serializers + @Owned storage (gated internally) if has_ecs() { emit_net() } # N2/N3: @Sync serializers + @Owned storage (gated internally)
if has_ui() { emit_ui_build() } if has_ui() { emit_ui_build() }
@ -208,6 +208,7 @@ function emit_program() -> void {
if g_uses_result { emith("%Result = type { i32, i32, ptr }\n") } # issue #46: ok/err/try value if g_uses_result { emith("%Result = type { i32, i32, ptr }\n") } # issue #46: ok/err/try value
if g_uses_option { emith("%Option = type { i32, i32 }\n") } # issue #53: some/none value if g_uses_option { emith("%Option = type { i32, i32 }\n") } # issue #53: some/none value
emit_cov_runtime() # issue #45: --coverage tables + exit dump emit_cov_runtime() # issue #45: --coverage tables + exit dump
if g_emit_module { emit_module_glue() } # issue #64: binary-module host-ABI declares + load-time registration ctor
} }
# issue #45: the line-coverage runtime. Emits the static line table, a parallel # issue #45: the line-coverage runtime. Emits the static line table, a parallel

View file

@ -76,6 +76,119 @@ function emit_engine_systems_for_phase(phase: pointer) -> void {
# declaration (draw) order. The active scene is snapshotted once per phase, so a # 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 # `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. # 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 }
return 7
}
# --- 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 = 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 { function emit_calls_for_phase(phase: pointer) -> void {
var i = 0 var i = 0
while i < len(prog) { while i < len(prog) {
@ -104,6 +217,7 @@ function emit_calls_for_phase(phase: pointer) -> void {
} }
} }
emit_engine_systems_for_phase(phase) # engine-owned systems run after every user handler 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
} }
# on enter / on exit compile to void functions @scene_enter_<Name> / # on enter / on exit compile to void functions @scene_enter_<Name> /

View file

@ -429,6 +429,7 @@ function emit_tick_helpers() -> void {
# system functions + lifecycle hooks + the drivable tick helpers — shared by the # system functions + lifecycle hooks + the drivable tick helpers — shared by the
# auto-loop game (emit_game_main) and an entry-driven game that owns its own loop. # auto-loop game (emit_game_main) and an entry-driven game that owns its own loop.
function emit_game_defs() -> void { function emit_game_defs() -> void {
emit_system_registry() # #64: @ludic_register_system + the registry globals
var i = 0 var i = 0
while i < len(prog) { if prog[i].kind == N_SYS { emit_system_fn(prog[i]) }; i = i + 1 } while i < len(prog) { if prog[i].kind == N_SYS { emit_system_fn(prog[i]) }; i = i + 1 }
emit_despawn_hooks() emit_despawn_hooks()

View file

@ -440,6 +440,11 @@ var g_uses_anim_rt: bool = false # Anim.play/clip/on_frame/fired or Motion.to (
var g_uses_tween_rt: bool = false # Tween.to/chain/delay/… (#48) -> splice tween.ludic + run esys_tween each Update var g_uses_tween_rt: bool = false # Tween.to/chain/delay/… (#48) -> splice tween.ludic + run esys_tween each Update
var g_uses_audio: bool = false # Audio.* (#22) -> splice audio.ludic; a windowed build also links audio.ll + AVFoundation var g_uses_audio: bool = false # Audio.* (#22) -> splice audio.ludic; a windowed build also links audio.ll + AVFoundation
var g_uses_http: bool = false # Http.* (#6) -> splice http.ludic; links http.ll + Foundation (macOS) var g_uses_http: bool = false # Http.* (#6) -> splice http.ludic; links http.ll + Foundation (macOS)
# issue #64: functions marked @System(Phase) in a prebuilt binary module — the
# compiler registers each with the host at load (it supplies the fn address,
# which Ludic source cannot take). Parallel arrays: fn name -> phase name.
var g_mod_sys_fn: []pointer
var g_mod_sys_phase: []pointer
function already_loaded(full: pointer) -> bool { function already_loaded(full: pointer) -> bool {
var i = 0 var i = 0
@ -467,6 +472,7 @@ function parse_one_decl() -> void {
var is_owned = false # @Owned model M — entities carry a network owner (N3) var is_owned = false # @Owned model M — entities carry a network owner (N3)
var role: pointer = null # @Server / @Predicted — a handler's network role (N5) var role: pointer = null # @Server / @Predicted — a handler's network role (N5)
var remote_dir: pointer = null # @ToServer / @ToClients — a remote event's direction (N4) var remote_dir: pointer = null # @ToServer / @ToClients — a remote event's direction (N4)
var sys_phase: pointer = null # @System(Phase) — a binary module's runtime-registered system (#64)
while is_op("@") { while is_op("@") {
pi = pi + 1; let a = eat_id() # collect a leading @annotation pi = pi + 1; let a = eat_id() # collect a leading @annotation
if (a == "export") { is_export = true } if (a == "export") { is_export = true }
@ -489,8 +495,9 @@ function parse_one_decl() -> void {
else { if (a == "Predicted") { role = "predicted" } # @Predicted handler (N5) else { if (a == "Predicted") { role = "predicted" } # @Predicted handler (N5)
else { if (a == "ToServer") { remote_dir = "toserver" } # @ToServer event (N4) else { if (a == "ToServer") { remote_dir = "toserver" } # @ToServer event (N4)
else { if (a == "ToClients") { remote_dir = "toclients" } # @ToClients event (N4) else { if (a == "ToClients") { remote_dir = "toclients" } # @ToClients event (N4)
else { if (a == "System") { sys_phase = "Update"; if is_op("(") { pi = pi + 1; sys_phase = eat_id(); eat_op(")") } } # @System(Phase) binary-module system (#64)
else { if is_op("(") { var d = 0 # any other @anno(args) — parsed and skipped else { if is_op("(") { var d = 0 # any other @anno(args) — parsed and skipped
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } } } } } } } } } } } } } } } } } } } while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } } } } } } } } } } } } } } } } } } } }
skipnl() skipnl()
} }
if is_id("import") { pi = pi + 1 if is_id("import") { pi = pi + 1
@ -551,7 +558,12 @@ function parse_one_decl() -> void {
if is_id("ui") { push(prog, parse_ui()); return } if is_id("ui") { push(prog, parse_ui()); return }
if is_id("var") { push(prog, parse_var()); return } if is_id("var") { push(prog, parse_var()); return }
if is_id("const") { push(prog, parse_const()); return } if is_id("const") { push(prog, parse_const()); return }
if is_id("function") { let f = parse_fn(); if is_export { f.ival = 1 }; push(prog, f); return } if is_id("function") {
let f = parse_fn()
if is_export { f.ival = 1 }
if (sys_phase != null) { push(g_mod_sys_fn, f.s); push(g_mod_sys_phase, sys_phase) } # #64: register at load
push(prog, f); return
}
if is_id("extern") { push(prog, parse_extern()); return } if is_id("extern") { push(prog, parse_extern()); return }
if is_id("entry") { push(prog, parse_main()); return } if is_id("entry") { push(prog, parse_main()); return }
if is_id("test") { push(g_tests, parse_test()); return } if is_id("test") { push(g_tests, parse_test()); return }
@ -776,6 +788,8 @@ function parse_program() -> void {
g_uses_value = false g_uses_value = false
g_uses_reflect_io = false g_uses_reflect_io = false
g_tests = new []Node g_tests = new []Node
g_mod_sys_fn = new []pointer
g_mod_sys_phase = new []pointer
loaded_paths = new []pointer loaded_paths = new []pointer
skipnl() skipnl()
g_game_name = "Ludic" g_game_name = "Ludic"

File diff suppressed because it is too large Load diff

View file

@ -89,6 +89,7 @@ entry {
else { if (a == ("--windowed")) { want = 1 } else { if (a == ("--windowed")) { want = 1 }
else { if (a == ("--headless")) { want = 2 } else { if (a == ("--headless")) { want = 2 }
else { if (a == ("--emit-llvm")) { emit_ir = true } else { if (a == ("--emit-llvm")) { emit_ir = true }
else { if (a == ("--emit-module")) { g_emit_module = true; emit_ir = true } # issue #64: prebuilt binary module IR
else { if (a == ("--fmt")) { fmt = true } else { if (a == ("--fmt")) { fmt = true }
else { if (a == ("--save-temps")) { save = true } else { if (a == ("--save-temps")) { save = true }
else { if (a == ("--run")) { run = true } else { if (a == ("--run")) { run = true }
@ -101,7 +102,7 @@ entry {
let m = `ludicc: ignoring unknown flag {a}\n` let m = `ludicc: ignoring unknown flag {a}\n`
file_write(file_stderr(), m, len(m)) file_write(file_stderr(), m, len(m))
} else { path = a } } else { path = a }
} } } } } } } } } } } } } } } } } } }
ai = ai + 1 ai = ai + 1
} }