# 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 ` 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}]`) } } # an example the compiler must REFUSE, with `want` in the message function reject_case(path: pointer, want: pointer, label: pointer) -> void { let ll = `{tmp_dir()}/rj_{flat(path)}.ll` if shq(`bin/ludicc examples/{path}.ludic --emit-llvm -o {ll} 2>{tmp_dir()}/rj.err`) { bad2(label, "compiled, and should have been refused") return } let got = capture_line(`grep -i error {tmp_dir()}/rj.err | head -1`) if s_contains(got, want) { ok(label) } else { bad2(label, `refused, but said [{got}]`) } } # 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 / window_hide / window_show -> windowed no-ops without a window, 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 + " App.window_hide()" + nl() # no window yet: both are no-ops src = src + " App.window_show()" + nl() 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 "\t" 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) } # package.ludic drives the CLI: `entry` (or the one program under src/) is what # build compiles, scripts run by name, and hooks wrap commands — a failing # `before` stops the command, `after` runs only on success. function package_scripts_case() -> void { let lbl = "package.ludic entry, scripts and hooks" let work = `{tmp_dir()}/pkgscripts` shell(`rm -rf {work} && mkdir -p {work}`) let root = capture_line("pwd") let L = `{root}/bin/ludic` if not shq(`cd {work} && {L} new g > /dev/null 2>&1 && mv g/src/main.ludic g/src/game.ludic`) { bad2(lbl, "setup failed"); return } # no src/main.ludic: the single program under src/ is found if not shq(`cd {work}/g && {L} build --headless > {work}/b1.out 2>&1`) { bad2(lbl, "the lone src/ program was not found"); return } # a second program makes it ambiguous; `entry` settles it write_file(`{work}/g/src/other.ludic`, "program Other {\n entry { }\n}\n") if shq(`cd {work}/g && {L} build --headless > {work}/b2.out 2>&1`) { bad2(lbl, "two programs and no entry should not build"); return } var man = read_file(`{work}/g/package.ludic`) man = man + "entry \"src/game.ludic\"\n" man = man + "script \"greet\" \"echo hi-$LUDIC_EVENT\"\n" man = man + "hook before build \"echo pre > hook.log\"\n" man = man + "hook after build \"echo post >> hook.log\"\n" man = man + "hook before test \"exit 7\"\n" write_file(`{work}/g/package.ludic`, man) if not shq(`cd {work}/g && {L} build --headless > {work}/b3.out 2>&1`) { bad2(lbl, "entry was not honoured"); return } if not (capture_line(`tr '\n' ' ' < {work}/g/hook.log`) == "pre post") { bad2(lbl, "build hooks did not run in order"); return } if not (capture_line(`cd {work}/g && {L} greet | tail -1`) == "hi-greet") { bad2(lbl, "a script did not run by name"); return } if not (capture_line(`cd {work}/g && {L} test > /dev/null 2>&1; echo $?`) == "7") { bad2(lbl, "a failing before hook did not stop the command"); 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: ` 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 ` `, 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/floats", "", "1 2 3 4 5 6 7 8 9 10 11 12", "floats.ludic (float/double: operators, context-typed literals, conversions, Math)") 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__spawn/despawn events)") feat_case("events/cancel", "", "0 1 92", "cancel.ludic (cancellable event + cancel + emit-as-expression)") feat_case("events/answer", "", "10 20 0", "answer.ludic (a listener's return ends that listener, not the dispatch)") reject_case("rejected/duplicate_const", "'KEY_LEFT' is defined twice", "a const defined twice is refused, naming both places") reject_case("rejected/duplicate_property", "'Look' is defined twice", "a property defined twice is refused") reject_case("rejected/redeclared_local", "'v0' is declared twice in this block", "a local declared twice in one block is refused") reject_case("rejected/missing_return", "can reach its end without returning", "a function that can run off its end without its result is refused") feat_case("functions/values", "", "42 10 25 49 81 1.5 1", "values.ludic (L2: fn types, fn name, calls through a local, global, field, element, parameter, result)") reject_case("rejected/fn_type_mismatch", "is not a fn(int)->int", "a function of the wrong type is refused") # 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/input_typeless_keys", "", "132 143 144 149 150 152 161 F1 F12 Home Delete CapsLock Num 0 Num / 1 0 1", "input_typeless_keys.ludic (the F-row, the six-pack, Caps Lock and the numpad are codes 132-166: bindable, in the held set, and named without asking the layout)") 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`)) } # Http.save_to: a body streamed to a file, counted as it goes - through a file:// URL, # so no network is touched; the copy has to be byte-identical if shq(`LUDIC_HOME=. bin/ludicc examples/library/http_save.ludic -o {tmp_dir()}/http_save > {tmp_dir()}/http_save.out 2>&1`) { let root = capture_line("pwd") shell(`rm -f {tmp_dir()}/http_save.copy`) let got3 = capture_line(`{tmp_dir()}/http_save {root}/selfhost/ludicc.seed.ll {tmp_dir()}/http_save.copy < /dev/null`) let same = shq(`cmp -s {root}/selfhost/ludicc.seed.ll {tmp_dir()}/http_save.copy`) if (got3 == "0 -1 200 1 1 1 1") and same { ok("http_save.ludic (Http.save_to/received/expected - a streamed download, byte for byte)") } else { bad2("http_save.ludic", `got [{got3}] identical {same}`) } } else { bad2("http_save.ludic build", capture_line(`tail -1 {tmp_dir()}/http_save.out`)) } } # Udp.*: two sockets on the loopback, a datagram each way, through ludicc and ludic build if is_darwin() { let udp_want = "2 2 104 105 1 2 127.0.0.1 1 0 1" if shq(`LUDIC_HOME=. bin/ludicc examples/library/udp.ludic -o {tmp_dir()}/udp_demo > {tmp_dir()}/udp.out 2>&1`) { let got = capture_line(`{tmp_dir()}/udp_demo < /dev/null`) if (got == udp_want) { ok("udp.ludic (Udp.open/send/recv/from/ip - datagrams over the loopback)") } else { bad2("udp.ludic", `got [{got}]`) } } else { bad2("udp.ludic build", capture_line(`tail -1 {tmp_dir()}/udp.out`)) } if shq(`LUDIC_HOME=. bin/ludic build examples/library/udp.ludic --headless -o {tmp_dir()}/udp_lb > {tmp_dir()}/udp_lb.out 2>&1`) { let got2 = capture_line(`{tmp_dir()}/udp_lb < /dev/null`) if (got2 == udp_want) { ok("ludic build links udp.ll for a program that uses Udp.*") } else { bad2("udp.ludic via ludic build", `got [{got2}]`) } } else { bad2("ludic build of a Udp.* program", capture_line(`tail -1 {tmp_dir()}/udp_lb.out`)) } } # Process.*: a child polled to its exit code without blocking, argv passed untouched, a # kill, a missing program - through ludicc and ludic build (the children are /bin/sh) if is_darwin() { let proc_want = "3 1 7 137 -1 255" if shq(`LUDIC_HOME=. bin/ludicc examples/library/process.ludic -o {tmp_dir()}/proc_demo > {tmp_dir()}/proc.out 2>&1`) { let got = capture_line(`{tmp_dir()}/proc_demo < /dev/null`) if (got == proc_want) { ok("process.ludic (Process.spawn/poll/kill/free - child processes, polled)") } else { bad2("process.ludic", `got [{got}]`) } } else { bad2("process.ludic build", capture_line(`tail -1 {tmp_dir()}/proc.out`)) } if shq(`LUDIC_HOME=. bin/ludic build examples/library/process.ludic --headless -o {tmp_dir()}/proc_lb > {tmp_dir()}/proc_lb.out 2>&1`) { let got2 = capture_line(`{tmp_dir()}/proc_lb < /dev/null`) if (got2 == proc_want) { ok("ludic build links process.ll for a program that uses Process.*") } else { bad2("process.ludic via ludic build", `got [{got2}]`) } } else { bad2("ludic build of a Process.* program", capture_line(`tail -1 {tmp_dir()}/proc_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_

_attach/detach events)") feat_case("events/scene_events", "", "10 1 20 2 30 3", "scene_events.ludic (public scene -> scene__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__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 ` 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() package_scripts_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() }