ludic/tools/ludic-cli/test.ludic
Orkuncakilkaya 619476ffed fix(input): keys are physical positions on every layout; Input.key_label names them
The Windows runtime keyed the held set by the character MapVirtualKeyA gave a
virtual key, so a game's WASD belonged to whatever the active layout put there,
and with an input method on every letter arrived as VK_PROCESSKEY. The typing
block is now read from the scancode (the arrows, numpad and F-keys still by
virtual key); macOS reads keyCode the same way. Input.key_label(key) names a key
in the player's own layout (Windows) or as its US character elsewhere.

Verified on Windows with SendInput into a live window: VK_Z carrying W's scancode
holds 'w', VK_PROCESSKEY carrying A's holds 'a', Caps Lock changes nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:26:53 +03:00

831 lines
54 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# test.ludic — the top-level regression suite. Replaces test.sh. The compiler is
# written in Ludic (selfhost/) and built from a checked-in LLVM-IR seed with
# clang; there is no C compiler in the repo. This suite proves the compiler
# builds from the seed, compiles every example to its byte-exact golden render,
# self-hosts to a fixpoint, and rebuilds itself with no C compiler.
#
# It drives the other commands through `bin/ludic <sub>` exactly where test.sh used
# to spawn a sibling script, so each stage stays an independent, observable run.
# compile a feature example as a game, run it (optional keys on stdin) and match
# its stdout against the expected space-joined output.
function feat_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void {
let nm = flat(path)
if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/e_{nm}`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
}
var got = ""
if (keys == "") { got = capture_line(`{tmp_dir()}/e_{nm} < /dev/null`) }
else { got = capture_line(`printf '{keys}' | {tmp_dir()}/e_{nm}`) }
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
}
# a feature example that consumes an in-repo controller package (packages/): same
# as feat_case, but the compiler is pointed at packages/ via LUDIC_MODULES so a
# `import "ludic.foo/bar.ludic"` resolves, and LUDIC_HOME anchors the engine
# runtime. This is exactly how `ludic build` / an external project builds against a
# fetched source package (the do_import module-root fallback, #63).
function controller_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void {
let nm = flat(path)
let root = capture_line("pwd")
let ll = `{tmp_dir()}/c_{nm}.ll`
if not shq(`LUDIC_HOME={root} LUDIC_MODULES={root}/packages bin/ludicc examples/{path}.ludic > {ll} 2>{tmp_dir()}/gb.err`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
}
if not shq(`{cc()} -O2 {ll} -o {tmp_dir()}/c_{nm} 2>{tmp_dir()}/gb.err`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
}
var got = ""
if (keys == "") { got = capture_line(`{tmp_dir()}/c_{nm} < /dev/null`) }
else { got = capture_line(`printf '{keys}' | {tmp_dir()}/c_{nm}`) }
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
}
# #75 — an external project (its CWD is not the toolchain repo, with no runtime/
# under it and no ludic_modules) builds an ECS game by resolving the engine runtime
# from $LUDIC_HOME (the toolchain install), independent of the package module root.
function runtime_home_case() -> void {
let root = capture_line("pwd")
let dir = `{tmp_dir()}/ext75`
shq(`rm -rf {dir} && mkdir -p {dir}`)
write_file(`{dir}/game.ludic`, "program Ext {\n property Mob { hp: int = 0 }\n model M { Mob }\n entry {\n spawn M { Mob { hp: 7 } }\n let pm = World.prop_id(\"Mob\")\n let e = World.query_next(pm, 0)\n print(World.get(e, pm, World.field_id(pm, \"hp\")))\n }\n}\n")
let lbl = "runtime ships with the toolchain, resolved from LUDIC_HOME (#75)"
# compiled from the external CWD; there is deliberately no runtime/ or
# ludic_modules/ under {dir}, so the engine runtime can only come from LUDIC_HOME.
if not shq(`cd {dir} && LUDIC_HOME={root} {root}/bin/ludicc game.ludic -o {dir}/game 2>{dir}/err`) {
bad2(lbl, capture_line(`grep -i 'error|cannot open' {dir}/err | head -1`)); return
}
let got = capture_line(`{dir}/game < /dev/null`)
if (got == "7") { ok(lbl) } else { bad2(lbl, `got [{got}] want [7]`) }
}
# The install layout: bin/ beside runtime/ and packages/, reached through $PATH
# from an unrelated directory, with nothing in the environment. This is what
# `curl https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh` lays down and the only shape most
# users will ever run, so it is asserted rather than assumed: a staged copy of
# the toolchain compiles and runs a project created by `ludic new`.
function install_layout_case() -> void {
let root = capture_line("pwd")
let home = `{tmp_dir()}/install`
let lbl = "an installed layout (bin/ + runtime/ + packages/, found on PATH) builds a new project"
shell(`rm -rf {home} && mkdir -p {home}/bin`)
shell(`cp bin/ludic bin/ludicc bin/ludic-fmt {home}/bin/`)
shell(`cp -R runtime {home}/runtime && cp -R packages {home}/packages && cp VERSION {home}/VERSION`)
let work = `{tmp_dir()}/installwork`
shell(`rm -rf {work} && mkdir -p {work}`)
# PATH only — no LUDIC_HOME, and the current directory has no bin/ of its own.
let envp = `env -u LUDIC_HOME PATH={home}/bin:/usr/bin:/bin:/usr/sbin:/sbin`
if not shq(`cd {work} && {envp} ludic new demo > {work}/new.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/new.out`)); return
}
if not shq(`cd {work}/demo && {envp} ludic build --headless > {work}/build.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/build.out`)); return
}
if not shq(`cd {work}/demo && {envp} ludic test > {work}/test.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/test.out`)); return
}
# from the project directory, never the repo root: `ludic version` used to
# answer from a ./bin and ./VERSION next to the *current* directory, which made
# this check pass in the repo while a real install said "(version unknown)".
let ver = capture_line(`cd {work}/demo && {envp} ludic version`)
if ver != `ludic {s_trim(read_file("VERSION"))}` {
bad2("an installed `ludic version` reports the install's VERSION", `got [{ver}]`); return
}
# and the shipped CLI carries none of the contributor tasks: `ludic dev …` is
# not a command there, it is a pointer at the tool that has them.
let devlog = `{work}/dev.out`
let devrc = sh(`cd {work}/demo && {envp} ludic dev build > {devlog} 2>&1`)
var devout = read_file(devlog)
if devout == null { devout = "" }
if devrc == 0 or not s_contains(devout, "ludic-dev") {
bad2("an installed `ludic` must not carry the toolchain's own tasks", `rc {string(devrc)}, said [{s_trim(devout)}]`)
return
}
ok(lbl)
}
# `ludic new` must scaffold something that compiles, for any name a user might
# reasonably pick. `my-game` produced `program My-Game`, which is a subtraction,
# and `2048` produced an identifier starting with a digit — both scaffolded a
# project that failed on the very first `ludic run`.
# The asset pack, end to end: `ludic pack` writes it, the runtime mounts it
# before main from packs.index, and the game reads its assets out of it while
# running from a directory where none of them exist on disk. That last part is
# the whole feature - a built game used to run only from its project root.
function pack_roundtrip_case() -> void {
let lbl = "ludic pack -> a game reads its assets with none of them on disk"
let work = `{tmp_dir()}/packrt`
let root = capture_line("pwd")
shell(`rm -rf {work} && mkdir -p {work}/assets/sub {work}/ship`)
write_file(`{work}/assets/a.txt`, "alpha")
write_file(`{work}/assets/sub/b.txt`, "beta")
write_file(`{work}/game.ludic`, pack_probe_src())
if not shq(`cd {work} && {root}/bin/ludic pack --out ship/game.lpak assets > pack.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack.out`)); return
}
if not shq(`cd {work} && {root}/bin/ludic pack --verify ship/game.lpak > verify.out 2>&1`) {
bad2(lbl, "the pack it just wrote does not verify"); return
}
if not shq(`cd {work} && {root}/bin/ludicc game.ludic -o ship/probe > cc.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/cc.out`)); return
}
write_file(`{work}/ship/packs.index`, "pack game.lpak" + nl())
# the assets exist only inside the pack from here on
shell(`rm -rf {work}/assets`)
# run from somewhere with no relation to the project at all
let got = s_trim(capture(`cd / && {work}/ship/probe 2>&1`))
if got != "alpha|beta|1|5|0" {
bad2(lbl, `read back [{got}], wanted [alpha|beta|1|5|0]`); return
}
ok(lbl)
}
# the probe game: reads two packed files and asks after a third that is in no
# pack, so a false hit would show up as loudly as a miss
function pack_probe_src() -> pointer {
var s = "program PackProbe {" + nl()
s = s + " entry {" + nl()
s = s + " var a = Fs.read_text(\"assets/a.txt\")" + nl()
s = s + " var b = Fs.read_text(\"assets/sub/b.txt\")" + nl()
s = s + " if a == null { a = \"MISS\" }" + nl()
s = s + " if b == null { b = \"MISS\" }" + nl()
s = s + " print(`{a}|{b}|{string(Fs.exists(\"assets/a.txt\"))}|{string(Fs.size(\"assets/a.txt\"))}|{string(Fs.exists(\"assets/gone.txt\"))}`)" + nl()
s = s + " }" + nl()
s = s + "}" + nl()
return s
}
# App.set_icon: the Dock tile for a binary with no bundle around it. The tile itself
# cannot be asserted from a test process, so what is checked is the part that can go
# wrong silently - that it compiles and runs in a windowed build, that a missing or
# unreadable image is a no-op rather than a crash or a cleared icon, and that a headless
# build (which has no AppKit linked at all) compiles it away to nothing.
function app_icon_case() -> void {
let lbl = "App.set_icon -> an icon for a binary with no bundle, and nothing headless"
if not is_darwin() { skip(lbl); return }
let work = `{tmp_dir()}/appicon`
let root = capture_line("pwd")
shell(`rm -rf {work} && mkdir -p {work}`)
# a real PNG to set, made the same way the bundle test makes its icon
shell(`sips -s format png --resampleHeightWidth 64 64 /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns --out {work}/icon.png > /dev/null 2>&1`)
var src = "program IconSet {" + nl()
src = src + " entry {" + nl()
src = src + " App.set_icon(\"icon.png\")" + nl()
src = src + " App.set_icon(\"nope.png\")" + nl() # missing: must not crash
src = src + " print(\"ok\")" + nl()
src = src + " }" + nl()
src = src + "}" + nl()
write_file(`{work}/i.ludic`, src)
if not shq(`cd {work} && {root}/bin/ludicc i.ludic -o i > cc.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/cc.out`)); return
}
let got = s_trim(capture(`cd {work} && ./i 2>&1`))
if got != "ok" { bad2(lbl, `windowed build printed [{got}]`); return }
# headless links no AppKit: it has to compile and run just the same
if not shq(`cd {work} && {root}/bin/ludicc i.ludic --headless -o ih > hh.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/hh.out`)); return
}
let goth = s_trim(capture(`cd {work} && ./ih 2>&1`))
if goth != "ok" { bad2(lbl, `headless build printed [{goth}]`); return }
ok(lbl)
}
# Os.save_dir / config_dir / cache_dir have to name the directory the PLATFORM keeps
# those things in - that is the entire reason a program calls them instead of building a
# path. They were the macOS layout on every platform, so a game built on Linux wrote its
# saves to ~/Library/Application Support, which is a directory that means nothing there.
#
# The assert is the host's own convention, so a macOS dev box covers the Apple branch and
# CI covers XDG. The env cases matter as much as the defaults: XDG_* must be honoured
# where it applies and ignored where it does not, and a variable that is set but EMPTY
# falls back, which is what the spec says and what an exported-but-unset variable looks
# like from a shell.
function os_dirs_case() -> void {
let lbl = "Os.save_dir / config_dir / cache_dir follow the platform's own convention"
let work = `{tmp_dir()}/osdirs`
let root = capture_line("pwd")
shell(`rm -rf {work} && mkdir -p {work}`)
var src = "program OsDirs {" + nl()
src = src + " entry {" + nl()
src = src + " print(`{Os.save_dir(\"App X\")}|{Os.config_dir(\"App X\")}|{Os.cache_dir(\"App X\")}`)" + nl()
src = src + " }" + nl()
src = src + "}" + nl()
write_file(`{work}/d.ludic`, src)
if not shq(`cd {work} && {root}/bin/ludicc d.ludic -o d > cc.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/cc.out`)); return
}
let home = capture_line("printf %s \"$HOME\"")
# with every XDG variable set: honoured on Linux, irrelevant on macOS
let set_env = "XDG_DATA_HOME=/x/d XDG_CONFIG_HOME=/x/c XDG_CACHE_HOME=/x/k"
let with_xdg = s_trim(capture(`cd {work} && env {set_env} ./d`))
# with none of them set, and with them set but empty
let no_xdg = s_trim(capture(`cd {work} && env -u XDG_DATA_HOME -u XDG_CONFIG_HOME -u XDG_CACHE_HOME ./d`))
let empty_xdg = s_trim(capture(`cd {work} && env XDG_DATA_HOME= XDG_CONFIG_HOME= XDG_CACHE_HOME= ./d`))
var want_plain = `{home}/.local/share/App X|{home}/.config/App X|{home}/.cache/App X`
var want_set = "/x/d/App X|/x/c/App X|/x/k/App X"
if is_darwin() {
# Apple keeps saves and config together; XDG has no standing here at all
want_plain = `{home}/Library/Application Support/App X|{home}/Library/Application Support/App X|{home}/Library/Caches/App X`
want_set = want_plain
}
if no_xdg != want_plain { bad2(lbl, `no XDG: got [{no_xdg}], wanted [{want_plain}]`); return }
if with_xdg != want_set { bad2(lbl, `XDG set: got [{with_xdg}], wanted [{want_set}]`); return }
if empty_xdg != want_plain { bad2(lbl, `XDG empty: got [{empty_xdg}], wanted [{want_plain}]`); return }
ok(lbl)
}
# `.packignore` — the pack's exclude file, with gitignore's semantics. The feature it
# exists for is that a pack root is packed wholesale: the preview renders, bake
# intermediates and .blend files a model pipeline leaves beside its meshes all shipped,
# and the only alternative the manifest offered was naming every file by hand.
#
# The patterns below are one of each kind that people actually rely on, and the assert
# is the exact set that comes out - both halves matter, because a rule that excludes too
# much is as bad as one that excludes nothing and is just as quiet.
function packignore_case() -> void {
let lbl = "ludic pack -> .packignore excludes, with gitignore's rules"
let work = `{tmp_dir()}/packign`
let root = capture_line("pwd")
shell(`rm -rf {work}`)
shell(`mkdir -p {work}/assets/kit/preview {work}/assets/deep/a/tmp {work}/assets/scratch {work}/assets/sub/scratch {work}/assets/keep`)
write_file(`{work}/assets/kit/preview/x.png`, "x") # preview/ - a directory, any depth
write_file(`{work}/assets/kit/model.blend`, "x") # *.blend - a name, any depth
write_file(`{work}/assets/kit/hiker.png`, "x") # kept
write_file(`{work}/assets/deep/a/tmp/junk.txt`, "x") # **/tmp
write_file(`{work}/assets/deep/a/keep.txt`, "x") # kept
write_file(`{work}/assets/scratch/s.txt`, "x") # /assets/scratch - anchored
write_file(`{work}/assets/sub/scratch/keep.txt`, "x") # kept: the anchor does not reach here
write_file(`{work}/assets/keep/other.png`, "x") # assets/keep/*.png
write_file(`{work}/assets/keep/important.png`, "x") # kept: re-included by !
write_file(`{work}/assets/preview`, "x") # kept: a FILE, and the rule says directory
var ig = "# what nobody opens" + nl()
ig = ig + "preview/" + nl() + "*.blend" + nl() + "/assets/scratch" + nl() + "**/tmp" + nl()
ig = ig + "assets/keep/*.png" + nl() + "!assets/keep/important.png" + nl()
write_file(`{work}/.packignore`, ig)
if not shq(`cd {work} && {root}/bin/ludic pack --out o.lpak assets > pack.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack.out`)); return
}
# cut, not awk: `--list` prints "<size>\t<name>" and awk's braces collide with string
# interpolation. capture_line joins the lines with spaces, which is the comparison.
let got = capture_line(`cd {work} && {root}/bin/ludic pack --list o.lpak | tail -n +2 | cut -f2 | LC_ALL=C sort`)
let want = "assets/deep/a/keep.txt assets/keep/important.png assets/kit/hiker.png assets/preview assets/sub/scratch/keep.txt"
if got != want {
bad2(lbl, `packed [{got}], wanted [{want}]`); return
}
# a deeper .packignore wins over a shallower one, which is what makes a kit able to
# keep something the project excludes everywhere else
write_file(`{work}/assets/kit/.packignore`, "!*.blend" + nl())
if not shq(`cd {work} && {root}/bin/ludic pack --out o2.lpak assets > pack2.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack2.out`)); return
}
if not shq(`cd {work} && {root}/bin/ludic pack --list o2.lpak | grep -q 'assets/kit/model.blend'`) {
bad2(lbl, "a nested !*.blend did not win over the root rule"); return
}
# and an ignore file is never itself an asset, whatever the rules say
if shq(`cd {work} && {root}/bin/ludic pack --list o2.lpak | grep -q packignore`) {
bad2(lbl, ".packignore was packed"); return
}
# --no-ignore is the escape hatch: everything, minus the control files
if not shq(`cd {work} && {root}/bin/ludic pack --no-ignore --out o3.lpak assets > pack3.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack3.out`)); return
}
let n3 = capture_line(`cd {work} && {root}/bin/ludic pack --list o3.lpak | tail -n +2 | wc -l | tr -d ' '`)
if n3 != "10" {
bad2(lbl, `--no-ignore packed {n3} files, wanted 10`); return
}
ok(lbl)
}
# `ludic bundle` has to produce something macOS actually accepts as an
# application, not a directory that looks like one: a plist that plutil parses,
# an executable where Info.plist says it is, an .icns, and a signature. The
# app's own assets go in the pack beside it, and the display name is allowed a
# space - which is where the shell quoting in the bundler earns its keep.
function bundle_case() -> void {
let lbl = "ludic bundle -> a .app with metadata, icon, assets and a signature"
if not is_darwin() { skip(lbl); return }
let work = `{tmp_dir()}/bundle`
let root = capture_line("pwd")
shell(`rm -rf {work} && mkdir -p {work}/src {work}/assets`)
write_file(`{work}/assets/hello.txt`, "packed")
# a 64x64 source icon: small, but sips will scale it and iconutil accept it
shell(`sips -s format png --resampleHeightWidth 64 64 /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns --out {work}/assets/icon.png > /dev/null 2>&1`)
write_file(`{work}/package.ludic`, bundle_manifest_src())
write_file(`{work}/src/main.ludic`, bundle_probe_src())
if not shq(`cd {work} && {root}/bin/ludic bundle > bundle.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/bundle.out`)); return
}
let app = `{work}/build/Probe App.app`
# quoted: the display name has a space in it, which is the whole point, and
# the prelude's file_exists builds an unquoted `test -e`
if not there(`{app}/Contents/MacOS/Probe App`) { bad2(lbl, "no executable in Contents/MacOS"); return }
if not there(`{app}/Contents/Resources/game.lpak`) { bad2(lbl, "no asset pack"); return }
if not there(`{app}/Contents/Resources/packs.index`) { bad2(lbl, "no packs.index"); return }
if not there(`{app}/Contents/Resources/AppIcon.icns`) { bad2(lbl, "no icon"); return }
if not there(`{app}/Contents/PkgInfo`) { bad2(lbl, "no PkgInfo"); return }
if not shq(`plutil -lint "{app}/Contents/Info.plist" > /dev/null 2>&1`) {
bad2(lbl, "Info.plist does not parse"); return
}
# the identifier is derived from the package path when the manifest omits it
let id = capture_line(`plutil -extract CFBundleIdentifier raw "{app}/Contents/Info.plist" 2>/dev/null`)
if id != "io.workshopsoft.probe-app" { bad2(lbl, `bundle id came out [{id}]`); return }
if not shq(`codesign --verify "{app}" > /dev/null 2>&1`) { bad2(lbl, "the signature does not verify"); return }
# and it runs from a directory with none of its assets, reading them from the pack
let got = s_trim(capture(`cd / && "{app}/Contents/MacOS/Probe App" 2>&1`))
if got != "packed" { bad2(lbl, `the bundled app printed [{got}], wanted [packed]`); return }
ok(lbl)
}
# `test -e` with the path quoted, so a bundle whose name contains a space can
# be inspected at all
function there(path: pointer) -> bool { return shq(`test -e "{path}"`) }
function bundle_manifest_src() -> pointer {
var s = "package \"git.workshopsoft.io/workshopsoft/probe-app\"" + nl()
s = s + "version \"2.1.0\"" + nl()
s = s + "kind source" + nl()
s = s + "app name \"Probe App\"" + nl()
s = s + "app icon \"assets/icon.png\"" + nl()
s = s + "app copyright \"(c) 2026 A & B\"" + nl()
s = s + "pack \"assets\"" + nl()
return s
}
function bundle_probe_src() -> pointer {
var s = "program Probe {" + nl()
s = s + " entry {" + nl()
s = s + " var t = Fs.read_text(\"assets/hello.txt\")" + nl()
s = s + " if t == null { t = \"MISS\" }" + nl()
s = s + " print(t)" + nl()
s = s + " }" + nl()
s = s + "}" + nl()
return s
}
function scaffold_names_case() -> void {
let lbl = "ludic new scaffolds a project that compiles (hyphens, digits, dots)"
let work = `{tmp_dir()}/scaffold`
shell(`rm -rf {work} && mkdir -p {work}`)
let root = capture_line("pwd")
let names = new []pointer
push(names, "my-game")
push(names, "2048")
push(names, "a.b.c")
push(names, "UPPER_case")
var i = 0
while i < len(names) {
let nm = names[i]
i += 1
if not shq(`cd {work} && {root}/bin/ludic new {nm} > {work}/new.out 2>&1`) {
bad2(lbl, `ludic new {nm} failed`); return
}
if not shq(`cd {work}/{nm} && {root}/bin/ludic build --headless > {work}/build.out 2>&1`) {
let why = capture_line(`tail -1 {work}/build.out`)
bad2(lbl, `{nm}: {why}`); return
}
if not shq(`cd {work}/{nm} && {root}/bin/ludic test > {work}/test.out 2>&1`) {
bad2(lbl, `{nm}: tests failed`); return
}
}
# and a name that cannot be a directory or a package is refused, not mangled
if shq(`cd {work} && {root}/bin/ludic new 'two words' > {work}/bad.out 2>&1`) {
bad2(lbl, "a name with a space was accepted"); return
}
ok(lbl)
}
# a "does it still compile" smoke test (parse -> lower -> link), no run
function qsmoke(path: pointer) -> void {
let nm = flat(path)
if game_build_ok("bin/ludicc", `examples/{path}.ludic`, `{tmp_dir()}/q_{nm}`) {
ok(`{path} compiles (@Queries desugars to S_QUERY)`)
} else { bad2(path, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)) }
}
# a plain compile-only rot guard for a showcase example that has no self-asserting
# `entry` (so net_case can't drive it): prove it still parses, lowers and links.
function smoke(path: pointer) -> void {
let nm = flat(path)
if not shq(`bin/ludicc --headless examples/{path}.ludic --emit-llvm -o {tmp_dir()}/sm_{nm}.ll > {tmp_dir()}/sm_{nm}.err 2>&1`) {
bad2(path, capture_line(`tail -1 {tmp_dir()}/sm_{nm}.err`)); return
}
ok(`{path} still compiles`)
}
# a pure-Ludic example that drives and asserts itself: compile headless to IR,
# let clang assemble it, run, and compare stdout. No C host and no C compiled.
function net_case(path: pointer, exp: pointer) -> void {
let nm = flat(path)
let ll = `{tmp_dir()}/n_{nm}.ll`
let log = `{tmp_dir()}/n_{nm}.out`
if not shq(`bin/ludicc --headless examples/{path}.ludic --emit-llvm -o {ll} > {log} 2>&1`) {
let t = capture_line(`tail -1 {log}`)
bad2(path, `build ({t})`); return
}
if not shq(`{cc()} -O2 {ll} -o {tmp_dir()}/nb_{nm} >> {log} 2>&1`) {
let t = capture_line(`tail -1 {log}`)
bad2(path, `build ({t})`); return
}
let got = capture_line(`{tmp_dir()}/nb_{nm} < /dev/null`)
if (got == exp) { ok(`{path} ({got})`) } else { bad2(path, `got [{got}] want [{exp}]`) }
}
# a test spec (issue #12): `test "name" { expect… }` blocks with no `entry`. The
# compiler synthesises a runner @main, so we just build, run, and require a clean
# exit (0) plus the expected `== N passed, M failed ==` summary on the last line.
function spec_case(path: pointer, exp: pointer) -> void {
let nm = flat(path)
let ll = `{tmp_dir()}/spec_{nm}.ll`
let er = `{tmp_dir()}/spec_{nm}.err`
if not shq(`bin/ludicc examples/{path}.ludic > {ll} 2>{er}`) {
bad2(path, capture_line(`tail -1 {er}`)); return
}
if not shq(`{cc()} -O2 {ll} -o {tmp_dir()}/spec_{nm} 2>{er}`) {
bad2(path, capture_line(`tail -1 {er}`)); return
}
let rc = sh(`{tmp_dir()}/spec_{nm} > {tmp_dir()}/spec_{nm}.out 2>&1`)
let summary = capture_line(`tail -1 {tmp_dir()}/spec_{nm}.out`)
if (rc == 0) and (summary == exp) { ok(`{path} ({summary})`) }
else { bad2(path, `rc={string(rc)} last=[{summary}]`) }
}
# issue #8: the failure path — a panic must abort with a non-zero exit and a
# `file:line: panic: <msg>` line on stderr (stdout is unaffected up to the panic).
function panic_case() -> void {
write_file(`{tmp_dir()}/panic.ludic`, "program P { entry { print(7); panic(\"boom\") } }\n")
if not shq(`bin/ludicc {tmp_dir()}/panic.ludic > {tmp_dir()}/panic.ll 2>/dev/null`) { bad("panic example did not compile"); return }
if not shq(`{cc()} -O2 {tmp_dir()}/panic.ll -o {tmp_dir()}/panic 2>/dev/null`) { bad("panic example did not link"); return }
let rc = sh(`{tmp_dir()}/panic > {tmp_dir()}/panic.out 2>{tmp_dir()}/panic.err`)
let msg = capture_line(`cat {tmp_dir()}/panic.err`)
if (rc != 0) and shq(`grep -q 'panic: boom' {tmp_dir()}/panic.err`) { ok(`panic aborts non-zero with a located stderr message ({msg})`) }
else { bad2("panic", `rc={string(rc)} err=[{msg}]`) }
}
# issue #45: `bin/ludic-dev test --coverage`. Compile each test-spec with `--coverage`,
# run it with LUDIC_COVERAGE pointed at a per-file dump, then aggregate the dumps
# into a clean per-file line-coverage report. The instrumentation is flag-gated,
# so this reuses the same seed-built bin/ludicc the rest of the suite does.
function cov_one(path: pointer, dir: pointer) -> int {
let nm = flat(path)
let ll = `{tmp_dir()}/cov_{nm}.ll`
let bin = `{tmp_dir()}/cov_{nm}`
let cov = `{dir}/{nm}.cov`
if not shq(`bin/ludicc --coverage examples/{path}.ludic > {ll} 2>{tmp_dir()}/cov.err`) {
bad2(path, capture_line(`tail -1 {tmp_dir()}/cov.err`)); return 1
}
if not shq(`{cc()} -O2 {ll} -o {bin} 2>{tmp_dir()}/cov.err`) {
bad2(path, capture_line(`tail -1 {tmp_dir()}/cov.err`)); return 1
}
shell(`rm -f {ll}`)
# run the spec; its atexit hook writes the dump to $LUDIC_COVERAGE
if not shq(`LUDIC_COVERAGE={cov} {bin} > /dev/null 2>&1`) {
bad2(path, "spec exited non-zero"); return 1
}
if not file_exists(cov) { bad2(path, "no coverage dump written"); return 1 }
# aggregate: total instrumented lines, how many were hit, and which were missed.
# each row is `<line> <hits>`, so an unhit line ends in " 0" — grep counts and
# lists them (avoiding awk, whose braces collide with string interpolation).
let total = str_to_int(capture_line(`grep -c '^[0-9]' {cov}`))
let nmiss = str_to_int(capture_line(`grep -c ' 0$' {cov}`))
let covered = total - nmiss
let missed = capture_line(`grep ' 0$' {cov} | cut -d' ' -f1 | tr '\n' ' '`)
var pct = 100
if total > 0 { pct = (covered * 100) / total }
var line = ` examples/{path}.ludic {string(covered)}/{string(total)} lines {string(pct)}%`
if not (missed == "") { line = `{line} uncovered: {missed}` }
print(line)
COV_COVERED += covered
COV_TOTAL += total
return 0
}
var COV_COVERED: int = 0
var COV_TOTAL: int = 0
function cmd_test_coverage() -> int {
# the suite normally rebuilds bin/ludicc from the seed; do the same here so the
# instrumentation path is exactly what a clean checkout ships.
shell(`mkdir -p bin build {tmp_dir()}/cov_out`)
if (not is_exec("bin/ludicc")) or newer("selfhost/ludicc.seed.ll", "bin/ludicc") {
if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) { err("ludic-dev: cannot build bin/ludicc from the seed\n"); return 1 }
}
COV_COVERED = 0
COV_TOTAL = 0
print("== line coverage (bin/ludic-dev test --coverage) ==")
cov_one("library/coverage", `{tmp_dir()}/cov_out`)
cov_one("library/testing", `{tmp_dir()}/cov_out`)
var pct = 100
if COV_TOTAL > 0 { pct = (COV_COVERED * 100) / COV_TOTAL }
print(` ----`)
print(` total: {string(COV_COVERED)}/{string(COV_TOTAL)} lines {string(pct)}%`)
return 0
}
function cmd_dev_test() -> int {
PASS = 0
FAIL = 0
print("== the compiler is written in Ludic, not C ==")
if shq("test -e compiler") or shq("ls compiler/*.c >/dev/null 2>&1") {
bad("compiler/ C sources still present")
} else { ok("no C compiler in the repo (the compiler is selfhost/*.ludic)") }
let cleft = capture_line("find tools -name '*.c' 2>/dev/null | wc -l")
ok(`the only C in the tree is any editor tooling ({cleft} .c files)`)
print("== the compiler builds from the IR seed (no C compiler) ==")
shell("rm -f bin/ludicc")
if shq("bin/ludic build examples/games/snake.ludic --headless >/dev/null 2>&1") and is_exec("bin/ludicc") {
ok("clang selfhost/ludicc.seed.ll -> bin/ludicc")
} else { bad("compiler did not build from the seed") }
print("== every example compiles to its golden render ==")
game_case("games/snake", "ddssaawwddss")
game_case("games/menu", "ssss")
game_case("games/chronorift", "ddddwwwwaassK")
print("== self-hosting and the bootstrap fixpoints ==")
shell(`bin/ludic-dev selfhost-test > {tmp_dir()}/sh.out 2>&1`)
if shq(`grep -q 'gen2.ll == gen3.ll' {tmp_dir()}/sh.out`) { ok("the compiler reproduces itself (fixpoint)") } else { bad("fixpoint") }
if shq(`grep -q 'no C compiler' {tmp_dir()}/sh.out`) { ok("the compiler rebuilds itself from the seed, C-free") } else { bad("C-free bootstrap") }
let corr = capture_line(`grep -c PASS {tmp_dir()}/sh.out`)
ok(`self-host suite: {corr} checks passed (see: ludic-dev selfhost-test)`)
print("== documented syntax stays compilable (guards against spec/compiler drift) ==")
qsmoke("lang/qdecl")
feat_case("lang/annotations", "", "3 25 0 0", "annotations.ludic (@Queries / @Computed / @OnSpawn / @Handles)")
feat_case("lang/lifecycle", "", "1 700 50 950 2", "lifecycle.ludic (@OnStart/@OnAttach/@OnSpawn/@OnDespawn/@OnQuit in order)")
feat_case("lang/toggle", "", "6 0 7 1 0", "toggle.ludic (enable/disable + @OnDisable/@OnEnable across property/model)")
feat_case("lang/strings", "", "1 2 3 4 5 6 7 8 9", "strings.ludic (str ops, interpolation, slicing)")
feat_case("lang/operators", "", "1 2 3 4 5 6 7 8 9 10", "operators.ludic (compound assignment on fixed/string/long, -fixed, char escapes, list literals)")
feat_case("lang/rng_demo", "", "69 89 6 -1 0", "rng_demo.ludic (Random.value/int/sign)")
feat_case("lang/time_demo", "aaaaaa", "0 1 2 3 4 16", "time_demo.ludic (Time.frame/elapsed/delta)")
feat_case("lang/scenes", "aaaaaaaaaa", "1000 1 101 102 2 3 900 201 900 202 900", "scenes.ludic (scene/layer/on enter/on exit/become)")
feat_case("lang/detach", "", "15 1 25 0", "detach.ludic (attach/detach + @OnAttach/@OnDetach)")
feat_case("lang/reason", "", "503 1009", "reason.ludic (@OnDespawn reason: Despawned vs Quit)")
# standard-library namespaces, each a self-contained `entry` program asserted
# against known-answer vectors (crypto) or structural invariants (uuid/noise).
feat_case("library/crypto", "", "1 2 3 4 5 6 7 8 9", "crypto.ludic (Crypto SHA-256/HMAC/base64 KAT + CSPRNG shape)")
feat_case("library/uuid", "", "1 2 3 4 5 6 7 8 9 10", "uuid.ludic (Uuid v4/v7 format, version/variant, parse/equals)")
feat_case("library/noise", "", "1 2 3 4 5 6 7 8 9 10 11", "noise.ludic (Noise value/perlin/simplex/fbm/cellular determinism + range)")
feat_case("library/bignum", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "bignum.ludic (BigInt arbitrary-precision + Decimal exact base-10 money)")
feat_case("library/containers", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "containers.ludic (Dict string-keyed hash map + Set string set)")
feat_case("library/numeric", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "numeric.ludic (Huge idle big-numbers + Angle wrapping radians + Percent clamped [0,1])")
feat_case("library/jobs", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31", "jobs.ludic (Job background compute/defer/fulfill/cancel + Promise all/race/progress + Sync mutex/atomic/channel; issue #14)")
feat_case("library/threads", "", "1 2 3 4 5 6 7", "threads.ludic (Job.parallel_for on OS threads + Sync atomic/mutex shared across workers + fn name worker entry)")
feat_case("library/optionresult", "", "1 2 3 4 5 6 7 8 9 10 11 12", "optionresult.ludic (option some/none + result ok/err/try safety types)")
feat_case("library/regex", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "regex.ludic (Regex match/find/groups/classes/quantifiers/replace + linear-time safety)")
feat_case("library/grid", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "grid.ludic (Grid line/flood/line_of_sight + A* pathfinding over the tilemap)")
feat_case("library/anim", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34", "anim.ludic (Anim frame/once/pingpong/cell + Tween progress/loop/yoyo/ease/number/round/point/tint)")
feat_case("library/anim_sugar", "", "4 8 2 1 0 100 100 0 0 1 20 20 30 0 1", "anim_sugar.ludic (Anim.clip/play/on_frame/fired + Motion.to + fluent Tween.to/chain/delay/parallel handles; issue #48)")
feat_case("library/namespace_block", "", "15 42 100", "namespace_block.ludic (#76 namespace Name { export/internal function } block — declares the namespace once, controls the public surface)")
feat_case("library/preload", "", "3 0 0 0 1 33 66 1 100 1", "preload.ludic (#82 Assets.enqueue/pump/progress/ready — incremental asset preload for a loading scene)")
feat_case("library/pool", "", "0 0 3 3 0 2 1 3 0 3 1", "pool.ludic (#80 Pool.live/free/reserved/capacity — the ECS freelist recycles despawned slots, reserved stays flat)")
feat_case("library/atlas", "", "16 16 32 48 1 1 1 16 1", "atlas.ludic (#81 Sprite.sheet/cell/cell_span/define/named + Assets.image/get — namespaced spritesheet/atlas with multi-cell sprites)")
feat_case("library/camera_zoom", "", "1 0 0 1 1", "camera_zoom.ludic (Camera.zoom deterministic Q16.16 render-time zoom about the screen centre, verified by pixel readback; issue #78)")
feat_case("library/clear_color", "q", "1 1", "clear_color.ludic (@ClearColor: the Render phase auto-clears to the declared colour + auto-presents, no Screen.clear/show in the handler; issue #86)")
feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)")
feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)")
feat_case("library/serialize", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "serialize.ludic (Value tree + Json encode/parse + Reflect.serialize/apply — bit-exact save/load; issue #44)")
feat_case("library/tiled_p0", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21", "tiled_p0.ludic (Tiled P0: Xml reader + Base64 decode/encode + gzip framing over inflate; issue #67)")
feat_case("library/tiled_p05", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30", "tiled_p05.ludic (Tiled P0.5: TMX/TSX reader -> same Value-tree intermediate as TMJ; Kenney sampleMap.tmx; issue #68)")
feat_case("library/tiled_p1", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p1.ludic (Tiled P1: rt_tmap model + GID resolver/flip decode + render; loads+draws Kenney map identically from .tmx and .tmj; issue #69)")
feat_case("library/tiled_p2", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p2.ludic (Tiled P2: per-tile objectgroup / property-convention / collision-layer -> Solids feed; A* baseline; issue #70)")
feat_case("library/tiled_p3", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p3.ludic (Tiled P3: animated tiles -> deterministic frame clock + tile objects w/ flips; beach_tileset 33 anims; issue #71)")
feat_case("library/tiled_p4", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "tiled_p4.ludic (Tiled P4: object shapes/text + custom props/types + templates + opt-in spawn; issue #72)")
feat_case("library/tiled_p5", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p5.ludic (Tiled P5: image/group layers + iso/hex/staggered coords + Wang GID resolution; issue #73)")
feat_case("library/tiled_p6", "", "1 2 3 4 5 6 7 8 9 10 11 12", "tiled_p6.ludic (Tiled P6: infinite/chunked maps + .world stitching + base64+zstd decompression; issue #74)")
feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)")
feat_case("library/lighting", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "lighting.ludic (Light ambient/point radial falloff + occluder hard shadows — 2D light accumulation, verified by pixel readback)")
feat_case("library/light_tiers", "", "1 1 1 1 1 1 1 1 1", "light_tiers.ludic (Light spot/falloff/soft/gel/normal/time_of_day — render-quality tiers 3-4; issue #49)")
feat_case("library/physics", "", "184 1 1 284 1 1 50 51 104 1 134 1 120 0 1 1 1 0 1 1 1", "physics.ludic (Body + Collider + esys_move: gravity/land, wall stop, sub-pixel, entity platform, one-way, triggers — entity broadphase; issue #65)")
feat_case("library/physics_tiles", "", "112 1 80 1 48 1 60", "physics_tiles.ludic (esys_move tile-grid broadphase: tile floor/wall + one-way tile land-from-above / rise-through; issue #65)")
runtime_home_case() # #75: engine runtime resolves from LUDIC_HOME, not the project
# builtin gameplay controllers, consumed as in-repo packages (issues #57-#61)
controller_case("library/core_components", "", "110 30 184 1", "core_components.ludic (ludic.core #77: canonical Position/Body/Collider imported from a package, driving esys_move, composed with a game Health component)")
controller_case("library/sprite_render", "q", "1 0", "sprite_render.ludic (ludic.core #85: engine sprite-render system auto-draws a Sprite entity from its Position; respects hidden)")
controller_case("library/sprite_atlas", "q", "32 1 1 1 0", "sprite_atlas.ludic (#90 Sprite { atlas: 1 } routes esys_sprite through atlas_draw_ex: a 16x32 cell_span draws both cells, tint applies, atlas: 0 stays a table id)")
controller_case("library/world_bounds", "", "184 -16 184 1 0", "world_bounds.ludic (ludic.core #84: engine world-bounds system — clamp/wrap/bounce/kill keep a Body in the play area)")
controller_case("library/gameplay_foundation", "", "2 0 1 5 24 0 2 1 15 85 7 1 1", "gameplay_foundation.ludic (ludic.gameplay #57: Cooldown timer + Stats/modifier stack + Faction table + Combat cancel/mutable pipeline)")
controller_case("games/platformer_demo", "", "1 1 1 1 1 1 1 1", "platformer_demo.ludic (ludic.platformer #58: gravity/land, jump apex, coyote, veto-gated double jump, wall collision + disable-system lever)")
controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)")
controller_case("games/shooter_demo", "", "1 1 1 1 1 1 1 1 1 1 1", "shooter_demo.ludic (ludic.shooter #60: decoupled move/aim, weapon registry, projectile faction-hit, spread/ring, homing, wave spawner)")
controller_case("games/npcai_demo", "", "1 1 1 1 1 1 1 1", "npcai_demo.ludic (ludic.npcai #61: perception/spot, FSM chase+attack driving the shooter weapon, utility flee, companion follower)")
controller_case("library/prefabs", "", "230159 30 0 7 44 -1 2", "prefabs.ludic (prefab chains, spawn as an expression, Prefab.spawn / spawn_at by name, self() in @OnSpawn)")
controller_case("library/component_access", "xxxxxxxx", "9 15 10 33 1 0 0 1 0 1 101 100 102 1 2 1 0", "component_access.ludic (Prop.of/has/count/despawn_all, countdown fields, enum-typed machine)")
controller_case("library/scene_menus", "xxxxxxxxxxxx", "100 200 201 1 202 300 6 7 8", "scene_menus.ludic (become from a listener, layer @Queries, scene shows / lasts, goto: buttons)")
controller_case("library/managers", "xxxxxxxxxxxx", "35046 46 251 53 900 -1 20 324 0 10 102", "managers.ludic (Map cell API, Sprite move/face/flash/blink, IVec2 geometry, List.sample, Input.move_i, Fx, audio bank, Camera.shake_for)")
controller_case("library/combat_kit", "xxxxxxxxxxxx", "6846 153196 31 107110 6000 5002 1 80 97 11 10 700 110", "combat_kit.ludic (Dash, Melee + MeleeHit, build stats in Combat.damage + Crit, Stats.add, Dungeon arena / exits / entry points, Prefs.max)")
controller_case("games/rpg_demo", "", "1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1", "rpg_demo.ludic (ludic.rpg #59 all 7 modules: grid move+veto+interact, inventory+equip, crafting, quests, dialog graph, sokoban+signal gate, poison status)")
spec_case("library/testing", "== 6 passed, 0 failed ==")
spec_case("library/coverage", "== 3 passed, 0 failed ==")
feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")
feat_case("library/recover", "", "2600 100 1 1 42 7", "recover.ludic (try/else recovers a result value + fallback, error binding, is_ok/is_err; issue #46)")
panic_case()
feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)")
# Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so
# their asserted values are macOS-specific; skip off Darwin (see is_darwin).
if is_darwin() {
feat_case("library/os", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "os.ludic (Os args/env round-trip, platform/arch, known dirs, pid)")
} else { skip("os.ludic (Os known-folder / uname ABI is macOS/BSD-specific)") }
feat_case("library/unicode", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "unicode.ludic (UTF-8 len/validate/char_at/chars/case/truncate/graphemes)")
if is_darwin() {
feat_case("library/fs", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32", "fs.ludic (Path join/dir/base/ext/normalize, Fs read/write/list/copy, Mime of/sniff)")
} else { skip("fs.ludic (Fs.list reads the macOS/BSD dirent layout)") }
# issue #9: the Time/Date/Duration/Clock stdlib, driven from its own `entry`.
net_case("lang/offline_rewards", "13 650 2026-08-30 0")
# EV0b + NETWORKING N0–N6: each a self-contained pure-Ludic program, driven and
# asserted from its own `entry`. The transport is the compiler's built-in
# loopback, so a networked game runs with zero foreign code.
net_case("events/mod_events", "10 32 42")
net_case("networking/net_echo", "4 10 20 30 42")
net_case("networking/net_snapshot", "50 7 50")
net_case("networking/net_sync", "12 3 4 50 999")
net_case("networking/net_owner", "-1 7 0 1")
net_case("networking/net_rpc", "0 8")
net_case("networking/net_roles", "1 102")
net_case("networking/net_demo", "5 999 5")
feat_case("events/promote", "", "100 101 200 201", "promote.ludic (@Public -> model_<M>_spawn/despawn events)")
feat_case("events/cancel", "", "0 1 92", "cancel.ludic (cancellable event + cancel + emit-as-expression)")
# EV2 the world table: the mod reflection ABI, callable from Ludic by name.
net_case("ecs/world_get", "50 1 7")
net_case("ecs/world_scan", "2 110")
net_case("ecs/world_spawn", "1 42 1")
net_case("ecs/world_mixed", "99")
net_case("ecs/world_query", "2 110")
# #43: engine-owned systems auto-advance the SpriteAnim / Motion components a
# game merely declares and carries — no handler wired. Entry-driven so it can
# assert the auto-advanced fields at known tick counts.
net_case("library/anim_ecs", "1 5 2 10 0 2 10 1 1")
# #47: the engine light pass consumes Light2D / Occluder / Ambient components at
# the end of the Render phase — ambient tint, additive glow, hard shadow — with
# no Light.* calls wired. Sampled back off the framebuffer with Screen.pixel.
net_case("library/light_ecs", "32 1 32 1")
# #7: action maps (read named actions, not keys), runtime rebinding, and
# deterministic input record/replay — fed one key per poll from stdin.
feat_case("library/input_actions", " xwa", "1 0 1 1 0 1 0", "input_actions.ludic (#7 action maps + rebinding + deterministic replay)")
feat_case("library/input_device", "", "1 1 0 1 0 1 71 -71 5 1 3 1 2 1 0 0 1", "input_device.ludic (#50 multi-key held state + analog axis/vector + mouse + gamepad/touch + full-state replay)")
feat_case("library/input_mouse_rebase", "", "0 0 5 -2 0 0 4 3 6 0 0 0", "input_mouse_rebase.ludic (mouse_dx/dy report no motion on the first frame or across a cursor-mode change)")
feat_case("library/input_manager", "", "1 1 1 0 1 0 0 1", "input_manager.ludic (#83 Input Manager: default bindings + device-agnostic actions across keyboard/pad + on-press/on-release edges)")
feat_case("library/input_auto", "llq", "2", "input_auto.ludic (#83 the frame loop auto-commits the device layer: Input.active fires with no manual Input.poll)")
feat_case("library/input_edge", "xkkxq", "0 11 1 100 0", "input_edge.ludic (#87 Input.key_pressed/key_released edges fire on the transition frame; no double-commit under the frame loop)")
feat_case("library/input_movement", "", "1 0 -1 -1 0", "input_movement.ludic (#79 Input.axis_i turns WASD into a -1/0/1 movement intent, no bool->int glue)")
feat_case("library/input_arrows", "", "128 129 130 131 1 0 -1 1 0", "input_arrows.ludic (Key.Up/Down/Left/Right are the held set's 128-131: key_down and move_i take the arrows)")
feat_case("library/input_key_label", "", "W A ; 1 Space Esc Shift Left Right 0 1", "input_key_label.ludic (Input.key_label names a physical key: the US character headless, words for the named keys and arrows)")
feat_case("library/audio", "", "0 0 0 0 1", "audio.ludic (#22 Audio.* load/play/music/volume/pitch/stop/is_playing — headless no-op)")
# #6 Http.* — the client links Foundation (macOS-only), so build it through the
# canonical `ludicc -o` path (which wires the framework) and gate on Darwin. The
# run exercises the pure-Ludic response parser; no network is touched.
if is_darwin() {
if shq(`LUDIC_HOME=. bin/ludicc examples/library/http.ludic -o {tmp_dir()}/http_demo > {tmp_dir()}/http.out 2>&1`) {
let got = capture_line(`{tmp_dir()}/http_demo < /dev/null`)
if (got == "200 1 5 hello 42 text/plain 404 0") { ok("http.ludic (#6 Http.parse/status/ok/text/header — pure response parser)") }
else { bad2("http.ludic", `got [{got}]`) }
} else { bad2("http.ludic build", capture_line(`tail -1 {tmp_dir()}/http.out`)) }
# and through `ludic build`, which is how a project builds: compile_app used to link
# gl.ll when a program named @lgl_* but never http.ll for @hs_*, so this failed to link
if shq(`LUDIC_HOME=. bin/ludic build examples/library/http.ludic --headless -o {tmp_dir()}/http_lb > {tmp_dir()}/http_lb.out 2>&1`) {
let got2 = capture_line(`{tmp_dir()}/http_lb < /dev/null`)
if (got2 == "200 1 5 hello 42 text/plain 404 0") { ok("ludic build links http.ll for a program that uses Http.*") }
else { bad2("http.ludic via ludic build", `got [{got2}]`) }
} else { bad2("ludic build of an Http.* program", capture_line(`tail -1 {tmp_dir()}/http_lb.out`)) }
}
feat_case("events/recurse", "", "16", "recurse.ludic (EV6: re-entrant emit is depth-bounded, no runaway cycle)")
net_case("events/scoped", "2")
net_case("ecs/world_dyn", "0 1 30 100 1 30")
feat_case("events/prop_events", "", "300 400", "prop_events.ludic (@Public -> prop_<P>_attach/detach events)")
feat_case("events/scene_events", "", "10 1 20 2 30 3", "scene_events.ludic (public scene -> scene_<S>_enter/exit events)")
feat_case("events/program_events", "", "1 100 2 200", "program_events.ludic (@Public @OnStart/@OnQuit -> program_start/quit)")
feat_case("events/layer_events", "aaaa", "50 2 1 50", "layer_events.ludic (layer toggle + public layer -> layer_<L>_show/hide)")
# showcase examples with no self-asserting `entry` — compile-only so they can't
# silently rot after a compiler or reorg change.
print("== showcase examples still compile ==")
smoke("ecs/hello")
smoke("events/events")
smoke("networking/net_rt")
smoke("library/cursor_capture") # #89 Input.cursor_mode compiles (no-op headless; windowed links cocoa.ll)
smoke("rendering/gl_triangle") # Gl.* (OpenGL 4.1 core) compiles headless; the run needs a GPU context
print("== the compiler and the CLI (ludicc / ludic) ==")
# ludicc comes out of the IR seed with clang alone; the CLI is then compiled
# by it, from Ludic.
if shq(`bin/ludic-dev build-cli > {tmp_dir()}/cli.out 2>&1`) and is_exec("bin/ludicc") {
ok("ludic-dev build-cli builds bin/ludicc from the seed (clang-only)")
} else { bad2("ludic-dev build-cli", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
# ludicc -o drives clang to a native binary. A game links windowed by default
# (LUDIC_HOME points the linker at runtime/native/cocoa.ll) — that path is
# macOS-only (Cocoa), so off Darwin we prove the same "-o yields a runnable
# native binary" with a headless link, which needs no window backend.
if is_darwin() {
if shq(`LUDIC_HOME=. bin/ludicc examples/games/snake.ludic -o {tmp_dir()}/cli_snake > {tmp_dir()}/cli.out 2>&1`) and shq(`file {tmp_dir()}/cli_snake 2>/dev/null | grep -q Mach-O`) {
ok("ludicc app.ludic -o bin -> native executable")
} else { bad2("ludicc -o", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
# #51 — a windowed build must load GameController so the native gamepad
# polling (win_pad, reached on the is_windowed branch) can see controllers.
if shq(`otool -L {tmp_dir()}/cli_snake 2>/dev/null | grep -q GameController`) {
ok("windowed link loads GameController (#51 gamepad binding)")
} else { bad("windowed binary is missing the GameController load command") }
} else {
if shq(`LUDIC_HOME=. bin/ludicc --headless examples/games/snake.ludic -o {tmp_dir()}/cli_snake > {tmp_dir()}/cli.out 2>&1`) and shq(`test -x {tmp_dir()}/cli_snake`) {
ok("ludicc app.ludic -o bin -> native executable (headless)")
} else { bad2("ludicc -o", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
}
# --emit-llvm stops at the IR.
if shq(`bin/ludicc examples/games/snake.ludic --emit-llvm -o {tmp_dir()}/cli_snake.ll > {tmp_dir()}/cli.out 2>&1`) and shq(`head -1 {tmp_dir()}/cli_snake.ll | grep -q 'LLVM IR'`) {
ok("ludicc --emit-llvm -> LLVM IR")
} else { bad2("ludicc --emit-llvm", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
# `ludic <file>` compiles, runs, and forwards the program's exit code.
write_file(`{tmp_dir()}/cli_exit.ludic`, "entry { exit(42) }\n")
let rc = sh(`bin/ludic {tmp_dir()}/cli_exit.ludic --headless > {tmp_dir()}/cli.out 2>&1`)
if (rc == 42) { ok("ludic app.ludic -> compiles, runs, forwards exit code") }
else { bad2("ludic run: expected exit 42", `got {string(rc)}`) }
# The CLI has to work as an install does: from another directory, with no
# ./bin, finding its compiler, the engine runtime and the bundled packages
# from its own location. This is the shape `curl … | sh` produces.
install_layout_case()
scaffold_names_case()
pack_roundtrip_case()
packignore_case()
os_dirs_case()
app_icon_case()
bundle_case()
# install.sh is what the landing page tells people to pipe into sh, and it is
# published with the docs site — so it is checked here rather than discovered
# broken by a first-time user. `sh -n` parses it without running anything.
if not file_exists("install.sh") { bad("install.sh is missing") }
else if shq("sh -n install.sh 2>/dev/null") and shq("sh install.sh --help >/dev/null 2>&1") {
ok("install.sh parses under POSIX sh and answers --help")
} else { bad("install.sh does not parse under POSIX sh") }
# the package manager (issue #63): fetch + MVS resolve + content-addressed
# store + ludic_modules/ links + build, run as its own hermetic suite (it
# stands up throwaway git repos, so like selfhost-test it runs as a sub-process
# and this stage checks its footer).
print("== package manager (ludic-dev test-pkg) ==")
shell(`bin/ludic-dev test-pkg > {tmp_dir()}/pkg.out 2>&1`)
if shq(`grep -q '0 failed' {tmp_dir()}/pkg.out`) {
let pc = capture_line(`grep -c PASS {tmp_dir()}/pkg.out`)
ok(`package-manager suite: {pc} checks passed (see: ludic-dev test-pkg)`)
} else { bad2("ludic-dev test-pkg", capture_line(`grep -i fail {tmp_dir()}/pkg.out | head -1`)) }
# docs site generator + guard, in Ludic (no Python). docs-gen emits the whole
# pages payload; docs-check is its coverage/integrity guard; docs-palette is
# the named-colour source of truth (its --check mode regenerates the tracked
# emit_color.ludic + palette.json into scratch files and compares).
print("== docs site (Ludic generator, no Python) ==")
if shq(`bin/ludic-dev docs-gen --out {tmp_dir()}/docs > {tmp_dir()}/docs.out 2>&1`) {
ok("ludic-dev docs-gen -> the pages-branch payload")
} else { bad2("ludic-dev docs-gen", capture_line(`tail -1 {tmp_dir()}/docs.out`)) }
if shq(`bin/ludic-dev docs-check {tmp_dir()}/docs > {tmp_dir()}/docs_chk.out 2>&1`) {
ok("ludic-dev docs-check passes on the generated site")
} else { bad2("ludic-dev docs-check", capture_line(`tail -1 {tmp_dir()}/docs_chk.out`)) }
if shq(`bin/ludic-dev docs-palette --check > {tmp_dir()}/pal.out 2>&1`) {
ok("ludic-dev docs-palette regenerates emit_color.ludic + palette.json byte-identically")
} else { bad2("ludic-dev docs-palette --check", capture_line(`tail -1 {tmp_dir()}/pal.out`)) }
# the OpenGL binding generator is the same shape: gl_api.ludic + gl_thunks.ll are
# tracked, and --check regenerates them from the platform gl3.h and compares. The
# header is macOS-only, so this guard cannot run off Darwin.
if is_darwin() {
if shq(`bin/ludic-dev glgen --check > {tmp_dir()}/glgen.out 2>&1`) {
ok("ludic-dev glgen regenerates gl_api.ludic + gl_thunks.ll byte-identically")
} else { bad2("ludic-dev glgen --check", capture_line(`tail -1 {tmp_dir()}/glgen.out`)) }
} else { skip("ludic-dev glgen --check (needs the macOS OpenGL headers)") }
# and the Vulkan one: vk_api.ludic + vk_thunks.ll regenerated from the registry's vk.xml,
# which comes with the Vulkan SDK - so this guard runs wherever the SDK is installed
if shq("ls $VULKAN_SDK/share/vulkan/registry/vk.xml $HOME/VulkanSDK/*/macOS/share/vulkan/registry/vk.xml 2>/dev/null | grep -q vk.xml") {
if shq(`bin/ludic-dev vkgen --check > {tmp_dir()}/vkgen.out 2>&1`) {
ok("ludic-dev vkgen regenerates vk_api.ludic + vk_thunks.ll byte-identically")
} else { bad2("ludic-dev vkgen --check", capture_line(`tail -1 {tmp_dir()}/vkgen.out`)) }
} else { skip("ludic-dev vkgen --check (needs the Vulkan SDK's vk.xml)") }
# render3d's shader variants as SPIR-V: tracked, so a change to a shader or to variants.list
# that nobody rebuilt fails here, wherever the Vulkan SDK (glslangValidator) is installed
if shq("ls $VULKAN_SDK/bin/glslangValidator $HOME/VulkanSDK/*/macOS/bin/glslangValidator 2>/dev/null | grep -q glslangValidator") {
if shq(`bin/ludic-dev shaders --check > {tmp_dir()}/shaders.out 2>&1`) {
ok("ludic-dev shaders rebuilds render3d's SPIR-V byte-identically")
} else { bad2("ludic-dev shaders --check", capture_line(`tail -1 {tmp_dir()}/shaders.out`)) }
} else { skip("ludic-dev shaders --check (needs the Vulkan SDK's glslangValidator)") }
return report()
}