ludic/selfhost/frontend/parse.ludic
Orkuncakilkaya e4265f5fdb feat(lang): 27.1 - a state machine as data: @Machine(Record.field) on a registry of transitions
A registry marked `@Machine(Deer.mood)` is the transitions of a machine over that enum field of the
records a state's Table<Deer> holds. Its record has from and to (the enum's variants), on: string (an
action's name, "" for a transition the tick asks), guard: fn(Row<Deer>, reads...) -> bool and
enter: fn(Row<Deer>, reads...) -> void; the states are the enum's variants and the start is the
field's default. The rows are data (an .lres or defs), the names the studio already edits.

Written by the compiler (machines.ludic, machines_write.ludic): for each action an `on` names, a row
reducer in the registry's file (named ..__machine__DeerSteps, so it sits beside the program's own
row reducer on the same action, after it): the row's state, the first transition from it on that
action whose guard passes, the field set, enter run - guards and enters called by name. When a row
leaves a state on a guard alone, `state DeerStepsMachine` (the kept row view) and
deer_steps_tick(m: mut DeerStepsMachine, s: mut Herd, reads...), one transition a row a tick.
Nothing allocates.

The table is the whole machine: the field written anywhere else - an assignment, or a `machine`
block's become over it - is a type error (check_stmt.ludic, ck_machine_write). Guards and enters take
the row first, are the record's module's, keep a row reducer's rules (and may be handed the row);
a guard writes nothing through it. The graph is checked, each error at its row (in the .lres when
the rows are there): a state never reached from the start, a state with no way out, an `on` naming
no action or an action with no @Target, a self-transition with no guard, two ways out of a state on
one trigger behind an unguarded first. Also refused: @Machine off a registry, a field that is not a
plain enum with a default, a @Column field, no table (or two) of the record, a transitions record of
another shape, a machine outside its table's state's module.

