ludic/selfhost/backend/emit_frame.ludic
Orkuncakilkaya d483c9283c frame allocs: a local shadowing a function is no edge, the drain's calls are no edges, HEAP keeps only a growth off the arena, and the arena starts at its first use
- the call graph is by name, and a local or a parameter named as a function (a float bd, part, bx)
  linked to that function: a name the function binds itself is never an edge now.
- drain_actions, generated, calls every reducer; a reducer is reached from its action's dispatch,
  so the drain's calls are not edges.
- a fresh value flowing into a local that also holds kept memory is still the frame's (storing it
  anywhere kept would have made it ESC): HEAP now keeps only a push's growth off the arena.
- the arena starts at its first use rather than at the first frame mark, so boot's temporaries are
  scratch too - dead once the Start handlers return - and a scratch site is never a birth.
Plus ludic.hints' rail and three of ludic.update's one-off lines declared. The arena goldens pass
poisoned. Maroon Lake (d79d189f): 39/11/66 -> 30/9/0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:23:28 +03:00

341 lines
13 KiB
Text

# emit_frame.ludic — 25.2: what a frame can come to allocate. The roots are the code that runs every
# frame - a handler in a frame phase, every reducer (the drain runs after each phase), an `@On` body,
# a function stored as a System's `tick`, and one stored in a field whose type marks it `@frame`
# (a step list) - and every allocating construct in what they reach through deps_reach's graph is a
# `falloc` line with the shortest chain from a root. A function under `@alloc_ok("why")` counts
# nothing and is not followed. ludic deps counts the lines (frame_allocs) and ratchets them.
var g_alloc_ok_names: []pointer = new []pointer # functions and handlers under @alloc_ok("why")
var g_frame_fields: []pointer = new []pointer # record fields declared `@frame`
var g_aok_pending: bool = false # @alloc_ok read, its function or handler not yet
const FR_AOK_STMT: int = 78 # Node.uns on a statement under @alloc_ok("why")
var g_aok_seen: bool = false # any statement is: bodies are searched only then
function fr_aok_stmt(n: Node) -> bool { return n.uns == FR_AOK_STMT and n.kind != E_ID }
# does this body hold a statement under @alloc_ok? (its function then keeps the scope's depth)
function fr_has_aok(n: Node) -> bool {
if n == null or not g_aok_seen { return false }
if fr_aok_stmt(n) { return true }
if fr_has_aok(n.a) or fr_has_aok(n.b) or fr_has_aok(n.c) { return true }
if n.kids != null {
var i = 0
while i < len(n.kids) { if fr_has_aok(n.kids[i]) { return true }; i += 1 }
}
return false
}
var g_fr_root: []bool = new []bool
var g_fr_from: []int = new []int # the node a reached one was first reached from
function fr_capped(t: Node) -> bool {
if t == null or t.kind != E_MEMBER or t.s == null { return false }
let tail = `.{t.s}`
var i = 0
while i < len(g_cap_keys) {
let k = g_cap_keys[i]
if len(k) > len(tail) and (k[len(k) - len(tail) .. len(k)] == tail) { return true }
i += 1
}
return false
}
function fr_is_event_handler(name: pointer) -> bool {
var i = 0
while i + 4 <= len(name) {
if name[i] == '_' and name[i + 1] == 'o' and name[i + 2] == 'n' and name[i + 3] == '_' { return true }
i += 1
}
return false
}
function fr_phase_frame(ph: pointer) -> bool {
return (ph == "Input") or (ph == "FixedUpdate") or (ph == "Update") or (ph == "LateUpdate") or (ph == "Render") or (ph == "Overlay")
}
# `tick` is a System's, by any name's record; any other field is a frame root only where its own
# property says `@frame` (Prop.field), so a step list of loads with a `run` field is not one
function fr_is_frame_field(name: pointer) -> bool { return (name == "tick") }
function fr_is_frame_prop_field(prop: pointer, name: pointer) -> bool {
if (name == "tick") { return true }
let key = `{prop}.{name}`
var i = 0
while i < len(g_frame_fields) { if (g_frame_fields[i] == key) { return true }; i += 1 }
return false
}
# a generic's instance (kept_push$NetFact) is under its declaration's @alloc_ok
function fr_alloc_ok(name: pointer) -> bool {
if str_starts(name, "ludic_act_") { return true } # the action queue's own kept records, grown to its high-water mark
var base = name
var d = 0
while d < len(name) {
if name[d] == '$' { base = name[0..d]; d = len(name) }
d += 1
}
var i = 0
while i < len(g_alloc_ok_names) { if (g_alloc_ok_names[i] == base) or (g_alloc_ok_names[i] == name) { return true }; i += 1 }
return false
}
function fr_mark_root(name: pointer) -> void {
let k = dr_index(name)
if k >= 0 { g_fr_root[k] = true }
}
# `x.tick = fn f`, `tick: fn f` in a record, and the same for an @frame field: f is a root
function fr_find_roots(n: Node) -> void {
if n == null { return }
if n.kind == S_ASSIGN and n.a != null and n.a.kind == E_MEMBER and n.a.s != null and fr_is_frame_field(n.a.s) and n.b != null and n.b.kind == E_FNREF { fr_mark_root(n.b.s) }
if n.kind == E_NEW and n.s != null and n.a != null and n.a.kind == E_REC and n.a.kids != null {
var i = 0
while i < len(n.a.kids) {
let f = n.a.kids[i]
if f.s != null and fr_is_frame_prop_field(n.s, f.s) and f.a != null and f.a.kind == E_FNREF { fr_mark_root(f.a.s) }
i += 1
}
}
fr_find_roots(n.a)
fr_find_roots(n.b)
fr_find_roots(n.c)
if n.kids != null {
var j = 0
while j < len(n.kids) { fr_find_roots(n.kids[j]); j += 1 }
}
}
function fr_is_textish(n: Node) -> bool {
if n == null { return false }
if n.kind == E_STR { return true }
if n.kind == E_CALL and n.a != null and n.a.kind == E_ID and (n.a.s == "string") { return true }
if n.kind == E_BIN and (n.s == "+") { return fr_is_textish(n.a) or fr_is_textish(n.b) }
return false
}
function fr_alloc_kind(n: Node, text_above: bool) -> pointer {
if n.kind == E_NEW and n.b == null { return `new:{n.s}` } # a dispatch's `new` fills the queue's kept record (n.b)
if n.kind == E_LIST { return "list" }
if n.kind == E_BIN and (n.s == "+") and not text_above and fr_is_textish(n) { return "text" }
if n.kind == E_CALL and n.a != null and n.a.kind == E_ID and n.a.s != null {
let c = n.a.s
if (c == "push") {
# a push into a field declared @max(n) grows to n at most, and the fence fails the run past it
if len(n.kids) > 0 and fr_capped(n.kids[0]) { return null }
return "grow"
}
if (c == "words") or (c == "floats") or (c == "doubles") or (c == "buffer") or (c == "bytes") or (c == "fixeds") or (c == "pointers") { return `{c}()` }
}
return null
}
# the allocating constructs in one declaration, each a falloc line with its chain
function fr_allocs(f: pointer, n: Node, k: int, text_above: bool) -> int {
if n == null { return 0 }
if fr_aok_stmt(n) { return 0 }
if g_arena and n.uns == ES_SCRATCH { # the frame's arena takes it: made and gone with the frame
return fr_allocs_kids(f, n, k)
}
var found = 0
let kind = fr_alloc_kind(n, text_above)
if kind != null {
deps_line(f, `falloc {kind} {g_dr_node[k].file}:{itoa(n.line)} {fr_chain(k)}`)
found += 1
}
let text = (n.kind == E_BIN) and (n.s == "+") and fr_is_textish(n)
found += fr_allocs(f, n.a, k, text)
found += fr_allocs(f, n.b, k, text)
found += fr_allocs(f, n.c, k, false)
if n.kids != null {
var i = 0
while i < len(n.kids) { found += fr_allocs(f, n.kids[i], k, false); i += 1 }
}
return found
}
function fr_allocs_kids(f: pointer, n: Node, k: int) -> int {
var found = 0
let text = (n.kind == E_BIN) and (n.s == "+") and fr_is_textish(n)
found += fr_allocs(f, n.a, k, text)
found += fr_allocs(f, n.b, k, text)
found += fr_allocs(f, n.c, k, false)
if n.kids != null {
var i = 0
while i < len(n.kids) { found += fr_allocs(f, n.kids[i], k, false); i += 1 }
}
return found
}
# root > ... > this declaration, by the first way it was reached: the root, then the last six
function fr_chain(k: int) -> pointer {
var s = g_dr_name[k]
var at = g_fr_from[k]
var steps = 0
var root = k
while at >= 0 {
if steps < 6 { s = `{g_dr_name[at]}>{s}` }
root = at
at = g_fr_from[at]
steps += 1
}
if steps > 6 { s = `{g_dr_name[root]}>..>{s}` }
return s
}
function deps_frame(f: pointer) -> void {
if g_arena { escape_analyse() } # with the arena on, what it takes is not counted
let n = len(g_dr_node)
g_fr_root = new []bool
g_fr_from = new []int
let seen = new []bool
var i = 0
while i < n { push(g_fr_root, false); push(g_fr_from, -1); push(seen, false); i += 1 }
i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_SYS and d.s != null and fr_phase_frame(d.ty) { fr_mark_root(d.s) }
# a component's own functions run while its page is open - but not its event handlers (`on click`),
# which run when the player does something; a reducer is reached from its action's dispatch
if d.kind == N_FN and d.s != null and d.cm >= 0 and not fr_is_event_handler(d.s) { fr_mark_root(d.s) }
fr_find_roots(d)
i += 1
}
var j = 0
while j < len(g_onlisten) {
fr_mark_root(`@On {g_onlisten[j].s}#{itoa(j)}`)
j += 1
}
# breadth first from every root, so a chain is the shortest
let queue = new []int
i = 0
while i < n {
if g_fr_root[i] and not fr_alloc_ok(g_dr_name[i]) { seen[i] = true; push(queue, i) }
i += 1
}
var qh = 0
while qh < len(queue) {
let k = queue[qh]
qh += 1
let refs = g_dr_refs[k]
var r = 0
while r < len(refs) {
let t = refs[r]
if not seen[t] and not fr_alloc_ok(g_dr_name[t]) {
seen[t] = true
g_fr_from[t] = k
push(queue, t)
}
r += 1
}
}
var total = 0
i = 0
while i < len(queue) {
let k = queue[i]
let d = g_dr_node[k]
if (d.kind == N_FN or d.kind == N_SYS or d.kind == N_BLOCK) and d.file != null and not is_runtime_file(d.file) { total += fr_allocs(f, d.a, k, false) }
i += 1
}
deps_line(f, `frame_allocs {itoa(total)}`)
deps_keeps(f, seen)
}
var g_arena_strict: bool = false # --arena-strict / `arena strict`: a keep in frame code is an error
# 25.3's region rule: an allocation in frame code that is kept past its frame (stored into a state,
# a queue, a global, an event) is a `fkeep` line - and under strict an error naming the store.
# keep(...) / intern(...) is the copy on purpose; @alloc_ok("why") says it is bounded
function deps_keeps(f: pointer, seen: []bool) -> void {
escape_analyse()
var n = 0
var s = 0
while s < len(g_es_site) {
let c = g_es_site_cls[s]
let fi = g_es_site_fn[s]
if c >= 0 and fi >= 0 and g_es_fnode[fi].s != null and (g_es_flag[c] & ES_ESC) != 0 and not g_es_site_aok[s] and not g_es_site_grow[s] {
let d = g_es_fnode[fi]
let k = dr_index(d.s)
if k >= 0 and seen[k] and not fr_alloc_ok(d.s) and d.file != null and not is_runtime_file(d.file) {
let site = g_es_site[s]
let why = g_es_why[c]
var at = "?"
if why != null and why.file != null { at = `{why.file}:{itoa(why.line)}` }
var kind = fr_alloc_kind(site, false)
if kind == null { kind = "text" }
deps_line(f, `fkeep {kind} {d.file}:{itoa(site.line)} {at} {fr_chain(k)}`)
if g_arena_strict {
let m = `{d.file}:{itoa(site.line)}: error: frame code keeps what it makes: this {kind} is kept at {at} ({fr_chain(k)}) - keep(...) or intern(...) to copy it on purpose, or @alloc_ok("why")\n`
file_write(file_stderr(), m, len(m))
}
n += 1
}
}
s += 1
}
deps_line(f, `frame_keeps {itoa(n)}`)
if g_arena_strict and n > 0 { exit(1) }
deps_births(f, seen)
deps_resources(f) # 25.5e
deps_owned(f)
}
function fr_consumed(n: Node) -> bool {
var i = 0
while i < len(g_es_consumed) { if g_es_consumed[i] == n { return true }; i += 1 }
return false
}
# 25.2's leak-at-birth: an allocation nothing keeps, made where the arena does not take it - outside
# frame code (a boot, a load), in a function spanning frames, or anywhere with the arena off - is
# made and dropped, never given back. `fbirth` lines, and the birth_leaks number
function deps_births(f: pointer, seen: []bool) -> void {
# what boot reaches: the Start handlers and the entry, before any frame
let boot = new []bool
let queue = new []int
var b = 0
while b < len(g_dr_node) { push(boot, false); b += 1 }
b = 0
while b < len(g_dr_node) {
let bd = g_dr_node[b]
if (bd.kind == N_SYS and (bd.ty == "Start")) or bd.kind == N_MAIN {
boot[b] = true
push(queue, b)
}
b += 1
}
var qh = 0
while qh < len(queue) {
let refs = g_dr_refs[queue[qh]]
qh += 1
var r = 0
while r < len(refs) {
if not boot[refs[r]] {
boot[refs[r]] = true
push(queue, refs[r])
}
r += 1
}
}
var n = 0
var s = 0
while s < len(g_es_site) {
let c = g_es_site_cls[s]
let fi = g_es_site_fn[s]
let site = g_es_site[s]
if c >= 0 and fi >= 0 and g_es_fnode[fi].s != null and (g_es_flag[c] & (ES_ESC | ES_FREED)) == 0 and not g_es_site_aok[s] and not g_es_site_grow[s] {
let d = g_es_fnode[fi]
let k = dr_index(d.s)
let inframe = k >= 0 and seen[k]
# a scratch site is the arena's whenever the game's frames are running - a frame's code, a click
# handler, a reducer - and the heap's only before the first frame: boot's code
let scratch = g_arena and site.uns == ES_SCRATCH # boot's too: the arena starts at its first use
if not scratch and d.file != null and not is_runtime_file(d.file) and not fr_alloc_ok(d.s) and not fr_consumed(site) {
var kind = fr_alloc_kind(site, false)
if kind == null { kind = "text" }
var where = "outside-a-frame"
if inframe { where = fr_chain(k) }
deps_line(f, `fbirth {kind} {d.file}:{itoa(site.line)} {d.s} {where}`)
n += 1
}
}
s += 1
}
deps_line(f, `birth_leaks {itoa(n)}`)
}