ludic/selfhost/frontend/parse.ludic
Orkuncakilkaya 8ca18725b6 escape (25.3a): which allocations never outlive their frame - the analysis, behind --escape-report; @alloc_ok on generics
emit_escape.ludic: every value is in a class, joined by flow edges (a let, an assignment, an argument
into its parameter, a result into the call) and store edges (a field, an element, a push). HEAP (a
parameter, a state, a global, what an unknown call hands back) flows forward; ESC (stored into
something HEAP, into a global, into an event's fields or named values, handed to an unknown callee)
flows backward, and from an ESC or HEAP target along a store. A load is its base's class. A site that
is neither ESC nor in a function reaching Mem.frame is LOCAL (Node.uns = ES_SCRATCH). ludicc
--escape-report prints each site and the totals; nothing is emitted differently yet - the arena that
allocates the LOCAL sites is next.

@alloc_ok on a generic now covers its instances (kept_push$NetFact is under kept_push's), and a
statement's @alloc_ok is carried on the node (Node.uns), so a generic's clone keeps it.

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

1646 lines
78 KiB
Text

# parse.ludic — recursive-descent parser: the token slice -> an AST.
# Core grammar: properties, vars/lets, consts, fns, entry; the ECS/ui/match
# constructs live in parse_game.ludic. Uses the global `toks` and a cursor `pi`.
var pi: int = 0
var prog: []Node # the top-level declarations
var g_game_name: pointer # the `program` name
# ---- diagnostics -----------------------------------------------------------
# Every compiler error is `file:line: error: message` — the form editors and
# build tools parse. While parsing, the location is the current token's; while
# lowering, it is the statement being emitted (emit_stmt records it).
var g_parse_file: pointer = "" # the file whose tokens are being parsed
# `numbers float`: files whose bare decimal literals are float, not fixed. A file that says
# so is listed, and so is every non-runtime file it imports (a barrel passes it on).
var g_float_files: []pointer = new []pointer
# L3 modules: `module NAME` at the top of a file names the module it and everything it imports
# belong to, until an import names its own; `friend module NAME` may see every module's private
# names (a test harness). A file in no module - the runtime, a program's root - is public.
# L7: the files that may write `unsafe` - the runtime, a package from the toolchain or
# ludic_modules, and what those import from beside them. A project's own files may only
# with --unsafe (g_unsafe_all).
var g_uses_bytes: bool = false # text_of / Fs.read_bytes / Fs.write_bytes: splice bytes.ludic
var g_trusted_files: []pointer = new []pointer
var g_unsafe_all: bool = false
function unsafe_trusted(f: pointer) -> bool {
if g_unsafe_all { return true }
return not (fm_get(g_fm_trusted, f) == "")
}
# a file's facts - its module, its package, whether it is trusted - asked of every reference the
# checker and the emitter resolve: a table by file name, not a list walked each time (with a game's
# nine hundred files that walk was most of a check-only build)
const FM_SLOTS: int = 16384
property FileMap {
keys: []pointer = null
vals: []pointer = null
}
function fm_new() -> FileMap {
let m = new FileMap
m.keys = new []pointer
m.vals = new []pointer
var i = 0
while i < FM_SLOTS {
push(m.keys, null)
push(m.vals, null)
i += 1
}
return m
}
function fm_slot(m: FileMap, f: pointer) -> int {
var h = 5381
var i = 0
let n = len(f)
while i < n {
h = (h * 33 + f[i]) % 1000003
i += 1
}
h = h % FM_SLOTS
while m.keys[h] != null and not (m.keys[h] == f) { h = (h + 1) % FM_SLOTS }
return h
}
# the latest value set for f wins, as the lists' walk from the end had it
function fm_put(m: FileMap, f: pointer, v: pointer) -> void {
if f == null { return }
let h = fm_slot(m, f)
m.keys[h] = f
m.vals[h] = v
}
function fm_get(m: FileMap, f: pointer) -> pointer {
if f == null { return "" }
let h = fm_slot(m, f)
if m.keys[h] == null { return "" }
return m.vals[h]
}
var g_fm_mod: FileMap = fm_new()
var g_fm_pkg: FileMap = fm_new()
var g_fm_trusted: FileMap = fm_new()
function trust_file(f: pointer) -> void {
push(g_trusted_files, f)
fm_put(g_fm_trusted, f, "1")
}
var g_mod_file: []pointer = new []pointer
var g_mod_name: []pointer = new []pointer
var g_mod_friends: []pointer = new []pointer
function module_of(f: pointer) -> pointer { return fm_get(g_fm_mod, f) }
function module_set(f: pointer, name: pointer) -> void {
push(g_mod_file, f)
push(g_mod_name, name)
fm_put(g_fm_mod, f, name)
}
function is_float_file(f: pointer) -> bool {
if (f == null) { return false }
return not (fm_get(g_fm_float, f) == "")
}
var g_fm_float: FileMap = fm_new()
function float_file_add(f: pointer) -> void {
push(g_float_files, f)
fm_put(g_fm_float, f, "1")
}
var g_parsing: bool = true # false once lowering starts
var g_err_file: pointer = "" # the statement being lowered
var g_err_line: int = 0
function cur() -> Tok { return toks[pi] }
function pk(o: int) -> Tok { return toks[pi + o] }
function is_op(v: pointer) -> bool { let t = toks[pi]; return t.kind == TK_OP and (t.text == v) }
function is_id(v: pointer) -> bool { let t = toks[pi]; return t.kind == TK_ID and (t.text == v) }
function is_kw(v: pointer) -> bool { return is_id(v) }
function perr(msg: pointer) -> void {
var file = g_err_file
var line = g_err_line
if g_parsing {
file = g_parse_file
line = 0
if pi < len(toks) { line = toks[pi].line }
}
if file == null { file = "" }
let m = `{file}:{line}: error: {msg}\n`
file_write(file_stderr(), m, len(m))
exit(1)
}
# the current token, described for a diagnostic
function tok_desc() -> pointer {
let t = toks[pi]
if t.kind == TK_EOF { return "end of file" }
if t.kind == TK_NL { return "end of line" }
if t.kind == TK_INT or t.kind == TK_FLOAT { return `number {itoa(t.ival)}` }
if t.kind == TK_STR { return `string \"{t.text}\"` }
return `'{t.text}'`
}
function eat_op(v: pointer) -> void { if not is_op(v) { perr(`expected '{v}', got {tok_desc()}`) }; pi += 1 }
function eat_id() -> pointer {
let t = toks[pi]
if t.kind != TK_ID { perr(`expected an identifier, got {tok_desc()}`) }
pi += 1
return t.text
}
function skipnl() -> void { while toks[pi].kind == TK_NL { pi += 1 } }
# a type: `[]T` slice, `fn(T, U) -> R` function, or a plain name (int/ptr/str/bool/struct)
# L7: the typed buffers are slices - a length, a bounds check on every index, and a place in the
# checker - so `floats` is `[]float`, `words` is `[]int`, and so on. `bytes()` stays raw.
function buffer_slice_ty(t: pointer) -> pointer {
if (t == "floats") { return "[]float" }
if (t == "words") { return "[]int" }
if (t == "fixeds") { return "[]fixed" }
if (t == "doubles") { return "[]double" }
if (t == "pointers") { return "[]pointer" }
return t
}
function ptype() -> pointer {
if (toks[pi].text == "fn") and (toks[pi + 1].text == "(") {
pi += 1
eat_op("(")
var out = "fn("
var first = true
while not is_op(")") {
if not first { out = out + "," }
out = out + ptype()
first = false
if is_op(",") { pi += 1 }
}
eat_op(")")
var r: pointer = "void"
if is_op("->") {
pi += 1
r = ptype()
}
return out + ")->" + r
}
if is_op("[") {
pi += 1
eat_op("]")
let el = ptype()
let out = bytes(len(el) + 3)
out[0] = '['; out[1] = ']' # "[]"
var i = 0
while el[i] != 0 { out[2 + i] = el[i]; i += 1 }
out[2 + i] = 0
return out
}
let tn = eat_id()
if is_op("<") { return gen_type_args(tn) }
return buffer_slice_ty(tn)
}
# ---- expressions -----------------------------------------------------------
function expr() -> Node { return p_or() }
# Call arguments. Each argument is either positional (`expr`) or named
# (`name: expr`) — a named argument is an identifier immediately followed by a
# colon, which is unambiguous inside a call. Named args are stored as E_FINIT
# (s=label, a=value) and reordered to the callee's parameter order at emit time.
function args_call(call: Node) -> void {
eat_op("("); skipnl()
while not is_op(")") {
let t = toks[pi]
let nx = toks[pi + 1]
if t.kind == TK_ID and nx.kind == TK_OP and (nx.text == ":") {
let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); skipnl(); fi.a = expr(); push(call.kids, fi)
} else {
push(call.kids, expr())
}
skipnl(); if is_op(",") { pi += 1; skipnl() }
}
eat_op(")")
}
# ---- string interpolation --------------------------------------------------
# `text {expr} text` desugars to a `+` chain of string literals and `string(expr)`
# holes, so it reuses the string-concat operator and needs no new runtime.
function interp_lit(buf: pointer, len: int) -> Node { let n = node(E_STR); n.s = buf[0..0 + len]; return n }
function interp_add(acc: Node, part: Node) -> Node {
if acc == null { return part }
return mkbin("+", acc, part)
}
function interp_str(e: Node) -> Node { # wrap a hole in string(...)
let c = node(E_CALL); let id = node(E_ID); id.s = "string"; c.a = id; push(c.kids, e); return c
}
var g_interp_line: int = 1 # the line the interpolated string sits on
var g_interp_pos: int = -1 # the backtick's place in its file
var g_hole_base: int = -1
function parse_hole(inner: pointer) -> Node { # re-lex+parse an embedded expression
let saved_toks = toks; let saved_pi = pi
let saved_base = g_lex_base
g_lex_base = g_hole_base # the hole's place in its file, or -1
lex_at(inner, g_interp_line); pi = 0; skipnl()
g_lex_base = saved_base
let e = expr()
toks = saved_toks; pi = saved_pi
return e
}
function parse_interp(raw: pointer) -> Node {
let my_pos = g_interp_pos # a hole's own templates move g_interp_pos
let n = len(raw)
var acc: Node = null
let lit = bytes(n + 1)
var lj = 0
var i = 0
while i < n {
let c = raw[i]
if c == '{' { # '{'
if raw[i + 1] == '{' { lit[lj] = '{'; lj += 1; i += 2; continue } # {{ -> {
if lj > 0 { acc = interp_add(acc, interp_lit(lit, lj)); lj = 0 }
i += 1
let hs = i
# find the hole's closing brace: braces nest, and a brace inside a "string", a 'char' or a
# `template` in the hole is text, not structure (lex.ludic, hole_end)
i = hole_end(raw, i, n)
g_hole_base = -1
if my_pos >= 0 { g_hole_base = my_pos + 1 + hs }
acc = interp_add(acc, interp_str(parse_hole(raw[hs..i])))
i += 1 # skip the closing '}'
} else {
if c == '}' and raw[i + 1] == '}' { lit[lj] = '}'; lj += 1; i += 2; continue } # }} -> }
if c == CH_BACKSLASH { # backslash escape, same table as "strings"
lit[lj] = unescape(raw[i + 1]); lj += 1; i += 2; continue
}
lit[lj] = c; lj += 1; i += 1
}
}
if lj > 0 or (acc == null) { acc = interp_add(acc, interp_lit(lit, lj)) }
return acc
}
# emit E(field: v, ...) — shared by the statement form and the expression form.
# As an expression it yields a cancellable event's cancelled flag (0/1); a
# non-cancellable event yields 0.
function parse_emit() -> Node {
pi += 1; let n = node(S_EMIT); n.s = eat_id()
let r = node(E_REC)
eat_op("("); skipnl()
while not is_op(")") {
let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); fi.a = expr(); push(r.kids, fi)
skipnl(); if is_op(",") { pi += 1; skipnl() }
}
eat_op(")")
n.a = r
return n
}
# `[a, b, c]` — a slice literal (an empty `[]` needs a type: `new []T`)
function parse_list() -> Node {
let n = node(E_LIST); n.line = toks[pi].line
eat_op("["); skipnl()
while not is_op("]") {
push(n.kids, expr())
skipnl(); if is_op(",") { pi += 1; skipnl() }
}
eat_op("]")
return n
}
function p_primary() -> Node {
let t = toks[pi]
if t.kind == TK_INTERP { pi += 1; g_interp_line = t.line; g_interp_pos = t.pos; return parse_interp(t.text) }
if is_op("[") { return parse_list() }
if t.kind == TK_ID and (t.text == "emit") and (toks[pi + 1].kind == TK_ID) { return parse_emit() }
if t.kind == TK_INT { let n = node(E_INT); n.ival = t.ival; n.s = t.text; pi += 1; return n } # s: a long literal's digits
if t.kind == TK_FLOAT { let n = node(E_FLOAT); n.ival = t.ival; n.s = t.text; pi += 1; return n }
if t.kind == TK_STR { let n = node(E_STR); n.s = t.text; pi += 1; return n }
if t.kind == TK_ID {
if (t.text == "true") { let n = node(E_BOOL); n.ival = 1; pi += 1; return n }
if (t.text == "false") { let n = node(E_BOOL); n.ival = 0; pi += 1; return n }
if (t.text == "null") { pi += 1; return node(E_NULL) }
if (t.text == "new") { pi += 1; let n = node(E_NEW); n.s = ptype(); if is_op("{") { n.a = record() }; return n }
if (t.text == "spawn") { return parse_spawn() } # spawn as an expression: the new entity
# fn name — a top-level function as a value, for a worker entry point (Job.parallel_for)
# `fn` stays an ordinary name before an operator word (`p < fn and ...`) or at the end of a line
if (t.text == "fn") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 1].line == t.line) and (toks[pi + 1].text != "and") and (toks[pi + 1].text != "or") and (toks[pi + 1].text != "not") and (toks[pi + 1].text != "in") and (toks[pi + 1].text != "is") { pi += 1; let n = node(E_FNREF); n.line = t.line; n.s = eat_id(); return n }
# try EXPR else { ... } — evaluate a fallible (result-typed) expression; on
# `ok` the whole expression is its payload, on `err` the else block runs (with
# the message bound to `error`) and its trailing expression is the fallback.
if (t.text == "try") {
pi += 1
let n = node(E_TRY); n.line = t.line
n.a = expr()
skipnl()
if not is_id("else") { perr("try needs an `else { ... }` fallback") }
pi += 1; skipnl()
n.b = block()
return n
}
if (t.text == "text_of") { g_uses_bytes = true }
let n = node(E_ID); n.s = t.text; pi += 1; return n
}
if is_op("(") { pi += 1; skipnl(); let e = expr(); skipnl(); eat_op(")"); return e }
if g_res_mode and is_op("{") { return record() } # L9: a resource's nested record, typed by its field
perr("expected expression")
return node(E_INT)
}
function p_postfix() -> Node {
var e = p_primary()
while true {
if is_op(".") { pi += 1; let m = node(E_MEMBER); m.a = e; m.s = eat_id(); e = m
if e.a.kind == E_ID and e.a.s == "Regex" { g_uses_regex = true } # splice the regex runtime on demand
if e.a.kind == E_ID and e.a.s == "Random" { g_uses_rng = true } # splice the seeded random numbers on demand
if e.a.kind == E_ID and e.a.s == "Fs" and (e.s == "read_bytes" or e.s == "write_bytes") { g_uses_bytes = true }
if e.a.kind == E_ID and (e.a.s == "BigInt" or e.a.s == "Decimal") { g_uses_bignum = true } # splice the bignum runtime on demand
if e.a.kind == E_ID and (e.a.s == "Dict" or e.a.s == "Set") { g_uses_dict = true } # splice the hash-table runtime on demand
if e.a.kind == E_ID and (e.a.s == "Huge" or e.a.s == "Angle" or e.a.s == "Percent") { g_uses_numeric = true } # splice the huge/angle/percent runtime on demand
if e.a.kind == E_ID and (e.a.s == "Job" or e.a.s == "Promise" or e.a.s == "Sync") { g_uses_jobs = true } # splice the jobs/promise/sync runtime on demand
if e.a.kind == E_ID and e.a.s == "Query" { g_uses_query = true } # splice the ECS spatial-query runtime on demand
if e.a.kind == E_ID and e.a.s == "Reflect" { g_uses_reflect = true } # force-emit the reflection ABI (Reflect.* reads the world schema)
if e.a.kind == E_ID and e.a.s == "Light" { g_uses_light = true } # splice the 2D light-accumulation pass on demand
if e.a.kind == E_ID and (e.a.s == "Value" or e.a.s == "Json") { g_uses_value = true } # splice the value tree + JSON on demand (#44)
if e.a.kind == E_ID and e.a.s == "Xml" { g_uses_xml = true } # splice the XML reader on demand (Tiled #67)
if e.a.kind == E_ID and e.a.s == "Base64" { g_uses_base64 = true } # splice base64 codec + inflate on demand (Tiled #67)
if e.a.kind == E_ID and e.a.s == "Tiled" { g_uses_tiled = true } # splice the Tiled map runtime on demand (#69)
# #81 — Sprite.sheet/cell/… or Assets.* splice the spritesheet/atlas runtime
# (it reads the framebuffer + the image loader, so it links against core).
if e.a.kind == E_ID and e.a.s == "Sprite" and (e.s == "sheet" or e.s == "cell" or e.s == "cell_span" or e.s == "define" or e.s == "named" or e.s == "draw" or e.s == "draw_scaled" or e.s == "width" or e.s == "height") { g_uses_atlas = true }
if e.a.kind == E_ID and e.a.s == "Assets" { g_uses_atlas = true }
if e.a.kind == E_ID and e.a.s == "Reflect" and (e.s == "serialize" or e.s == "apply") { g_uses_value = true; g_uses_reflect_io = true } # Reflect.serialize/apply -> value tree + world table
# Input.* action-map / record-replay methods (#7) -> splice input.ludic.
# Input.key stays bare (no runtime), so gate on the new methods only.
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "bind" or e.s == "rebind" or e.s == "poll" or e.s == "down" or e.s == "pressed" or e.s == "record" or e.s == "replay" or e.s == "action" or e.s == "bind_pad" or e.s == "active" or e.s == "just_pressed" or e.s == "just_released") { g_uses_input = true }
# #50 device layer — any of the multi-key / analog / mouse / gamepad / touch
# methods also splices input.ludic (Input.key stays bare, no runtime).
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "key_down" or e.s == "key_pressed" or e.s == "key_released" or e.s == "key_label" or e.s == "press" or e.s == "release" or e.s == "axis" or e.s == "axis_i" or e.s == "vector" or e.s == "strength" or e.s == "mouse_x" or e.s == "mouse_y" or e.s == "mouse_dx" or e.s == "mouse_dy" or e.s == "mouse_down" or e.s == "wheel" or e.s == "set_mouse" or e.s == "pad_connected" or e.s == "pad_button" or e.s == "pad_axis" or e.s == "move_i" or e.s == "set_pad" or e.s == "touch_count" or e.s == "touch_x" or e.s == "touch_y" or e.s == "set_touch" or e.s == "cursor_mode") { g_uses_input = true }
# Anim.play/clip/on_frame/fired + Motion.to (#48): the ergonomic writes over
# the SpriteAnim/Motion components live in systems.ludic and use the world
# table, so splice it and force the reflection ABI even if the game leaves
# the engine auto-advance to do the ticking.
if e.a.kind == E_ID and e.a.s == "Anim" and (e.s == "play" or e.s == "clip" or e.s == "on_frame" or e.s == "fired") { g_uses_anim_rt = true }
if e.a.kind == E_ID and e.a.s == "Motion" and e.s == "to" { g_uses_anim_rt = true }
# Audio.* (#22) — any Audio method splices the audio runtime.
if e.a.kind == E_ID and e.a.s == "Audio" { g_uses_audio = true }
# Http.* (#6) — any Http method splices the HTTP client runtime.
if e.a.kind == E_ID and e.a.s == "Http" { g_uses_http = true }
# Udp.* — any Udp method splices the datagram runtime (and links udp.ll / udp_win.ll).
if e.a.kind == E_ID and e.a.s == "Udp" { g_uses_udp = true }
# Process.* — any Process method splices the child-process runtime (process.ll / process_win.ll).
if e.a.kind == E_ID and e.a.s == "Process" { g_uses_process = true }
# Gl.* — any Gl method splices the OpenGL runtime (and links the GL backend).
if e.a.kind == E_ID and e.a.s == "Gl" { g_uses_gl = true }
# Vk.* — any Vk method splices the Vulkan runtime (and links its loader)
if e.a.kind == E_ID and e.a.s == "Vk" { g_uses_vk = true }
# Tween.to/chain/delay/value/stop/parallel (#48): the fluent stateful handles
# live in tween.ludic, advanced by an engine-owned system each Update tick.
if e.a.kind == E_ID and e.a.s == "Tween" and (e.s == "to" or e.s == "chain" or e.s == "delay" or e.s == "value" or e.s == "stop" or e.s == "parallel") { g_uses_tween_rt = true }
if e.a.kind == E_ID and e.a.s == "Fx" { g_uses_fx = true } # Fx.* -> splice fx.ludic + tick/draw it each frame
}
else if is_op("[") { pi += 1; let lo = expr()
if is_op("..") { pi += 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring
else { let ix = node(E_INDEX); ix.a = e; ix.b = lo; eat_op("]"); e = ix }
}
else if is_op("(") { let c = node(E_CALL); c.a = e; c.line = toks[pi].line; args_call(c); e = c
if (c.a.kind == E_ID) and rt_wanted_name(c.a.s) { push(g_rt_wants, c.a.s) } # a runtime called by its own name
if (c.a.kind == E_ID) and ((c.a.s == "draw_sprite") or (c.a.s == "draw_sprite_scaled")) { warn_draw_sprite(c.a.s) } # #85 deprecate the bare aliases
}
else { break }
}
return e
}
function p_unary() -> Node {
if is_op("-") { pi += 1; let n = node(E_UN); n.s = "-"; n.a = p_unary(); return n }
if is_op("~") { pi += 1; let n = node(E_UN); n.s = "~"; n.a = p_unary(); return n } # bitwise not
if is_id("not") { pi += 1; let n = node(E_UN); n.s = "not"; n.a = p_unary(); return n }
return p_postfix()
}
function mkbin(op: pointer, l: Node, r: Node) -> Node { let b = node(E_BIN); b.s = op; b.a = l; b.b = r; return b }
# precedence (Go-style, so `flags & MASK == 0` needs no parens): shifts and `&`
# bind like `*`; `|` and `^` bind like `+`; both tighter than comparison.
function p_mul() -> Node {
var l = p_unary()
while is_op("*") or is_op("/") or is_op("%") or is_op("<<") or is_op(">>") or is_op("&") {
let op = toks[pi].text; pi += 1; l = mkbin(op, l, p_unary()) }
return l
}
function p_add() -> Node {
var l = p_mul()
while is_op("+") or is_op("-") or is_op("|") or is_op("^") {
let op = toks[pi].text; pi += 1; l = mkbin(op, l, p_mul()) }
return l
}
function p_cmp() -> Node {
var l = p_add()
while is_op("<") or is_op("<=") or is_op(">") or is_op(">=") or is_op("==") or is_op("!=") {
let op = toks[pi].text; pi += 1; l = mkbin(op, l, p_add())
}
return l
}
function p_and() -> Node {
var l = p_cmp()
while is_id("and") { pi += 1; l = mkbin("and", l, p_cmp()) }
return l
}
function p_or() -> Node {
var l = p_and()
while is_id("or") { pi += 1; l = mkbin("or", l, p_and()) }
return l
}
# a record literal `{ field: value, ... }` — used by spawn component inits.
# Rule A: a named part uses `:` (`=` is assignment/binding only).
function record() -> Node {
eat_op("{")
let r = node(E_REC)
while true { skipnl(); if is_op("}") { break }
let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); fi.a = expr(); push(r.kids, fi)
if is_op(",") { pi += 1 } }
eat_op("}"); return r
}
# ---- statements ------------------------------------------------------------
function block() -> Node {
skipnl(); eat_op("{")
let b = node(N_BLOCK)
while true { skipnl(); if is_op("}") { break }
push(b.kids, stmt())
# Rule B: statements are separated by a newline or ';' (both lex to TK_NL).
# After a statement the next token must be that separator or the block's end —
# two statements may not sit adjacent with only spaces between them.
var sep = toks[pi].kind == TK_NL
if is_op("}") { sep = true }
if not sep { perr("expected newline or ';' between statements") }
}
eat_op("}")
return b
}
# parse a statement and stamp it with its source line (the first token's line),
# unless the specific rule already set one. The line drives --coverage and the
# panic/expect file:line messages.
function stmt() -> Node {
var aok = false
if is_op("@") and (toks[pi + 1].text == "alloc_ok") { # 25.2: @alloc_ok("why") before one statement
pi += 2
eat_op("(")
let why = expr()
if why.kind != E_STR or len(why.s) == 0 { perr("@alloc_ok needs its reason: @alloc_ok(\"a memo miss, bounded by MM_CAP\")") }
eat_op(")")
skipnl()
aok = true
}
let ln = toks[pi].line
let n = stmt_body()
if n.line == 0 { n.line = ln }
if aok { n.uns = FR_AOK_STMT; g_aok_seen = true } # on the node, so a generic's clone carries it (gen_clone copies uns)
return n
}
function stmt_body() -> Node {
let t = toks[pi]
if t.kind == TK_ID {
if (t.text == "let") or (t.text == "var") {
var mut = 0; if (t.text == "var") { mut = 1 } # let = immutable, var = mutable
pi += 1; let n = node(S_LET); n.ival = mut; n.line = toks[pi].line; n.s = eat_id()
if is_op(":") { pi += 1; n.ty = ptype() }
if is_op("=") { pi += 1; n.a = expr() }
return n
}
if (t.text == "return") {
pi += 1; let n = node(S_RETURN)
if toks[pi].kind != TK_NL and not is_op("}") { n.a = expr() }
return n
}
if (t.text == "if") {
pi += 1; let n = node(S_IF); n.a = expr(); n.b = block()
let save = pi; skipnl() # peek past newlines for a trailing `else`
if is_id("else") { pi += 1; skipnl()
if is_id("if") { n.c = stmt() } else { n.c = block() } }
else { pi = save } # no else: keep the separator for block()'s Rule-B check
return n
}
if (t.text == "while") { pi += 1; let n = node(S_WHILE); n.a = expr(); n.b = block(); return n }
if (t.text == "for") {
pi += 1
if is_op("(") { return parse_query_for() }
let n = node(S_FOR); n.s = eat_id()
let inkw = eat_id() # 'in'
n.a = expr(); eat_op(".."); n.b = expr(); n.c = block()
return n
}
if (t.text == "spawn") { return parse_spawn() }
if (t.text == "dispatch") and toks[pi + 1].kind == TK_ID { return parse_dispatch() } # 0.R
if (t.text == "machine") {
pi += 1; let n = node(S_MACHINE); n.a = expr(); skipnl(); eat_op("{")
var sidx = 0 # states auto-number by declaration order
while true { skipnl(); if is_op("}") { break }
let stkw = eat_id() # 'state'
let s = node(S_STATE); s.s = eat_id()
if is_op("=") { pi += 1; s.b = expr() } # explicit value (still allowed)
s.ival = sidx # else: the enum variant of the same name
# when the store is enum-typed, or this ordinal
skipnl(); s.a = block()
push(n.kids, s); sidx += 1 }
eat_op("}"); return n
}
# `emit E(...)` fires an event, but a bare `emit(...)` is an ordinary call
# (the compiler dogfoods a function named `emit`), so require an event name.
if (t.text == "emit") and (toks[pi + 1].kind == TK_ID) { return parse_emit() }
if (t.text == "become") { pi += 1; let n = node(S_BECOME); n.s = eat_id(); return n }
if (t.text == "despawn") { pi += 1; let n = node(S_DESPAWN); n.a = expr(); return n }
if (t.text == "enable") or (t.text == "disable") {
var en = 0; if (t.text == "enable") { en = 1 }
pi += 1; let n = node(S_TOGGLE); n.ival = en
if is_id("layer") { pi += 1; n.ty = "layer"; n.s = eat_id(); note_toggled_layer(n.s); return n } # enable/disable layer L
if is_id("system") { pi += 1; n.ty = "system"; n.s = eat_id(); if en == 0 { push(g_disabled_sys, n.s) }; return n } # disable system <esys_fn> (lever 5, compile-time)
n.s = eat_id() # `enable P on e` / `disable Model` / `disable Handler`
if is_id("on") { pi += 1; n.a = expr() } # property on an entity
return n
}
if (t.text == "attach") { # attach P on e [{ field: val, ... }]
pi += 1; let n = node(S_ATTACH); n.s = eat_id()
if not is_id("on") { perr("attach needs 'on <entity>'") }
pi += 1; n.a = expr()
if is_op("{") { n.b = record() } # optional field overrides (same-line)
return n
}
if (t.text == "detach") { # detach P on e
pi += 1; let n = node(S_DETACH); n.s = eat_id()
if not is_id("on") { perr("detach needs 'on <entity>'") }
pi += 1; n.a = expr()
return n
}
if (t.text == "unsafe") and (toks[pi + 1].text == "{") { # L7: raw memory allowed inside
let un = node(S_UNSAFE)
pi += 1
un.a = block()
return un
}
if (t.text == "break") { pi += 1; return node(S_BREAK) }
if (t.text == "continue") { pi += 1; return node(S_CONTINUE) }
if (t.text == "cancel") { pi += 1; return node(S_CANCEL) } # veto a cancellable event
if (t.text == "match") {
pi += 1; let n = node(S_MATCH); n.a = expr(); skipnl(); eat_op("{")
while true {
skipnl(); if is_op("}") { break }
let arm = node(S_MARM)
while true { push(arm.kids, expr()); if is_op(",") { pi += 1; skipnl(); continue }; break }
eat_op("=>"); skipnl()
if is_op("{") { arm.a = block() } else { let b = node(N_BLOCK); push(b.kids, stmt()); arm.a = b }
push(n.kids, arm)
}
eat_op("}"); return n
}
}
let e = expr()
if is_op("=") or is_op("+=") or is_op("-=") or is_op("*=") or is_op("/=") {
let n = node(S_ASSIGN); n.line = toks[pi].line; n.s = toks[pi].text; pi += 1; n.a = e; n.b = expr(); return n
}
let n = node(S_EXPR); n.a = e; return n
}
# ---- declarations ----------------------------------------------------------
function parse_var() -> Node {
pi += 1; let n = node(N_VAR); n.s = eat_id(); eat_op(":"); n.ty = ptype()
if is_op("=") { pi += 1; n.a = expr() }
n.pos2 = toks[pi - 1].end # 0.S: where the declaration ends
return n
}
function parse_const() -> Node {
pi += 1; let n = node(N_CONST); n.s = eat_id(); eat_op(":"); n.ty = ptype(); eat_op("="); n.a = expr()
return n
}
# the language's statement / declaration words cannot name a function (`new`,
# `match`, `spawn` ...). Contextual words the parser only recognises in position
# (`emit`, `on`, `phase`, `start`, `export`, ...) stay usable — the compiler itself
# has a function called `emit`.
function is_reserved_word(w: pointer) -> bool {
if (w == "new") or (w == "match") or (w == "machine") or (w == "become") or (w == "spawn") or (w == "despawn") { return true }
if (w == "query") or (w == "cancel") or (w == "scene") or (w == "layer") or (w == "handler") or (w == "property") { return true }
if (w == "model") or (w == "enum") or (w == "event") or (w == "namespace") or (w == "function") or (w == "let") { return true }
if (w == "prefab") { return true }
if (w == "var") or (w == "const") or (w == "if") or (w == "else") or (w == "while") or (w == "for") or (w == "return") { return true }
if (w == "import") or (w == "program") or (w == "entry") or (w == "ui") or (w == "break") or (w == "continue") { return true }
if (w == "and") or (w == "or") or (w == "not") or (w == "true") or (w == "false") or (w == "null") { return true }
return false
}
function parse_fn() -> Node {
pi += 1; let n = node(N_FN); n.s = eat_id()
if is_op("<") { n.tps = gen_params() } # L5: function first<T>(xs: []T) -> T
gen_enter(n.tps)
n.pos2 = toks[pi].pos # 0.S: the parameter list's '('
eat_op("(")
if is_reserved_word(n.s) { perr(`'{n.s}' is a reserved word and cannot name a function`) }
while not is_op(")") {
let p = node(N_PARAM); p.s = eat_id(); eat_op(":"); parse_param_mut(p); p.ty = ptype()
if is_op("=") { pi += 1; p.a = expr() } # L11: a default, for a call that leaves it out
push(n.kids, p)
if is_op(",") { pi += 1 }
}
eat_op(")")
n.ty = "void"
if is_op("->") { pi += 1; n.ty = ptype() }
n.a = block()
gen_enter(null)
return n
}
function parse_main() -> Node {
pi += 1
let n = node(N_MAIN)
let ps = parse_entry_params() # 0.S: entry (h: mut Hiker) - state.ludic
n.a = block()
entry_bind(n.a, ps)
return n
}
# issue #76 — rewrite calls to a namespace-block sibling (a bare short-name call)
# to the prefixed function name, so a body can call `pending()` where the function
# is emitted as `combat_pending`. Only E_CALL callees that are bare identifiers are
# rewritten (Ludic has no function values, and Name.method calls are E_MEMBER).
function ns_short_in(names: []pointer, s: pointer) -> bool {
var i = 0
while i < len(names) { if (names[i] == s) { return true }; i += 1 }
return false
}
function ns_rewrite_calls(n: Node, shorts: []pointer, prefix: pointer) -> void {
if (n == null) { return }
if (n.kind == E_CALL) and (n.a != null) and (n.a.kind == E_ID) {
if ns_short_in(shorts, n.a.s) { n.a.s = prefix + n.a.s }
}
ns_rewrite_calls(n.a, shorts, prefix)
ns_rewrite_calls(n.b, shorts, prefix)
ns_rewrite_calls(n.c, shorts, prefix)
var i = 0
while i < len(n.kids) { ns_rewrite_calls(n.kids[i], shorts, prefix); i += 1 }
}
# issue #76 — `namespace Name { [export|internal] function short(…) … }`. Declares
# the namespace once; each function is emitted as `<namelower>_short` and, when
# exported (the default; `internal` opts out), registered so `Name.short(…)`
# dispatches to it — the same generic path @Namespace(Name) uses, so it is the
# block sugar for it. Sibling calls inside the block are rewritten to the prefixed
# name. `internal` keeps a function as a private helper: it is emitted but not part
# of the Name.* surface.
function parse_namespace() -> void {
pi += 1 # eat "namespace"
let nsname = eat_id()
eat_op("{"); skipnl()
let fns = new []Node
let shorts = new []pointer
let exps = new []int
while not is_op("}") {
var is_exp = 1 # default: exported (public)
if is_id("export") { pi += 1 }
else { if is_id("internal") { pi += 1; is_exp = 0 } }
if is_id("alias") {
ns_parse_alias(nsname, is_exp)
skipnl()
continue
}
if not is_id("function") { perr("a namespace body holds functions or aliases: expected `function` or `alias`") }
let f = parse_fn()
push(fns, f); push(shorts, f.s); push(exps, is_exp)
skipnl()
}
eat_op("}")
let prefix = ns_lower(nsname) + ("_")
var i = 0 # prefix every function name
while i < len(fns) { fns[i].s = prefix + shorts[i]; i += 1 }
i = 0 # rewrite sibling calls to the prefixed name
while i < len(fns) { ns_rewrite_calls(fns[i].a, shorts, prefix); i += 1 }
register_namespace(nsname) # Name.method dispatch (generic path)
push(g_ns_blocks, nsname)
i = 0
while i < len(fns) {
if (exps[i] == 1) { push(g_ns_exports, fns[i].s) } # record the public surface
push(prog, fns[i])
i += 1
}
}
# Is the compiler itself running on Windows? A property of the HOST, asked at run
# time - every Windows process inherits OS=Windows_NT - so one compiler source
# answers correctly whichever platform its binary was built for.
function host_is_windows() -> bool {
let os = getenv("OS")
if (os == null) { return false }
return os == "Windows_NT"
}
# a path separator: '/' everywhere, '\' too on Windows (argv[0] and %PATH% use it)
function is_sep(c: int) -> bool { return (c == '/') or (c == 92) }
# a copy of `s` with every '\' as '/', which Windows accepts wherever it takes a path
function fwd_slashes(s: pointer) -> pointer {
let n = len(s)
let out = bytes(n + 1)
var i = 0
while i < n { if s[i] == 92 { out[i] = '/' } else { out[i] = s[i] }; i += 1 }
out[n] = 0
return out
}
# directory part of a path, including the trailing separator, or "" if none
function dir_of(path: pointer) -> pointer {
var last = -1
var i = 0
while path[i] != 0 { if is_sep(path[i]) { last = i }; i += 1 }
if last < 0 { return "" }
return path[0..last + 1]
}
function join_path(dir: pointer, rel: pointer) -> pointer {
if is_sep(rel[0]) { return rel } # absolute
if rel[0] != 0 { if rel[1] == ':' { return rel } } # absolute: a Windows drive
return dir + rel
}
# The toolchain install root, with a trailing '/' ("" meaning the current
# directory). Everything the toolchain owns rather than the project — the engine
# runtime under runtime/native, the bundled ludic.* packages, the VERSION file,
# cocoa.ll — is resolved against it.
#
# $LUDIC_HOME wins when it is set. Otherwise it is derived from the compiler
# binary, whose directory is `bin` in both shapes that exist: an install
# (~/.ludic/bin/ludicc, root ~/.ludic) and a repo checkout (bin/ludicc, root the
# repo). So a directory named bin means the root is its parent; anything else is
# taken as the root itself, which is what a loose binary beside its runtime
# wants. Invoked through $PATH argv[0] carries no directory at all, so the PATH
# entries are searched for the binary first — an installed `ludicc mygame.ludic`
# has to find its own runtime with nothing in the environment.
#
# On Windows argv[0] and $PATH carry backslashes, $PATH is split on ';', and a
# binary found through it is `ludicc.exe` while argv[0] may say `ludicc`, so the
# directory is read with forward slashes and the lookup tries both names.
function ludic_home() -> pointer {
let env = getenv("LUDIC_HOME")
if (env != null) { return ensure_slash(fwd_slashes(env)) }
var d = fwd_slashes(dir_of(arg(0)))
if (len(d) == 0) { d = path_lookup_dir(base_name(arg(0))) }
if (len(d) == 0) { return "" }
if (len(d) >= 4) and (d[len(d) - 4..len(d)] == "bin/") { return d[0..len(d) - 4] }
return d
}
# the first $PATH entry holding an openable file called `name` (or `name`.exe on
# Windows), with a trailing '/' — "" when $PATH is unset or nothing matches.
function path_lookup_dir(name: pointer) -> pointer {
let p = getenv("PATH")
if (p == null) { return "" }
let win = host_is_windows()
var sep = ':'
if win { sep = ';' }
let n = len(p)
var start = 0
var i = 0
while i <= n {
if (i == n) or (p[i] == sep) {
if i > start {
let dir = ensure_slash(fwd_slashes(p[start..i]))
let f = file_open(dir + name, "rb")
if (f != null) { file_close(f); return dir }
if win {
let fx = file_open(dir + name + ".exe", "rb")
if (fx != null) { file_close(fx); return dir }
}
}
start = i + 1
}
i += 1
}
return ""
}
var loaded_paths: []pointer
var cur_dir: pointer
var g_uses_regex: bool = false # a program mentioned Regex.* -> splice the regex runtime
var g_uses_bignum: bool = false # a program mentioned BigInt.*/Decimal.* -> splice the bignum runtime
var g_uses_dict: bool = false # a program mentioned Dict.*/Set.* -> splice the hash-table runtime
var g_uses_numeric: bool = false # a program mentioned Huge.*/Angle.*/Percent.* -> splice the numeric runtime
var g_uses_jobs: bool = false # a program mentioned Job.*/Promise.*/Sync.* -> splice the concurrency runtime
var g_uses_query: bool = false # a program mentioned Query.* -> splice the query runtime + reflection ABI
var g_uses_reflect: bool = false # a program mentioned Reflect.* -> force-emit the reflection ABI
var g_uses_light: bool = false # a program mentioned Light.* -> splice the 2D light pass
var g_uses_value: bool = false # Value.*/Json.*/Reflect.serialize -> splice the value tree + JSON (#44)
var g_uses_rng: bool = false # Random.* -> splice the seeded random numbers (runtime/native/rng.ludic)
var g_rt_wants: []pointer = new []pointer # bare names a spliced runtime answers (rng_*, seed, value_*, json_*)
var g_uses_xml: bool = false # Xml.* -> splice the XML reader (Tiled #67)
var g_uses_base64: bool = false # Base64.* -> splice the base64 codec + inflate (Tiled #67)
var g_uses_tiled: bool = false # Tiled.* -> splice the Tiled map runtime (#69)
var g_uses_atlas: bool = false # Sprite.sheet/cell/… or Assets.* -> splice the spritesheet/atlas runtime (#81)
var g_uses_reflect_io: bool = false # Reflect.serialize/apply -> splice the reflection serializer
var g_uses_esys: bool = false # an engine-owned system component (SpriteAnim/Motion/Light2D) is declared -> splice systems.ludic + force the reflection ABI
var g_uses_input: bool = false # a program used Input.bind/down/poll/… (action maps + record/replay) -> splice input.ludic
var g_has_clear_color: bool = false # @ClearColor(colour) declared -> the Render phase auto-clears + auto-presents (#86)
var g_clear_color: Node = null # the declared clear colour: a literal, a const name or a Color.Name
var g_warned_draw_sprite: bool = false # emit the bare-draw_sprite deprecation note once (#85)
var g_uses_world_despawn: bool = false # World.despawn / esys_bounds kill -> emit @fn_world_despawn (#84)
# #85 — steer a direct bare draw_sprite / draw_sprite_scaled call to the namespaced
# Screen.sprite (or the engine sprite-render system). Warns once, non-fatally — the
# bare form still compiles (Screen.sprite lowers to it), it is just deprecated.
function warn_draw_sprite(name: pointer) -> void {
if g_warned_draw_sprite { return }
g_warned_draw_sprite = true
let m = `ludicc: warning: bare {name}(...) is deprecated (#85) — use Screen.sprite / Screen.sprite_scaled, or declare a Sprite component for the engine sprite-render system\n`
file_write(file_stderr(), m, len(m))
}
var g_uses_anim_rt: bool = false # Anim.play/clip/on_frame/fired or Motion.to (#48) -> splice systems.ludic + force the reflection ABI
var g_uses_tween_rt: bool = false # Tween.to/chain/delay/… (#48) -> splice tween.ludic + run esys_tween each Update
var g_uses_fx: bool = false # Fx.sparks/number/clear -> splice fx.ludic; fx_tick each Update, fx_draw each Render
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_udp: bool = false # Udp.* -> splice udp.ludic; links udp.ll (macOS) / udp_win.ll + ws2_32
var g_uses_process: bool = false # Process.* -> splice process.ludic; links process.ll (macOS) / process_win.ll
var g_uses_gl: bool = false # Gl.* -> splice gl.ludic (+ generated gl_api.ludic); links gl.ll + gl_thunks.ll + OpenGL
var g_uses_vk: bool = false # Vk.* -> splice vk.ludic (+ generated vk_api.ludic); links vk_thunks.ll + the platform loader
# 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
# 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
# issue #76: `namespace Name { export/internal function … }` block form. A block
# declares the namespace once and controls its public surface declaratively.
# g_ns_blocks names the namespaces declared this way; g_ns_exports holds the
# prefixed function names that are *exported* (e.g. "combat_amount"), so emit_ns_call
# can reject Name.method for an `internal` helper. (A namespace declared the old
# per-function @Namespace(Name) way has no block entry, so all its methods stay
# dispatchable — back-compatible.)
var g_ns_blocks: []pointer
var g_ns_exports: []pointer
function is_ns_block(name: pointer) -> bool {
var i = 0
while i < len(g_ns_blocks) { if (g_ns_blocks[i] == name) { return true }; i += 1 }
return false
}
function ns_export_has(prefixed: pointer) -> bool {
var i = 0
while i < len(g_ns_exports) { if (g_ns_exports[i] == prefixed) { return true }; i += 1 }
return false
}
# lever 5 of the controller extensibility contract (#57): `disable system <fn>`
# switches off exactly one engine-owned system (an esys_* registered via
# @EngineSystem or the core seed) at compile time, so a game can carry a
# well-known component but tick it with its own handler instead. Recorded at
# parse time and read by emit_one_engine_system, which then skips that call
# (byte-identical for any program that disables nothing).
var g_disabled_sys: []pointer
function is_system_disabled(fn: pointer) -> bool {
var i = 0
while i < len(g_disabled_sys) { if (g_disabled_sys[i] == fn) { return true }; i += 1 }
return false
}
# #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 += 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 += 1 }
return false
}
function already_loaded(full: pointer) -> bool {
var i = 0
while i < len(loaded_paths) { if (loaded_paths[i] == full) { return true }; i += 1 }
return false
}
# parse one top-level declaration (or resolve an import) into `prog`.
# Modifiers are `@annotations` in front of the declaration: `@export`, `@edge`,
# `@pure`, `@deterministic`, … — one channel, not a zoo of prefix keywords.
function parse_one_decl() -> void {
var is_export = false
var qspec: Node = null
var onspawn_model: pointer = null
var ondespawn_model: pointer = null
var ondespawn_reason: pointer = null # @OnDespawn(M, reason: r) — LC1 teardown reason binding
var onattach_prop: pointer = null
var ondetach_prop: pointer = null
var onenable_prop: pointer = null
var ondisable_prop: pointer = null
var on_event: pointer = null # @On(Event) — a compile-time event listener
var is_public = false # @Public — promote a lifecycle hook to an event
var hook_phase: pointer = null # @OnStart / @OnQuit override the phase
var is_sync_prop = false # @Sync property P — every field replicates (NETWORKING N2)
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 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)
var is_det = false # @deterministic — no floating point inside (emit_float.ludic)
while is_op("@") {
pi += 1; let a = eat_id() # collect a leading @annotation
if a == "export" { is_export = true }
else if a == "Public" { is_public = true } # @Public hook promotion
else if a == "On" { eat_op("("); on_event = eat_id(); eat_op(")") } # @On(Event) listener
else if a == "Queries" { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...)
else if a == "OnSpawn" { eat_op("("); onspawn_model = eat_id(); eat_op(")") }
else if a == "OnDespawn" {
eat_op("("); ondespawn_model = eat_id()
if is_op(",") { pi += 1; eat_id(); eat_op(":"); ondespawn_reason = eat_id() } # , reason: r
eat_op(")")
}
else if a == "OnAttach" { eat_op("("); onattach_prop = eat_id(); eat_op(")") }
else if a == "OnDetach" { eat_op("("); ondetach_prop = eat_id(); eat_op(")") }
else if a == "OnEnable" { eat_op("("); onenable_prop = eat_id(); eat_op(")") }
else if a == "OnDisable" { eat_op("("); ondisable_prop = eat_id(); eat_op(")") }
else if a == "OnStart" { hook_phase = "Start" } # boot
else if a == "OnQuit" { hook_phase = "OnQuit" } # shutdown
else if a == "Sync" { is_sync_prop = true } # @Sync property (N2)
else if a == "Owned" { is_owned = true } # @Owned model (N3)
else if a == "Server" { role = "server" } # @Server handler (N5)
else if a == "Predicted" { role = "predicted" } # @Predicted handler (N5)
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 += 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 a == "deterministic" { is_det = true }
else if a == "alloc_ok" { # 25.2: @alloc_ok("why") - this allocates in a frame on purpose
eat_op("(")
let why = expr()
if why.kind != E_STR or len(why.s) == 0 { perr("@alloc_ok needs its reason: @alloc_ok(\"a memo miss, bounded by MM_CAP\")") }
eat_op(")")
g_aok_pending = true # a global: `export` parses the declaration one call down
}
else if a == "ClearColor" { # @ClearColor(colour) — Render auto-clear + auto-present (#86)
eat_op("(")
g_clear_color = expr() # a literal, a `const`, or a Color.Name — resolved when emitted
g_has_clear_color = true
eat_op(")")
}
else if is_op("(") { # any other @anno(args) — parsed and skipped
var d = 0
while true { if is_op("(") { d += 1 }; if is_op(")") { d -= 1 }; pi += 1; if d == 0 { break } }
}
skipnl()
}
if is_id("friend") and (toks[pi + 1].text == "module") {
mod_parse_friend() # frontend/modules.ludic
return
}
if is_id("module") and toks[pi + 1].kind == TK_ID {
mod_parse_line() # module NAME [uses A, B]
return
}
# `export function f`, `export var v`, ...: visible from other modules (L3)
if is_id("export") and (toks[pi + 1].kind == TK_ID) {
pi += 1
let p0 = len(prog)
let e0 = len(g_events)
parse_one_decl()
var k = p0
while k < len(prog) {
prog[k].vis = 1
k += 1
}
k = e0
while k < len(g_events) {
g_events[k].vis = 1
k += 1
}
return
}
if is_id("numbers") and (toks[pi + 1].text == "float") {
pi += 2
if not is_float_file(g_parse_file) { float_file_add(g_parse_file) }
return
}
if is_id("import") { pi += 1
let t = toks[pi]
if t.kind != TK_STR { perr("expected \"path\" after import") }
let rel = t.text; pi += 1
do_import(rel)
return
}
if is_id("enum") { push(prog, parse_enum()); return }
if is_id("event") {
let ev = parse_event()
if (remote_dir != null) { ev.ty = remote_dir } # N4: a directional remote event (RPC)
register_event(ev); return
}
if is_id("property") {
let c = parse_component()
if is_sync_prop { var fi = 0; while fi < len(c.kids) { c.kids[fi].ival = 1; fi += 1 } } # N2: mark every field replicable
push(prog, c); return
}
if is_id("model") {
let m = parse_archetype()
if is_owned { m.ival = 1 } # N3: this model's entities carry a network owner
push(prog, m); return
}
if is_id("scene") { parse_scene(); return } # layers push handlers into prog; scene -> g_scenes
if is_id("prefab") { push(prog, parse_prefab()); return } # a model with preset component fields
if is_id("handler") {
let h = parse_system()
if is_det { push(g_det_names, h.s) }
if g_aok_pending { push(g_alloc_ok_names, h.s); g_aok_pending = false }
if (role != null) { if (role == "server") { h.ival = 1 } else { h.ival = 2 } } # N5: @Server=1 / @Predicted=2
if (on_event != null) { register_onlisten(on_event, h.a); return } # @On(Event) listener
if (onspawn_model != null) {
register_onspawn(onspawn_model, h.a) # spawn hook
if is_public { ensure_event(`model_{onspawn_model}_spawn`, false) } # @Public -> model_<M>_spawn
return
}
if (ondespawn_model != null) {
register_ondespawn(ondespawn_model, h.a, ondespawn_reason) # despawn hook
if is_public { ensure_event(`model_{ondespawn_model}_despawn`, true) } # @Public -> model_<M>_despawn (with reason)
return
}
if (onattach_prop != null) { register_onattach(onattach_prop, h.a); if is_public { ensure_event(`prop_{onattach_prop}_attach`, false) }; return } # -> prop_<P>_attach
if (ondetach_prop != null) { register_ondetach(ondetach_prop, h.a); if is_public { ensure_event(`prop_{ondetach_prop}_detach`, false) }; return } # -> prop_<P>_detach
if (onenable_prop != null) { register_onenable(onenable_prop, h.a); if is_public { ensure_event(`prop_{onenable_prop}_enable`, false) }; return } # -> prop_<P>_enable
if (ondisable_prop != null) { register_ondisable(ondisable_prop, h.a); if is_public { ensure_event(`prop_{ondisable_prop}_disable`, false) }; return } # -> prop_<P>_disable
if (hook_phase != null) { # @OnStart/@OnQuit
h.ty = hook_phase
if is_public { # -> program_start / program_quit
if (hook_phase == "Start") { ensure_event_empty("program_start") }
else { ensure_event_empty("program_quit") }
}
}
if (qspec != null) { # @Queries wraps the body in its S_QUERY
qspec.a = h.a
let wrap = node(N_BLOCK); push(wrap.kids, qspec); h.a = wrap
}
push(prog, h); return
}
if is_id("ui") { push(prog, parse_ui()); return }
if is_id("namespace") { parse_namespace(); return } # #76 namespace block
if is_id("port") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_port(); return } # L3 ports
if is_id("bind") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_bind(); return }
if is_id("open") and (toks[pi + 1].text == "registry") { # L8: open to other modules' defs
pi += 1
parse_registry()
g_rg_open[len(g_rg_open) - 1] = 1
return
}
if is_id("registry") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "of") { parse_registry(); return } # L8
if is_id("def") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].kind == TK_ID) { parse_def(); return } # L8
if is_id("view") and (toks[pi + 1].kind == TK_ID) and ((toks[pi + 2].text == "{") or (toks[pi + 2].text == "(")) { parse_view(); return } # L11
if is_id("component") and (toks[pi + 1].kind == TK_ID) and ((toks[pi + 2].text == "{") or (toks[pi + 2].text == "(")) { parse_ui_component(); return } # L11
if is_id("state") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_state(); return } # 0.S
if is_id("action") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_action(); return } # 0.R
if is_id("reducer") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "on") { parse_reducer(); return }
if is_id("var") {
if not g_allow_globals {
perr(`a module-level var is refused: a module's changing data is its state (state Name {{ ... }}), passed to the functions that use it - or, if it never changes, a let`)
}
push(prog, parse_var())
return
}
if is_id("let") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == ":") { # 0.S: a module-level let - immutable all the way down
let v = parse_var()
v.uns = 1
if v.a == null { perr(`a module-level let {v.s} needs its value`) }
push(prog, v)
return
}
if is_id("const") { push(prog, parse_const()); return }
var is_unsafe = false
if is_id("unsafe") and (toks[pi + 1].text == "function") { # L7: an unsafe function
pi += 1
is_unsafe = true
}
if is_id("function") {
let f = parse_fn()
if is_unsafe { f.uns = 1 }
if is_det { push(g_det_names, f.s) }
if g_aok_pending { push(g_alloc_ok_names, f.s); g_aok_pending = false }
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 }
if is_id("entry") { push(prog, parse_main()); return }
if is_id("test") { push(g_tests, parse_test()); return }
perr(`expected a declaration (property, model, handler, function, …), got {tok_desc()}`)
}
# test "name" { ... } — a named test block, collected into g_tests. The block
# runs under a synthetic runner main (see emit_test_runner); `expect` assertions
# inside it record failures. Kept out of `prog` so it never emits as a plain fn.
function parse_test() -> Node {
let ln = toks[pi].line
pi += 1 # past `test`
if toks[pi].kind != TK_STR { perr("expected a \"name\" string after test") }
let n = node(N_TEST)
n.s = toks[pi].text
n.line = ln
n.pos2 = toks[pi].end # 0.S: where state parameters would go
pi += 1 # past the name
let ps = parse_entry_params() # 0.S: test "name" (h: mut Hiker)
n.a = block()
entry_bind(n.a, ps)
return n
}
# lex and parse an imported fragment into `prog`, saving/restoring lexer state.
#
# An import is resolved first relative to the importing file (the historical
# behaviour). When that fails and the spec is not absolute, it is looked up
# under the package module root — $LUDIC_MODULES, default "ludic_modules" — so a
# fetched source package resolves by its import path, e.g.
# import "git.workshopsoft.io/user/pkg/foo.ludic"
# materialised by `ludic get` at ludic_modules/git.workshopsoft.io/user/pkg/foo.ludic.
# The fallback only fires when the local path does not exist, so every existing
# import resolves exactly as before (the emitted IR is byte-identical).
# true when `rel` is an engine-runtime splice path ("runtime/..."), as opposed to
# a user or package import. Those ship with the toolchain, not the project (#75).
function is_runtime_path(rel: pointer) -> bool { return str_starts(rel, "runtime/") }
# byte-wise `a < b`, for a stable import order of a directory's files
function str_less(a: pointer, b: pointer) -> bool {
var i = 0
while true {
let ca = a[i]; let cb = b[i]
if ca != cb { return ca < cb }
if ca == 0 { return false }
i += 1
}
return false
}
# `import "dir/*.ludic"` — every .ludic file of the directory, in name order, so
# a game lists its modules once instead of one import per file.
function do_import_dir(rel: pointer) -> void {
let dir = rel[0..len(rel) - 8] # strip "/*.ludic"
let names = Fs.list(join_path(cur_dir, dir))
if (names == null) { perr(`import: no directory {dir}`) }
var i = 0
while i < len(names) { # selection sort by name
var best = i
var j = i + 1
while j < len(names) { if str_less(names[j], names[best]) { best = j }; j += 1 }
let t = names[i]; names[i] = names[best]; names[best] = t
i += 1
}
i = 0
while i < len(names) {
let nm = names[i]
let n = len(nm)
if (n > 6) and (nm[n - 6..n] == ".ludic") { do_import(dir + ("/") + nm) }
i += 1
}
}
# `import "camp"` names a directory: its index.ludic is the barrel that lists
# what the directory exports (its own imports, relative to itself).
function barrel_of(rel: pointer) -> pointer {
let n = len(rel)
if (n > 6) and (rel[n - 6..n] == ".ludic") { return "" }
if rel[n - 1] == '/' { return rel + "index.ludic" }
return rel + "/index.ludic"
}
function do_import(rel: pointer) -> void {
if (len(rel) > 8) and (rel[len(rel) - 8..len(rel)] == "/*.ludic") { do_import_dir(rel); return }
let barrel = barrel_of(rel)
if barrel != "" {
if (read_file(join_path(cur_dir, barrel)) == null) and Fs.is_dir(join_path(cur_dir, rel)) {
perr(`import: {rel} is a directory with no index.ludic (add {barrel} listing its imports)`)
}
do_import(barrel)
return
}
var full = join_path(cur_dir, rel)
var src = read_file(full)
# #75 — the engine runtime (runtime/native/*) ships with the toolchain, not the
# project. When an auto-spliced runtime import is not found relative to the build
# (an external game whose CWD is not the toolchain repo), resolve it from the
# install root $LUDIC_HOME (default: the compiler binary's directory) — the same
# place main.ludic finds cocoa.ll / audio.ll. This is tried before the package
# module root, so $LUDIC_MODULES / ludic_modules holds only third-party packages.
if (src == null) and (rel[0] != '/') and is_runtime_path(rel) {
let hc = ludic_home() + rel
let hs = read_file(hc)
if (hs != null) { full = hc; src = hs }
}
if (src == null) and (rel[0] != '/') {
let cand = ensure_slash(getenv_or("LUDIC_MODULES", "ludic_modules")) + rel
let s2 = read_file(cand)
if (s2 != null) { full = cand; src = s2 }
}
# The ludic.* packages ship with the toolchain too, so an installed compiler
# resolves `import "ludic.core/components.ludic"` from $LUDIC_HOME/packages
# with no ludic_modules/ to set up. It is the last candidate on purpose: a
# project that fetched its own copy of a package (ludic_modules, above) keeps
# the version it pinned.
if (src == null) and (rel[0] != '/') {
let pc = ludic_home() + "packages/" + rel
let ps = read_file(pc)
if (ps != null) { full = pc; src = ps }
}
if already_loaded(full) { return }
push(loaded_paths, full)
if is_float_file(g_parse_file) and not is_runtime_path(rel) and not is_float_file(full) { float_file_add(full) }
# a module reaches as far as its own files: a package found through $LUDIC_HOME or
# ludic_modules is not beside its importer and keeps its own module (or none)
let beside = full == join_path(cur_dir, rel)
if is_runtime_path(rel) or not beside or (beside and unsafe_trusted(g_parse_file)) { trust_file(full) }
if beside and not is_runtime_path(rel) and not (module_of(g_parse_file) == "") { module_set(full, module_of(g_parse_file)) }
if not beside and not is_runtime_path(rel) { pkg_file_set(full, pkg_name_of(rel)) } # modules.ludic
if not beside and not is_runtime_path(rel) and (src != null) { native_note_pkg(full, rel) } # natives.ludic
if beside and not is_runtime_path(rel) and not (pkg_of_file(g_parse_file) == "") { pkg_file_set(full, pkg_of_file(g_parse_file)) }
if (src == null) { perr(`cannot open import {full}`) }
# the audio runtime can arrive through atlas.ludic's own import or the Assets
# splice, not only through an Audio.* call in the game; a windowed build must
# then link audio.ll + AVFoundation for its snd_* calls, so flag it here.
let atail = "runtime/native/audio.ludic"
let fl = len(full)
if (fl >= len(atail)) and (full[fl - len(atail)..fl] == atail) { g_uses_audio = true }
let saved_toks = toks; let saved_pi = pi; let saved_dir = cur_dir
let saved_file = g_parse_file; let saved_parsing = g_parsing
cur_dir = dir_of(full)
g_parse_file = full; g_parsing = true
lex(src) # resets the global token stream
pi = 0
skipnl()
while toks[pi].kind != TK_EOF { parse_one_decl(); skipnl() }
toks = saved_toks; pi = saved_pi; cur_dir = saved_dir
g_parse_file = saved_file; g_parsing = saved_parsing
}
# a game (has handlers/components) links the Ludic runtime; auto-splice it.
# Tools (an `entry` block, no ECS) get nothing.
# a bare name that only a spliced runtime answers: the runtime comes in when a program calls it
# directly (a package calling value_get, a helper calling rng_range), not only through Value.*
function rt_wanted_name(n: pointer) -> bool {
if (n == "seed") { return true }
return str_starts(n, "rng_") or str_starts(n, "value_") or str_starts(n, "json_")
}
# a wanted name with this prefix that nothing in the program defines
function rt_wants_unmet(prefix: pointer) -> bool {
var i = 0
while i < len(g_rt_wants) {
let n = g_rt_wants[i]
if str_starts(n, prefix) and find_fn(n) == null and find_global(n) == null { return true }
i += 1
}
return false
}
function maybe_splice_runtime() -> void {
let saved = cur_dir
# L6: the engine's namespaces that are aliases of runtime functions, declared in Ludic
cur_dir = ""
do_import("runtime/native/namespaces.ludic")
# L7: the byte buffer's API, when a program reads or writes bytes (it opens files through
# the asset pack, and a program that does not should not carry the pack's machinery)
if g_uses_bytes { do_import("runtime/native/bytes.ludic") }
cur_dir = saved
# Random.* (or a bare rng_* / seed nobody else defines) in a program with no ECS: the seeded
# random numbers on their own; a game has them through core.ludic
if not has_ecs() and (g_uses_rng or rt_wants_unmet("rng_") or rt_wants_unmet("seed")) {
cur_dir = ""
do_import("runtime/native/rng.ludic")
cur_dir = saved
}
# a game (has systems/components) links the Ludic runtime.
if has_ecs() {
cur_dir = ""
do_import("runtime/native/core.ludic")
cur_dir = saved
}
# any program that uses Regex.* gets the regex engine spliced in (it is
# self-contained — only compiler intrinsics — so it works in a plain tool too).
if g_uses_regex {
cur_dir = ""
do_import("runtime/native/regex.ludic")
do_import("runtime/native/regex_vm.ludic")
cur_dir = saved
}
# any program that uses BigInt.*/Decimal.* gets the bignum engine spliced in
# (self-contained — only compiler intrinsics — so a plain tool works too).
if g_uses_bignum {
cur_dir = ""
do_import("runtime/native/bignum.ludic")
cur_dir = saved
}
# any program that uses Dict.*/Set.* gets the hash-table engine spliced in
# (self-contained — only compiler intrinsics — so a plain tool works too).
if g_uses_dict {
cur_dir = ""
do_import("runtime/native/dict.ludic")
cur_dir = saved
}
# any program that uses Huge.*/Angle.*/Percent.* gets the numeric runtime
# spliced in (it builds on Math.*, which lowers inline, so a plain tool works).
if g_uses_numeric {
cur_dir = ""
do_import("runtime/native/numeric.ludic")
cur_dir = saved
}
# any program that uses Job.*/Promise.*/Sync.* gets the concurrency runtime
# spliced in (it is self-contained — only compiler intrinsics — so a plain tool
# works too). A deterministic cooperative scheduler; see runtime/native/jobs.ludic.
if g_uses_jobs {
cur_dir = ""
do_import("runtime/native/jobs.ludic")
cur_dir = saved
}
# any program that uses Query.* gets the ECS spatial-query helpers spliced in;
# they read entity state through the reflection ABI (emit_decl force-emits it
# for a Query program even when it declares no events).
if g_uses_query {
cur_dir = ""
do_import("runtime/native/query.ludic")
cur_dir = saved
}
# any program that uses Light.* gets the 2D light-accumulation pass spliced in;
# it reads and writes the framebuffer (rt_fb), so it links with core.ludic.
if g_uses_light {
cur_dir = ""
do_import("runtime/native/light.ludic")
cur_dir = saved
}
# Value.*/Json.* (#44) get the generic value tree + JSON bridge spliced in; it
# is self-contained (only string/slice ops), so it works in a plain tool too.
if rt_wants_unmet("value_") or rt_wants_unmet("json_") { g_uses_value = true } # value_get called directly
if g_uses_value {
cur_dir = ""
do_import("runtime/native/value.ludic")
cur_dir = saved
}
# Xml.* (#67) — the minimal pure-Ludic XML reader for the TMX/TSX/TX subset.
# Self-contained (string/slice ops only), so it works in a plain tool too.
if g_uses_xml {
cur_dir = ""
do_import("runtime/native/xml.ludic")
cur_dir = saved
}
# Sprite.sheet/cell/… or Assets.* (#81) — the spritesheet / atlas runtime. It
# reads the framebuffer and the image loader (rt_image_load / img_px in
# image.ludic), so it links against core.ludic (do_import dedupes).
if g_uses_atlas {
cur_dir = ""
do_import("runtime/native/core.ludic")
do_import("runtime/native/audio.ludic") # Assets.enqueue loads .wav/.mp3 into the sound bank
do_import("runtime/native/atlas.ludic")
cur_dir = saved
}
# Fx.* — engine-owned sparks and floating numbers; drawn through the framebuffer,
# so it links against core.ludic (do_import dedupes).
if g_uses_fx {
cur_dir = ""
do_import("runtime/native/core.ludic")
do_import("runtime/native/fx.ludic")
cur_dir = saved
}
# Base64.* (#67) — base64 codec; pull in the DEFLATE inflater alongside it, so
# a plain tool can run the full base64 -> zlib/gzip decode chain (inflate.ludic
# is self-contained; do_import dedupes when a game already linked it via core).
if g_uses_base64 {
cur_dir = ""
do_import("runtime/native/inflate.ludic")
do_import("runtime/native/zstd.ludic")
do_import("runtime/native/base64.ludic")
cur_dir = saved
}
# Tiled.* (#69) — the Tiled map runtime: model, GID resolver, loader, draw. It
# reads/writes the tilemap + framebuffer, so it links against core.ludic, and
# it consumes the XML/base64/inflate primitives above.
if g_uses_tiled {
cur_dir = ""
do_import("runtime/native/core.ludic")
do_import("runtime/native/xml.ludic")
do_import("runtime/native/inflate.ludic")
do_import("runtime/native/zstd.ludic")
do_import("runtime/native/base64.ludic")
do_import("runtime/native/value.ludic")
do_import("runtime/native/tiled.ludic")
# #72 opt-in spawning stands on the reflection ABI, which the compiler emits
# only for a game (emit_decl: has_ecs -> emit_world_table). So splice the
# spawn helpers only for a Tiled game; a plain map-reading tool never links
# against the world table.
if has_ecs() { do_import("runtime/native/tiled_spawn.ludic") }
cur_dir = saved
}
# Reflect.serialize/apply add the reflection serializer, which stands on both
# the value tree (above) and the world table (force-emitted for Reflect.*).
if g_uses_reflect_io {
cur_dir = ""
do_import("runtime/native/reflect_io.ludic")
cur_dir = saved
}
# engine-owned systems (#43/#47): a game that declares a well-known engine
# component (SpriteAnim / Motion / Light2D / Occluder) gets systems.ludic
# spliced, and the compiler inserts a call to each esys_* at its frame phase
# (emit_engine_systems_for_phase). The systems read/write components through the
# reflection ABI, so g_uses_esys also force-emits the world table (emit_decl).
# Light2D/Occluder additionally consume the 2D light pass, so pull it in too.
# Input.* action maps + record/replay (#7): splice input.ludic. It reads the
# live key through rt_poll (core.ludic), so pull the runtime in even for a
# program with no ECS (do_import dedupes when a game already linked core).
if g_uses_input {
cur_dir = ""
do_import("runtime/native/core.ludic")
do_import("runtime/native/input.ludic")
cur_dir = saved
}
# Tween.* fluent handles (#48): splice the stateful tween runtime; esys_tween is
# inserted into the Update phase (emit_game.ludic) to advance handles each tick.
# It reads the live frame clock via the standard game loop, so pull core in too.
if g_uses_tween_rt {
cur_dir = ""
do_import("runtime/native/core.ludic")
do_import("runtime/native/tween.ludic")
cur_dir = saved
}
# Audio.* (#22): splice the Audio runtime. It is self-contained — its native
# calls (snd_*) are is_windowed()-guarded, so a headless build carries the API
# as no-ops and needs no audio backend — so it works in a plain program too.
if g_uses_audio {
cur_dir = ""
do_import("runtime/native/audio.ludic")
cur_dir = saved
}
# Http.* (#6): splice the HTTP client. The transport (hs_* intrinsics) is native
# (linked from http.ll), but the response parser is pure Ludic; self-contained,
# so it works in a plain program too.
if g_uses_http {
cur_dir = ""
do_import("runtime/native/http.ludic")
cur_dir = saved
}
# Udp.*: splice the datagram library. The sockets are native (udp.ll / udp_win.ll, bound
# by `extern function` in udp.ludic); the address helpers are pure Ludic.
if g_uses_udp {
cur_dir = ""
do_import("runtime/native/udp.ludic")
cur_dir = saved
}
# Process.*: splice the child-process library. Spawning is native (process.ll /
# process_win.ll); the argv array and the Windows command line are built in Ludic.
if g_uses_process {
cur_dir = ""
do_import("runtime/native/process.ludic")
cur_dir = saved
}
# Vk.* draws into the runtime's window, whose size, drawable and clock (gl_width,
# gl_set_drawable, gl_now_us, ...) still live in the GL runtime: splice and link it too,
# until the window layer stands apart from OpenGL (maroon-lake docs/plan/22, 22.15)
if g_uses_vk { g_uses_gl = true }
# Gl.*: splice the OpenGL surface (gl.ludic + the generated gl_api.ludic). The
# native calls are the linked GL entry points themselves; the window attach is
# is_windowed()-guarded, so a headless build renders into an offscreen context.
if g_uses_gl {
cur_dir = ""
do_import("runtime/native/gl.ludic")
cur_dir = saved
}
# Vk.*: splice the Vulkan surface (vk.ludic + the generated vk_api.ludic). The
# loader is opened at run time, so a program that never calls Vk.open needs none.
if g_uses_vk {
cur_dir = ""
do_import("runtime/native/vk.ludic")
cur_dir = saved
}
# Anim.play/Motion.to sugar (#48): the writes live in systems.ludic and use the
# reflection ABI, so splice it and force the world table even when the game does
# not otherwise trip uses_engine_systems.
if g_uses_anim_rt {
g_uses_esys = true
cur_dir = ""
do_import("runtime/native/core.ludic")
do_import("runtime/native/systems.ludic")
cur_dir = saved
}
if uses_engine_systems() {
g_uses_esys = true
cur_dir = ""
# SpriteAnim / Motion -> the animation systems (self-contained, reflection only)
if (find_comp("SpriteAnim") != null) or (find_comp("Motion") != null) {
do_import("runtime/native/systems.ludic")
}
# Body -> the movement + collision system (#65). It reads the tilemap
# (rt_tile / rt_mapw / rt_maph) for the tile-grid broadphase, so it links
# against core.ludic; do_import dedupes when a game already pulled core in.
if find_comp("Body") != null {
do_import("runtime/native/core.ludic")
do_import("runtime/native/systems_move.ludic")
}
# Sprite -> the engine sprite-render system (#85). It draws through
# rt_draw_sprite_ex (image.ludic, part of core), so it links against core.ludic
# (do_import dedupes when a game already pulled core in).
if find_comp("Sprite") != null {
do_import("runtime/native/core.ludic")
do_import("runtime/native/systems_sprite.ludic")
}
# TileSkin -> the engine tilemap-render system: draws the Map.* grid from glyph -> sprite skins.
if find_comp("TileSkin") != null {
do_import("runtime/native/core.ludic")
do_import("runtime/native/systems_tileskin.ludic")
}
# Bounds -> the engine world-bounds system (#84). Self-contained (reflection
# ABI only); its kill policy calls world_despawn, so force the @fn_world_despawn
# helper to be emitted.
if find_comp("Bounds") != null {
do_import("runtime/native/systems_bounds.ludic")
}
# Light2D / Occluder -> the lighting render system, which links against the
# 2D light pass (light.ludic). do_import dedupes, so this is a no-op when the
# game also uses Light.* directly (g_uses_light already pulled it in).
if (find_comp("Light2D") != null) or (find_comp("Occluder") != null) {
do_import("runtime/native/light.ludic")
do_import("runtime/native/systems_light.ludic")
g_uses_light = true
}
cur_dir = saved
}
}
function parse_program() -> void {
prog = new []Node
g_computed = new []Node
g_onspawn = new []Node
g_ondespawn = new []Node
g_onattach = new []Node
g_ondetach = new []Node
g_onenable = new []Node
g_ondisable = new []Node
g_scenes = new []Node
g_scene_count = 0
g_start_scene = 0
g_events = new []Node
g_onlisten = new []Node
g_toggled_layers = new []pointer
g_uses_regex = false
g_uses_gl = false
g_uses_vk = false
g_uses_bignum = false
g_uses_dict = false
g_uses_numeric = false
g_uses_jobs = false
g_uses_query = false
g_uses_reflect = false
g_uses_esys = false
g_uses_input = false
g_has_clear_color = false
g_clear_color = null
g_warned_draw_sprite = false
g_uses_world_despawn = false
g_uses_light = false
g_uses_value = false
g_uses_rng = false
g_rt_wants = new []pointer
g_uses_xml = false
g_uses_base64 = false
g_uses_tiled = false
g_uses_atlas = false
g_uses_fx = false
g_uses_reflect_io = false
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
g_disabled_sys = 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, "Body"); push(g_esys_fn, "esys_move"); push(g_esys_phase, "Update")
push(g_esys_comp, "Bounds"); push(g_esys_fn, "esys_bounds"); push(g_esys_phase, "LateUpdate")
push(g_esys_comp, "TileSkin"); push(g_esys_fn, "esys_tileskin"); push(g_esys_phase, "Render")
push(g_esys_comp, "Sprite"); push(g_esys_fn, "esys_sprite"); push(g_esys_phase, "Render")
push(g_esys_comp, "Light2D"); push(g_esys_fn, "esys_light2d"); push(g_esys_phase, "Render")
g_namespaces = new []pointer
g_ns_blocks = new []pointer
g_ns_exports = new []pointer
loaded_paths = new []pointer
skipnl()
g_game_name = "Ludic"
# imports may precede the program block
while is_id("import") { pi += 1; let t = toks[pi]; let rel = t.text; pi += 1; do_import(rel); skipnl() }
# @annotations on the program itself (e.g. @Handles(Movement)) — parsed, skipped
while is_op("@") {
pi += 1; let a = eat_id()
if is_op("(") { var d = 0
while true { if is_op("(") { d += 1 }; if is_op(")") { d -= 1 }; pi += 1; if d == 0 { break } } }
skipnl()
}
if is_id("program") { pi += 1; g_game_name = eat_id(); skipnl(); eat_op("{") }
while true {
skipnl()
if toks[pi].kind == TK_EOF { break }
if is_op("}") { break }
parse_one_decl()
}
g_gen_nodes = true # 0.S: what the finishing passes make is generated
modules_finish() # L3: the declared uses have no cycle
registries_finish() # L8: each registry gets its defs
ports_finish() # L3: each port gets its one bind
views_finish() # L11: each view gets its model and call
components_finish() # L11: each component gets its class
ui_blocks_states() # 0.S: a ui block reads a state as State.field
actions_finish() # 0.R: the reducers checked, the queue written
g_gen_nodes = false
}