Scriptable properties (ECS-safe subset): @Computed and @OnSpawn

Adds two annotations that give properties/models a scriptable feel WITHOUT
reattaching behavior to data — both reduce to code the data-oriented model
already emits:

- @Computed field on a property: a derived value that is NOT stored; x.field
  expands inline to its expression (bare names read as fields of x) at each use.
  Zero storage, zero runtime dispatch. Reuses qualify_fields (now non-destructive,
  base-node based); a g_computed registry keeps derived fields out of the layout.
- @OnSpawn(Model) on a handler: a constructor that runs at each spawn of Model
  with the model's properties bound by name. Spawn statically knows the model, so
  no runtime dispatch; emit_spawn binds the properties and inlines the hook body.

examples/annotations.ludic now exercises @Handles/@Queries/@Computed/@OnSpawn
(output 3 25 0 0); test.sh 15/15, fixpoint holds, goldens byte-identical.

Deferred (need more machinery, by design): @OnDespawn (despawn doesn't statically
know the entity's model) and @OnChange (needs change-tracking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-27 21:01:32 +03:00
parent ecead0564e
commit 1951af99e9
9 changed files with 4177 additions and 3503 deletions

View file

@ -281,11 +281,38 @@ for (Transform, Velocity) in query [Transform, Velocity, {Actor}]
`Prop{constraint}` block reads its bare names as fields of `Prop`, and `on: Model`
adds a `{Model}` tag filter. The body runs once per matching entity.
**`@Computed` — a derived field.** A property field marked `@Computed` is **not
stored**; `x.field` expands inline to its expression with the bare names read as
fields of `x`. It reads like a field but costs nothing at runtime — no getter, no
storage — so it doesn't reattach behavior to data:
```ludic
# doc-check: skip — a property with a derived field
property Velocity {
dx: int = 0
dy: int = 0
@Computed speed2: int = dx * dx + dy * dy # v.speed2 == v.dx*v.dx + v.dy*v.dy
}
```
**`@OnSpawn` — a constructor.** `@OnSpawn(Model)` on a handler runs its body every
time a `Model` is spawned, with the model's properties bound by name — a place to
initialize an entity. It's a handler keyed to the spawn, not an observer on the
data, so the ECS model is untouched:
```ludic
# doc-check: skip — a spawn hook
@OnSpawn(Enemy)
handler InitEnemy { Health.hp = Health.max } # start every Enemy at full HP
```
**`@Handles` — the handlers a program drives.** Written in front of the
`program`, `@Handles(Move)` names the handlers it uses. It parses and reads as
documentation; every declared handler still runs (registration is implicit).
See [`examples/annotations.ludic`](examples/annotations.ludic).
See [`examples/annotations.ludic`](examples/annotations.ludic), which uses all
four. (`@OnDespawn` and change-reactions are future work — despawn doesn't
statically know an entity's model, and reactions need change-tracking.)
## Structs, arrays and slices

View file

@ -1,22 +1,29 @@
# annotations.ludic — the annotation-first style. A handler's query is an
# @Queries decorator instead of a clause, and the program lists the handlers it
# drives with @Handles. Both lower to the same code the older spellings did.
# annotations.ludic — the annotation-first style. Behaviour is expressed with
# @-decorators that desugar to plain handlers, queries and expressions, so the
# properties stay pure data and the data-oriented model is untouched.
@Handles(Move)
program RPG2D {
property Transform { x: int = 0, y: int = 0, scale: int = 1 }
property Velocity { dx: int = 0, dy: int = 0 }
property Velocity {
dx: int = 0
dy: int = 0
@Computed speed2: int = dx * dx + dy * dy # derived; no storage, inlined at use
}
model Actor { Transform, Velocity }
# A constructor: every Actor starts visible. Runs at spawn with the model's
# properties bound by name — no per-frame cost, no observer machinery.
@OnSpawn(Actor)
handler InitActor { Transform.scale = 2 }
handler Spawn phase Start {
spawn Actor { Transform { x: 0, scale: 2 } Velocity { dx: 3, dy: 1 } }
spawn Actor { Transform { x: 0, scale: 0 } Velocity { dx: 9, dy: 9 } }
spawn Actor { Transform { x: 0 } Velocity { dx: 3, dy: 4 } }
spawn Actor { Transform { x: 0 } Velocity { dx: 0, dy: 0 } }
}
# Move every Actor whose scale is positive and that is actually moving. The
# query lives in the annotation; each bound property is addressed by its own
# name in the body (`Transform`, `Velocity`), and a constraint like
# `Transform{scale > 0}` reads `scale` as a field of Transform.
# query is the decorator; each bound property is addressed by its own name.
@Queries(these: [Transform{scale > 0}, Velocity{dx > 0 or dy > 0}], on: Actor)
handler Move phase Update {
Transform.x = Transform.x + Velocity.dx
@ -24,7 +31,7 @@ program RPG2D {
}
handler Report phase Render {
for (t) in query [Transform] { print_int(t.x); print_int(t.y) }
for (t, v) in query [Transform, Velocity] { print_int(t.x); print_int(v.speed2) }
quit()
}
}

View file

@ -140,6 +140,42 @@ fn find_fn(name: ptr) -> Node {
return ptr_null()
}
# @Computed derived fields: a per-property (Prop.field -> expression) registry.
# These are NOT stored in the component layout; `x.field` expands inline to the
# expression with its bare names read as fields of `x`. Populated at parse time.
var g_computed: []Node # each: s = "Prop.field", ty = result type, a = expr
fn register_computed(prop: ptr, field: ptr, ty: ptr, e: Node) -> void {
let cf = node(N_FIELD); cf.s = sconcat(prop, sconcat(".", field)); cf.ty = ty; cf.a = e
push(g_computed, cf)
}
fn computed_expr(prop: ptr, field: ptr) -> Node {
if ptr_is_null(prop) { return ptr_null() }
let key = sconcat(prop, sconcat(".", field))
let i = 0
while i < len(g_computed) { if streq(g_computed[i].s, key) { return g_computed[i].a }; i = i + 1 }
return ptr_null()
}
# best-effort static type of an expression (for computed-field lookup; emits nothing)
fn static_type(e: Node) -> ptr {
if e.kind == E_ID { let li = loc_find(e.s); if li >= 0 { return loc_ty[li] } }
return ptr_null()
}
# @OnSpawn(Model) hooks: a Model -> hook-body registry. Populated at parse time;
# `spawn Model { … }` runs the body with the model's properties bound (like a
# constructor). Spawn statically knows the model, so no runtime dispatch is needed.
var g_onspawn: []Node # each: s = Model name, a = hook body block
fn register_onspawn(model: ptr, body: Node) -> void {
let n = node(N_BLOCK); n.s = model; n.a = body; push(g_onspawn, n)
}
fn onspawn_body(model: ptr) -> Node {
let i = 0
while i < len(g_onspawn) { if streq(g_onspawn[i].s, model) { return g_onspawn[i].a }; i = i + 1 }
return ptr_null()
}
# local variable environment
fn loc_reset() -> void { nloc = 0 }
fn loc_push(name: ptr, r: ptr, ty: ptr) -> void {

View file

@ -157,6 +157,11 @@ fn emit_expr(e: Node) -> Val {
let ord = enum_ordinal(e.a.s, e.s)
if ord >= 0 { return val(itoa(ord), "int") }
}
let bt = static_type(e.a) # `x.field` where field is @Computed -> inline it
if not ptr_is_null(bt) {
let cx = computed_expr(bt, e.s)
if not ptr_is_null(cx) { return emit_expr(qualify_fields(cx, e.a)) }
}
let a = emit_member_addr(e); return emit_load_at(a, g_addr_ty)
}
if e.kind == E_INDEX { let a = emit_index_addr(e); return emit_load_at(a, g_addr_ty) }

View file

@ -39,6 +39,22 @@ fn emit_init_component(e: ptr, comp: ptr, rec: Node) -> void {
}
}
# bind each of a model's properties to entity `e`'s component storage, so an
# @OnSpawn hook body can address them by name (like a query binding for one entity).
fn emit_bind_props(model: Node, e: ptr) -> void {
let me = itoa(MAX_ENT)
let c = 0
while c < len(model.kids) {
let pname = model.kids[c].s
let slot = nreg()
emit(" "); emit(slot); emit(" = getelementptr inbounds ["); emit(me); emit(" x %Cmp_"); emit(pname); emit("], ptr @S_"); emit(pname); emit(", i32 0, i32 "); emit(e); emit("\n")
let vslot = emit_alloca("ptr")
emit(" store ptr "); emit(slot); emit(", ptr "); emit(vslot); emit("\n")
loc_push(pname, vslot, pname)
c = c + 1
}
}
fn emit_spawn(st: Node) -> void {
let e = emit_bind("call i32 @L_alloc()")
let ak = find_arch_id(st.s)
@ -56,6 +72,13 @@ fn emit_spawn(st: Node) -> void {
emit_init_component(e, cn, rec)
c = c + 1
}
let ob = onspawn_body(st.s) # @OnSpawn(Model) hook runs after init
if not ptr_is_null(ob) {
let save = nloc
emit_bind_props(arch, e)
emit_block(ob)
nloc = save
}
} else {
let i = 0
while i < len(st.kids) { emit_init_component(e, st.kids[i].s, st.kids[i].a); i = i + 1 }

File diff suppressed because it is too large Load diff

View file

@ -282,12 +282,14 @@ fn already_loaded(full: ptr) -> bool {
fn parse_one_decl() -> void {
let is_export = false
let qspec: Node = ptr_null()
let onspawn_model: ptr = ptr_null()
while is_op("@") {
pi = pi + 1; let a = eat_id() # collect a leading @annotation
if streq(a, "export") { is_export = true }
if streq(a, "Queries") { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...)
else { if streq(a, "Queries") { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...)
else { if streq(a, "OnSpawn") { eat_op("("); onspawn_model = eat_id(); eat_op(")") } # @OnSpawn(Model)
else { if is_op("(") { let 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
@ -303,6 +305,9 @@ fn parse_one_decl() -> void {
if is_id("model") { push(prog, parse_archetype()); return }
if is_id("handler") {
let h = parse_system()
if not ptr_is_null(onspawn_model) { # @OnSpawn(Model): a spawn hook, not a phased handler
register_onspawn(onspawn_model, h.a); return
}
if not ptr_is_null(qspec) { # @Queries wraps the body in its S_QUERY
qspec.a = h.a
let wrap = node(N_BLOCK); push(wrap.kids, qspec); h.a = wrap
@ -346,6 +351,8 @@ fn maybe_splice_runtime() -> void {
fn parse_program() -> void {
prog = new []Node
g_computed = new []Node
g_onspawn = new []Node
loaded_paths = new []ptr
skipnl()
g_game_name = "Ludic"

View file

@ -5,9 +5,13 @@
fn parse_component() -> Node {
pi = pi + 1; let n = node(N_COMP); n.s = eat_id(); skipnl(); eat_op("{")
while true { skipnl(); if is_op("}") { break }
let is_computed = false
if is_op("@") { pi = pi + 1; let ann = eat_id(); if streq(ann, "Computed") { is_computed = true }; skipnl() }
let f = node(N_FIELD); f.s = eat_id(); eat_op(":"); f.ty = ptype()
if is_op("=") { pi = pi + 1; f.a = expr() }
push(n.kids, f); if is_op(",") { pi = pi + 1 } }
if is_computed { register_computed(n.s, f.s, f.ty, f.a) } # derived: no storage
else { push(n.kids, f) }
if is_op(",") { pi = pi + 1 } }
eat_op("}"); return n
}
@ -95,15 +99,21 @@ fn mk_and(a: Node, b: Node) -> Node {
let n = node(E_BIN); n.s = "and"; n.a = a; n.b = b; return n
}
# In `Prop{scale > 0}` the bare names are fields of Prop; qualify each to
# `Prop.field` (the binding var is the property name) for the desugared where.
fn qualify_fields(e: Node, prop: ptr) -> Node {
# Rewrite each bare identifier in `e` as `base.field` — used both by
# `Prop{constraint}` (base is the property binding) and by @Computed field
# expansion (base is the accessed value). Non-destructive: builds a fresh tree,
# so a stored computed expression can be expanded at many access sites.
fn qualify_fields(e: Node, base: Node) -> Node {
if ptr_is_null(e) { return e }
if e.kind == E_ID {
let m = node(E_MEMBER); let base = node(E_ID); base.s = prop; m.a = base; m.s = e.s; return m
let m = node(E_MEMBER); m.a = base; m.s = e.s; return m
}
if e.kind == E_BIN {
let n2 = node(E_BIN); n2.s = e.s; n2.a = qualify_fields(e.a, base); n2.b = qualify_fields(e.b, base); return n2
}
if e.kind == E_UN {
let n2 = node(E_UN); n2.s = e.s; n2.a = qualify_fields(e.a, base); return n2
}
if e.kind == E_BIN { e.a = qualify_fields(e.a, prop); e.b = qualify_fields(e.b, prop); return e }
if e.kind == E_UN { e.a = qualify_fields(e.a, prop); return e }
return e
}
@ -124,7 +134,8 @@ fn parse_queries_anno() -> Node {
let pname = eat_id()
let v = node(E_ID); v.s = pname; push(qn.kids, v) # binding var = property name
let t = node(E_ID); t.s = pname; t.ival = 0; push(terms.kids, t)
if is_op("{") { pi = pi + 1; let ce = expr(); eat_op("}"); wh = mk_and(wh, qualify_fields(ce, pname)) }
if is_op("{") { pi = pi + 1; let ce = expr(); eat_op("}")
let cb = node(E_ID); cb.s = pname; wh = mk_and(wh, qualify_fields(ce, cb)) }
if is_op(",") { pi = pi + 1 }
skipnl()
}

View file

@ -50,10 +50,11 @@ qsmoke() { # name
else bad "$n: $(tail -1 /tmp/qs.out)"; fi
}
qsmoke qdecl
# @Queries desugars to a query loop; @Handles parses. Check the desugared behavior.
# The annotation DSL: @Queries desugars to a query, @Computed inlines a derived
# field, @OnSpawn runs a constructor, @Handles parses. Check the composite output.
if ./selfhost/game-build.sh build/ludicc examples/annotations.ludic "/tmp/ludic_ann" >/tmp/ann.out 2>&1 \
&& [ "$(/tmp/ludic_ann | tr '\n' ' ')" = "3 1 0 0 " ]; then
ok "annotations.ludic (@Queries desugars to a query, @Handles parses)"
&& [ "$(/tmp/ludic_ann </dev/null | tr '\n' ' ')" = "3 25 0 0 " ]; then
ok "annotations.ludic (@Queries / @Computed / @OnSpawn / @Handles)"
else bad "annotations: $(tail -1 /tmp/ann.out)"; fi
# --- Toolchain-agent CLI smoke tests append below this line ---
echo "== self-hosted front-end binaries (ludicc / ludic) =="