feat(compiler): package-declarable engine systems + namespaces (#62)

Registry-izes the two hooks that made stdlib namespaces and engine systems
compiler-hardcoded, so a package registers them with no compiler edit — the
Phase-1 prerequisite for shipping the controller libraries (#58–#61) as real
packages rather than in-repo stdlib.

- Engine systems are a data-driven registry (component, esys-fn, phase). The
  core three (SpriteAnim/Motion — Update, Light2D — Render) are seeded in that
  exact order, so uses_engine_systems / emit_engine_systems_for_phase are now
  registry-driven with byte-identical output (verified: anim_ecs, light_ecs,
  snake IR unchanged; 87/0 golden renders; C-free fixpoint holds). A package
  appends with `@EngineSystem(Component, Phase)` on its esys function.
- Namespaces are a registry too: a package marks a provider with
  `@Namespace(Foo)`, and emit_ns_call aliases an otherwise-unknown Foo.method to
  the bare foo_method (the same generic path the core namespaces use) — after
  every hardcoded core block, so core dispatch is untouched.
- Both annotations are keyword-free (like #64's @System), so no vocabulary /
  grammar churn.

Proven end-to-end (hermetic, source path, runs everywhere): a package registers
Score + esys_score via @EngineSystem and coach_bonus via @Namespace; a consumer
game imports it and prints "4 99" — the engine system ran each Update and
Coach.bonus() dispatched, with no compiler edit for the package. Package suite
18/0.

Core stdlib namespaces stay on their optimized hardcoded blocks by design
(byte-identity + determinism); the generic path is proven to carry a namespace
and packages ride it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-01 12:30:01 +03:00
parent 075a7b1430
commit 7cbd5d175c
6 changed files with 19004 additions and 18230 deletions

View file

@ -23,6 +23,16 @@ function emit_expect_fail(cond1: pointer, msgsym: pointer, got: pointer, want: p
emit(`{lok}:\n`)
}
# lowercase an ASCII identifier (for #62 package-namespace aliasing: Foo -> foo)
function ns_lower(s: pointer) -> pointer {
let n = cstr_len(s)
let b = bytes(n + 1)
var i = 0
while i < n { var c = s[i]; if (c >= 65) and (c <= 90) { c = c + 32 }; b[i] = c; i = i + 1 }
b[n] = 0
return b
}
function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
# Math.* is computed inline (deterministic fixed-point), not routed through a
# bare rt_ name — so `floor`/`round`/`lerp` never leak into the bare namespace.
@ -576,6 +586,10 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "encode") { bare = "json_encode"; push(labels, "value") }
if (meth == "parse") { bare = "json_parse"; push(labels, "text") }
}
# #62: a package-provided namespace (declared with @Namespace(Foo)) that none
# of the hardcoded core blocks matched — alias Foo.method to the bare function
# foo_method (positional args), the same generic path the core aliases use.
if (bare == null) and is_registered_namespace(ns) { bare = ns_lower(ns) + ("_") + meth }
if (bare == null) { perr(`unknown builtin {ns}.{meth}`) }
reorder_named(e, labels)
let id = node(E_ID); id.s = bare; e.a = id

View file

@ -26,9 +26,11 @@ function has_models() -> bool {
# 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 {
if find_comp("SpriteAnim") != null { return true }
if find_comp("Motion") != null { return true }
if find_comp("Light2D") != null { return true }
# #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 = i + 1 }
if find_comp("Occluder") != null { return true }
return false
}

View file

@ -60,16 +60,20 @@ function emit_one_engine_system(comp: pointer, fn: pointer) -> void {
}
}
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 = i + 1
}
if (phase == "Update") {
emit_one_engine_system("SpriteAnim", "esys_spriteanim")
emit_one_engine_system("Motion", "esys_motion")
# 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_esys_tween()\n") }
}
if (phase == "Render") {
emit_one_engine_system("Light2D", "esys_light2d")
}
}
# Global handlers run first, then the active scene's layer handlers in

View file

