Merge branch 'lang/ecs' into lang/uifree

This commit is contained in:
Orkun ÇAKILKAYA 2026-09-28 10:12:48 +03:00
commit f294484c02
7 changed files with 52637 additions and 52025 deletions

View file

@ -0,0 +1,10 @@
bump: patch
type: fix
**A dispatched action no longer allocates a record each time.** `dispatch A { ... }` made a fresh
record for the queue, and Ludic frees nothing, so a system dispatching every frame (an input's
`Move`, a frame's time) grew the program by a record a frame. The queue now keeps a list per
action: a dispatch takes the next one (making one only when all are queued), fills every field
as `new` would - given, or its default - and `drain_actions()` hands them all back once the queue
is empty. A reducer reads its action only during the drain, so nothing sees a record after it is
reused; keep what must last in the state, not the action. `ludic.base`'s `actions_test` holds a
reused record getting its defaults back.

View file

@ -11,6 +11,15 @@ program ActionsTest {
reducer Door on Knock(d: mut Door, a: Knock) {
if a.times >= 2 { d.open = true }
}
state Tally {
sum: int = 0
words: string = ""
}
action Note { n: int = 7, word: string = "hi" }
reducer Tally on Note(t: mut Tally, a: Note) {
t.sum = t.sum + a.n
t.words = t.words + a.word
}
function input_tick(t: Tick) -> void { dispatch Knock { times: 2 } }
function sim_tick(d: mut Door, t: Tick) -> void {
if d.open { d.seen = d.seen + "o" } else { d.seen = d.seen + "c" }
@ -27,4 +36,16 @@ program ActionsTest {
expect(d.open)
expect_eq(d.seen, "o")
}
test "a dispatched record is kept and filled again: what a dispatch leaves out is its default, not the last one's" (t: Tally) {
dispatch Note { n: 1, word: "a" }
dispatch Note { n: 2, word: "b" }
drain_actions()
expect_eq(t.sum, 3)
dispatch Note { }
dispatch Note { word: "c" }
dispatch Note { n: 5 }
drain_actions()
expect_eq(t.sum, 3 + 7 + 7 + 5)
expect_eq(t.words, "abhichi")
}
}

View file

@ -1060,7 +1060,7 @@ function emit_expr(e: Node) -> Val {
if e.kind == E_STR { return val(emit_str_const(e.s), "string") }
if e.kind == E_NEW {
if is_slice_ty(e.s) { return emit_new_slice(e.s) }
return emit_new_struct(e.s, e.a)
return emit_new_struct(e.s, e.a, e.b)
}
if e.kind == E_LIST { return emit_list(e) } # [a, b, c] -> a fresh slice
# fn name -> the function's address, for a worker entry point. The OS-thread runtime calls it

View file

@ -23,14 +23,16 @@ function rec_field(rec: Node, fname: pointer) -> Node {
# `new T` / `new T { field: value, ... }` — allocate a record and seed each
# field: a value from the override record if given, else the field's declared
# default. `rec` is the E_REC of overrides (or null for the bare `new T`).
function emit_new_struct(name: pointer, rec: Node) -> Val {
# `into`: a record to fill in place of a fresh one (a dispatch's, kept by the action queue)
function emit_new_struct(name: pointer, rec: Node, into: Node) -> Val {
let s = layout_node(name) # a struct or a property — same shape
if (s == null) and port_find(name) >= 0 { perr(`{name} is a port: it is filled once with 'bind {name} {{ ... }}' where the program is put together, not made with new`) }
if (s == null) { perr(`unknown record type {name} in new`) }
vis_check(s, name)
let lty = layout_ty(name)
let sz = emit_sizeof(lty)
let obj = emit_bind(`call ptr @malloc(i64 {sz})`)
var obj = ""
if into != null { obj = emit_expr(into).code } else { obj = emit_bind(`call ptr @malloc(i64 {sz})`) }
var f = 0
while f < len(s.kids) {
let fd = s.kids[f]

View file

@ -26,6 +26,7 @@ const ACTION_PASSES: int = 64
function parse_action() -> void {
let c = parse_component()
c.uns = 3
c.vis = 1 # the program's own file makes and keeps its records
push(prog, c)
push(g_act_names, c.s)
push(g_act_nodes, c)
@ -55,6 +56,14 @@ function parse_dispatch() -> Node {
let nw = node(E_NEW)
nw.s = name
if is_op("{") { nw.a = record() }
# its record is the queue's: one of the action's kept records, filled as `new` fills a fresh one
let take = node(E_CALL)
let tc = node(E_ID)
tc.s = `ludic_act_new__{name}`
tc.pos = -1
take.a = tc
take.pos = -1
nw.b = take
let call = node(E_CALL)
let callee = node(E_ID)
callee.s = "ludic_act_push"
@ -145,13 +154,20 @@ function red_check(i: int) -> void {
}
# the queue, its push and its drain: each action's reducers in the order of their states' names
function act_src() -> pointer {
var src = "export state LudicActions {\n kinds: []int = new []int\n vals: []pointer = new []pointer\n head: int = 0\n depth: int = 0\n}\n"
var src = "export state LudicActions {\n kinds: []int = new []int\n vals: []pointer = new []pointer\n head: int = 0\n depth: int = 0\n"
var a = 0
while a < len(g_act_names) {
src = src + ` kept{itoa(a)}: []{g_act_names[a]} = new []{g_act_names[a]}\n used{itoa(a)}: int = 0\n`
a += 1
}
src = src + "}\n"
src = src + act_new_src()
src = src + "export function ludic_act_push(q: mut LudicActions, k: int, v: pointer) -> void {\n push(q.kinds, k)\n push(q.vals, v)\n}\n"
src = src + "export function drain_actions(q: mut LudicActions) -> void {\n if q.depth > 0 or len(q.kinds) == 0 { return }\n q.depth = 1\n var pass = 0\n var round_end = len(q.kinds)\n"
src = src + " while q.head < len(q.kinds) {\n if q.head == round_end {\n pass += 1\n round_end = len(q.kinds)\n"
src = src + ` if pass >= {itoa(ACTION_PASSES)} {{ ludic_act_runaway(q.kinds[q.head]) }}\n }}\n`
src = src + " let k = q.kinds[q.head]\n let v = q.vals[q.head]\n q.head += 1\n"
var a = 0
a = 0
while a < len(g_act_names) {
let rs = red_sorted(g_act_names[a])
if len(rs) > 0 {
@ -165,7 +181,13 @@ function act_src() -> pointer {
}
a += 1
}
src = src + " }\n List.clear(q.kinds)\n List.clear(q.vals)\n q.head = 0\n q.depth = 0\n}\n" # in place: a drain a phase, seven a frame, allocates nothing
src = src + " }\n List.clear(q.kinds)\n List.clear(q.vals)\n q.head = 0\n q.depth = 0\n" # in place: a drain a phase, seven a frame, allocates nothing
a = 0
while a < len(g_act_names) {
src = src + ` q.used{itoa(a)} = 0\n` # every record read: each is free again
a += 1
}
src = src + "}\n"
src = src + "export function ludic_act_runaway(k: int) -> void {\n var name = \"?\"\n"
a = 0
while a < len(g_act_names) {
@ -175,6 +197,19 @@ function act_src() -> pointer {
src = src + " print(`actions: {name} is still being dispatched after " + itoa(ACTION_PASSES) + " rounds of reducers - a reducer dispatches what dispatches it`)\n exit(1)\n}\n"
return src
}
# each action's next kept record, made only when all it has are queued: `dispatch` fills it in
# place, and the drain hands them all back once every reducer has read them
function act_new_src() -> pointer {
var src = ""
var a = 0
while a < len(g_act_names) {
let n = g_act_names[a]
let k = itoa(a)
src = src + `export function ludic_act_new__{n}(q: mut LudicActions) -> {n} {{\n if q.used{k} >= len(q.kept{k}) {{ push(q.kept{k}, new {n}) }}\n q.used{k} += 1\n return q.kept{k}[q.used{k} - 1]\n}}\n`
a += 1
}
return src
}
function red_sorted(act: pointer) -> []int {
let out = new []int
var i = 0
@ -198,5 +233,5 @@ function red_sorted(act: pointer) -> []int {
# a call to the queue from anywhere: the queue is the runtime's to supply, like a state an entry point
# is given (drain_actions() from a ludic.base runner or a program's own loop)
function is_action_builtin(name: pointer) -> bool {
return (name == "drain_actions") or (name == "ludic_act_push")
return (name == "drain_actions") or (name == "ludic_act_push") or str_starts(name, "ludic_act_new__")
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff