1688 lines
80 KiB
Text
1688 lines
80 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
|
|
}
|
|
}
|
|
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"; 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 == "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
|
|
}
|
|
|
|
# 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 {
|
|
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)
|
|
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 == "AppendOnly") or (a == "ByKey") { at_decl(a) } # a registry's, for editors (attrs.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: `export` parses the declaration one call down
|
|
}
|
|
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()
|
|
}
|
|
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
|
|
g_at_keep = true
|
|
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 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
|
|
}
|
|
# 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
|
|
}
|