ludic schema's code section gains `machines` (registry, record, field, enum, table, start, states,
actions, tick, module, at); ludic deps names a machine's reducer `reducer Deer in Herd.deer on Spook
(machine DeerSteps)`. vocab @Machine; docs annot-machine, kw-machine; LANGUAGE.md "A machine as
data"; examples actions/machine (+ deer_steps.lres) and ten rejects; test.ludic feat, reject and
schema cases (not run); changes/machines.md. Reseeded; bootstrap-cfree fixpoint holds (317642
lines); Maroon Lake's `ludic build --check` is clean against this tree.

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

1764 lines
84 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()
var g_fm_src: FileMap = fm_new() # every file read, by its path: the schema's docs and spans
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
var col = 0
if g_parsing {
file = g_parse_file
line = 0
if pi < len(toks) { line = toks[pi].line; col = toks[pi].col }
}
if file == null { file = "" }
if g_diag_json {
diag_add(file, line, col, "error", msg)
diag_exit(1)
}
let m = `{file}:{line}: error: {msg}\n`
file_write(file_stderr(), m, len(m))
exit(1)
}
# an error the pass reporting it can go on from (every @Ref, every module rule broken): reported,
# counted, and the build stops once the pass is done (diag_stop_if_errors)
function perr_at(file: pointer, line: int, col: int, msg: pointer) -> void {
var f = file
if f == null { f = "" }
if g_diag_json { diag_add(f, line, col, "error", msg) }
else {
let m = `{f}:{itoa(line)}: error: {msg}\n`
file_write(file_stderr(), m, len(m))
g_dg_errors += 1
}
}
# a warning: kept in the JSON array, or said on stderr; the build goes on
function pwarn_at(file: pointer, line: int, col: int, msg: pointer) -> void {
var f = file
if f == null { f = "" }
if g_diag_json { diag_add(f, line, col, "warning", msg) }
else {
let m = `{f}:{itoa(line)}: warning: {msg}\n`
file_write(file_stderr(), m, len(m))
}
}
function diag_stop_if_errors() -> void {
if g_dg_errors > 0 { diag_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"; id.uns = TPL_HOLE; 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)) }
if acc.kind == E_CALL { acc.a.uns = TPL_COPY } # `{s}` alone is a new string, never s itself (emit_call.ludic)
return acc
}
const TPL_COPY: int = 91 # on the callee of a template's lone string(hole)
const TPL_HOLE: int = 92 # on the callee of every other string(hole) (a Key is refused there)
# 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()
cx_emit(n) # the schema's code map (emit_schema_code.ludic)
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_KEY { pi += 1; return key_lit_node(t) } # k"..." / kn"..." (i18n.ludic)
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(); cx_fnref(n); 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 == "Build" and e.s == "schema_hash" { g_uses_schema_hash = true } # the schema hashed after the checker
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(":")
let v0 = pi
fi.a = expr()
fi.sp0 = toks[v0].off; fi.sp1 = toks[pi - 1].oend # the schema's text of the value
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("=")
let v0 = pi
n.a = expr()
n.sp0 = toks[v0].off; n.sp1 = toks[pi - 1].oend # the schema's text of the value
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
}
# a handler's hook, for the schema's code map: the attribute that made it one, and what it names
function decl_hook(ev: pointer, sp: pointer, dsp: pointer, att: pointer, det: pointer, en: pointer, dis: pointer, ph: pointer) -> pointer {
if ev != null { return "On" }
if sp != null { return "OnSpawn" }
if dsp != null { return "OnDespawn" }
if att != null { return "OnAttach" }
if det != null { return "OnDetach" }
if en != null { return "OnEnable" }
if dis != null { return "OnDisable" }
if ph != null {
if (ph == "Start") { return "OnStart" }
return "OnQuit"
}
return null
}
function decl_hook_target(ev: pointer, sp: pointer, dsp: pointer, att: pointer, det: pointer, en: pointer, dis: pointer) -> pointer {
if ev != null { return ev }
if sp != null { return sp }
if dsp != null { return dsp }
if att != null { return att }
if det != null { return det }
if en != null { return en }
return dis
}
# 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.
var g_decl_exported: bool = false # `export` read in front of the declaration being parsed
function parse_one_decl() -> void {
let p0 = len(prog)
let e0 = len(g_events)
let r0 = len(g_rg_var)
let outer = g_decl_exported
g_decl_exported = false
parse_one_decl_in()
if g_decl_exported { decl_mark_exported(p0, e0, r0) }
g_decl_exported = outer
}
# `export function f`, `export var v`, ...: what the declaration added is visible from other modules (L3)
function decl_mark_exported(p0: int, e0: int, r0: int) -> void {
var k = p0
while k < len(prog) {
prog[k].vis = 1
k += 1
}
k = r0
while k < len(g_rg_var) { # a @PerMap registry's own node is not in prog
g_rg_var[k].vis = 1
k += 1
}
k = e0
while k < len(g_events) {
g_events[k].vis = 1
k += 1
}
}
function parse_one_decl_in() -> void {
at_decl_reset() # attrs.ludic: @AppendOnly / @ByKey belong to one declaration
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)
var is_rowverb = false # @RowVerb — a verb of a table's row (27.3, actions_rows.ludic)
# attributes and `export` in any order: `@ToClients export event E` keeps its @ToClients
while is_op("@") or (is_id("export") and (toks[pi + 1].kind == TK_ID or toks[pi + 1].text == "@")) {
if is_id("export") {
pi += 1
g_decl_exported = true
skipnl()
continue
}
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 == "RowVerb" { is_rowverb = true }
else if a == "Machine" { mc_parse_attr() } # @Machine(Record.field) - 27.1, machines.ludic
else if (a == "AppendOnly") or (a == "ByKey") { at_decl(a) } # a registry's, for editors (attrs.ludic)
else if a == "PerMap" { # a map-scoped registry (permap.ludic)
at_decl(a)
g_pm_pending = true
}
else if a == "Chunked" { pm_parse_chunked() } # @Chunked(64): read a chunk at a time
else if a == "TextKey" { i18n_parse_textkey() } # @TextKey("steps"): the registry's text keys (i18n.ludic)
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: the declaration after the attributes reads it
}
else if a == "creates" or a == "releases" { # 25.5e: @creates(PhysShape) / @releases(PhysShape)
eat_op("(")
let kind = eat_id()
eat_op(")")
if a == "creates" { g_res_pending_make = kind } else { g_res_pending_free = kind }
}
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()
}
mc_attr_placed() # @Machine is a registry's (machines.ludic)
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
}
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
cx_handler(h, h.s, decl_hook(on_event, onspawn_model, ondespawn_model, onattach_prop, ondetach_prop, onenable_prop, ondisable_prop, hook_phase),
decl_hook_target(on_event, onspawn_model, ondespawn_model, onattach_prop, ondetach_prop, onenable_prop, ondisable_prop), qspec, is_public, null, null) # the schema's code map
if (on_event != null) { register_onlisten(on_event, h.a); cx_listened(); 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("reducer") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "in") { parse_row_reducer(); return } # 27.3
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 is_rowverb { push(g_rv_nodes, f) }
if g_aok_pending { push(g_alloc_ok_names, f.s); g_aok_pending = false }
if g_res_pending_make != null { push(g_res_make_fn, f.s); push(g_res_make_kind, g_res_pending_make); g_res_pending_make = null }
if g_res_pending_free != null { push(g_res_free_fn, f.s); push(g_res_free_kind, g_res_pending_free); g_res_pending_free = null }
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)
fm_put(g_fm_src, full, src)
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
}
# a @PerMap registry: the .lres reader its generated fill calls (permap.ludic)
if g_pm_any {
cur_dir = ""
do_import("runtime/native/lres.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
at_check_refs() # every @Ref names a registry (attrs.ludic)
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
}