@ -445,6 +445,27 @@ var g_uses_http: bool = false # Http.* (#6) -> splice http.ludic; links http
# which Ludic source cannot take). Parallel arrays: fn name -> phase name.
var g_mod_sys_fn: []pointer
var g_mod_sys_phase: []pointer
# issue #62: package-declarable registries (data-driven, additive to the core
# hardcoded paths). Engine systems: (component, esys-fn, phase) — @EngineSystem.
# Namespaces: names a package provides Foo.* dispatch for — @Namespace.
var g_esys_comp: []pointer
var g_esys_fn: []pointer
var g_esys_phase: []pointer
var g_namespaces: []pointer
# #62: package namespace registry — a package marks a Foo.* provider with
# @Namespace(Foo); emit_ns_call aliases an otherwise-unknown Foo.method to the
# bare function foo_method (lowercased namespace + "_" + method).
function register_namespace(name: pointer) -> void {
var i = 0
while i < len(g_namespaces) { if (g_namespaces[i] == name) { return }; i = i + 1 }
push(g_namespaces, name)
}
function is_registered_namespace(name: pointer) -> bool {
var i = 0
while i < len(g_namespaces) { if (g_namespaces[i] == name) { return true }; i = i + 1 }
return false
}
function already_loaded(full: pointer) -> bool {
var i = 0
@ -473,6 +494,9 @@ function parse_one_decl() -> void {
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 sys_phase: pointer = null # @System(Phase) — a binary module's runtime-registered system (#64)
var esys_comp: pointer = null # @EngineSystem(Comp, Phase) — a package compile-time engine system (#62)
var esys_phase: pointer = null
var ns_name: pointer = null # @Namespace(Name) — a package-provided Foo.* namespace (#62)
while is_op("@") {
pi = pi + 1; let a = eat_id() # collect a leading @annotation
if (a == "export") { is_export = true }
@ -496,8 +520,10 @@ function parse_one_decl() -> void {
else { if (a == "ToServer") { remote_dir = "toserver" } # @ToServer 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 (a == "EngineSystem") { eat_op("("); esys_comp = eat_id(); eat_op(","); esys_phase = eat_id(); eat_op(")") } # @EngineSystem(Comp, Phase) package engine system (#62)
else { if (a == "Namespace") { eat_op("("); ns_name = eat_id(); eat_op(")") } # @Namespace(Name) package Foo.* namespace (#62)
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()
}
if is_id("import") { pi = pi + 1
@ -562,6 +588,8 @@ function parse_one_decl() -> void {
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
if (esys_comp != null) { push(g_esys_comp, esys_comp); push(g_esys_fn, f.s); push(g_esys_phase, esys_phase) } # #62: package engine system
if (ns_name != null) { register_namespace(ns_name) } # #62: package Foo.* namespace
push(prog, f); return
}
if is_id("extern") { push(prog, parse_extern()); return }
@ -790,6 +818,14 @@ function parse_program() -> void {
g_tests = new []Node
g_mod_sys_fn = new []pointer
g_mod_sys_phase = new []pointer
# #62: seed the engine-system registry with the core entries, in the historical
# emit order (SpriteAnim, Motion — Update; Light2D — Render), so a core game is
# byte-identical; packages append via @EngineSystem.
g_esys_comp = new []pointer; g_esys_fn = new []pointer; g_esys_phase = new []pointer
push(g_esys_comp, "SpriteAnim"); push(g_esys_fn, "esys_spriteanim"); push(g_esys_phase, "Update")
push(g_esys_comp, "Motion"); push(g_esys_fn, "esys_motion"); push(g_esys_phase, "Update")
push(g_esys_comp, "Light2D"); push(g_esys_fn, "esys_light2d"); push(g_esys_phase, "Render")
g_namespaces = new []pointer
loaded_paths = new []pointer
skipnl()
g_game_name = "Ludic"

File diff suppressed because it is too large Load diff

View file

@ -170,6 +170,28 @@ function cmd_test_pkg() -> int {
ok("x vendor copies the resolved packages into ./vendor")
} else { bad2("x vendor failed", capture_line(`tail -1 {work}/vendor.out`)) }
# ---- package-declarable engine-system + namespace (issue #62) -------------
# A source package registers a compile-time engine system (@EngineSystem) and a
# Foo.* namespace (@Namespace) with no compiler edit; a consumer game imports it
# and both take effect. Source path → runs everywhere (not dylib/macOS-gated).
let cp = `{proxy}/example.test/coachpack`
run(`mkdir -p {cp} && git -C {cp} init -q`)
pt_write(`{cp}/package.ludic`, `package "example.test/coachpack"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Coach"` + nl())
pt_write(`{cp}/coach.ludic`, "property Score { value: int = 0 }\n@EngineSystem(Score, Update) function esys_score() -> void {\n let P = world_prop_id(\"Score\")\n let e = world_query_next(P, 0)\n if e >= 0 { world_set(e, P, 0, world_get(e, P, 0) + 1) }\n}\n@Namespace(Coach) function coach_bonus() -> int { return 99 }\n")
pt_commit_tag(cp, "v1.0.0")
let proj4 = `{work}/proj4`
pt_write(`{proj4}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/coachpack" "1.0.0"` + nl())
pt_write(`{proj4}/game.ludic`, "program Game {\n import \"example.test/coachpack/coach.ludic\"\n model Player { Score }\n @OnSpawn(Player) handler Init { }\n var frames: int = 0\n handler Boot phase Start { let e = world_spawn(world_model_id(\"Player\")) }\n handler Run phase Update {\n frames = frames + 1\n if frames >= 5 {\n let P = world_prop_id(\"Score\")\n let e = world_query_next(P, 0)\n print(world_get(e, P, 0))\n print(Coach.bonus())\n quit()\n }\n }\n}\n")
if shq(`( cd {proj4} && {envp} {root}/bin/x get > {work}/get4.out 2>&1 )`) {
run(`LUDIC_HOME={root} LUDIC_MODULES={proj4}/ludic_modules {root}/bin/ludicc --headless {proj4}/game.ludic -o {proj4}/game 2>{work}/gc4.err`)
if file_exists(`{proj4}/game`) {
let got = capture_line(`printf 'aaaaa' | {proj4}/game`)
if got == "4 99" {
ok("a package registers an engine-system (@EngineSystem) + a namespace (@Namespace) with no compiler edit (\"4 99\")")
} else { bad2("package hooks produced wrong output", `got [{got}] want [4 99]`) }
} else { bad2("consumer of a @EngineSystem/@Namespace package failed to build", capture_line(`tail -1 {work}/gc4.err`)) }
} else { bad2("x get for the coach package failed", capture_line(`tail -1 {work}/get4.out`)) }
# ---- prebuilt binary package (issue #64): exported function + system + component
# A module is compiled to a dylib (x build-lib), fetched as a `kind prebuilt`
# dependency, and linked into a consumer game that never saw its source. At load