Sprite loading was a bare png_load — one file per 16x16 sprite, no way to load one sheet and address a cell by grid coords or name. Adds a Sprite.*/Assets.* runtime (atlas.ludic) over the variable-size image loader, so a cell is a sub-rect of the kept image and is NOT restricted to the 16x16 sprite table: Sprite.sheet(path,cw,ch), Sprite.cell(sheet,col,row), Sprite.cell_span(sheet,col,row,cols,rows) (a sprite may span >1 cell), Sprite.define/named (name + lookup), Sprite.draw/draw_scaled (through camera/zoom/clip like Screen.sprite), Sprite.width/height, and Assets.image/load/get. Spliced on demand (Sprite.sheet/… or Assets.*), so a program using neither is byte-identical. Example examples/library/atlas.ludic (verified against a real 12x11 Kenney sheet, incl. a 2x3 multi-cell span and named lookup). 14 docs pages. Full suite 114/0, goldens byte-identical, fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
423 lines
30 KiB
Text
423 lines
30 KiB
Text
# 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/x <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/x_e_{nm}`) {
|
||
bad2(label, capture_line("grep -i error /tmp/x_gb.err | head -1")); return
|
||
}
|
||
var got = ""
|
||
if (keys == "") { got = capture_line(`/tmp/x_e_{nm} < /dev/null`) }
|
||
else { got = capture_line(`printf '{keys}' | /tmp/x_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 `x app` / 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/x_c_{nm}.ll`
|
||
if not shq(`LUDIC_HOME={root} LUDIC_MODULES={root}/packages bin/ludicc examples/{path}.ludic > {ll} 2>/tmp/x_gb.err`) {
|
||
bad2(label, capture_line("grep -i error /tmp/x_gb.err | head -1")); return
|
||
}
|
||
if not shq(`{cc()} -O2 {ll} -o /tmp/x_c_{nm} 2>/tmp/x_gb.err`) {
|
||
bad2(label, capture_line("grep -i error /tmp/x_gb.err | head -1")); return
|
||
}
|
||
var got = ""
|
||
if (keys == "") { got = capture_line(`/tmp/x_c_{nm} < /dev/null`) }
|
||
else { got = capture_line(`printf '{keys}' | /tmp/x_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/x_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]`) }
|
||
}
|
||
|
||
# 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/x_q_{nm}`) {
|
||
ok(`{path} compiles (@Queries desugars to S_QUERY)`)
|
||
} else { bad2(path, capture_line("grep -i error /tmp/x_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/x_sm_{nm}.ll > /tmp/x_sm_{nm}.err 2>&1`) {
|
||
bad2(path, capture_line(`tail -1 /tmp/x_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/x_n_{nm}.ll`
|
||
let log = `/tmp/x_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/x_nb_{nm} >> {log} 2>&1`) {
|
||
let t = capture_line(`tail -1 {log}`)
|
||
bad2(path, `build ({t})`); return
|
||
}
|
||
let got = capture_line(`/tmp/x_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/x_spec_{nm}.ll`
|
||
let er = `/tmp/x_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/x_spec_{nm} 2>{er}`) {
|
||
bad2(path, capture_line(`tail -1 {er}`)); return
|
||
}
|
||
let rc = sh(`/tmp/x_spec_{nm} > /tmp/x_spec_{nm}.out 2>&1`)
|
||
let summary = capture_line(`tail -1 /tmp/x_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/x_panic.ludic", "program P { entry { print(7); panic(\"boom\") } }\n")
|
||
if not shq("bin/ludicc /tmp/x_panic.ludic > /tmp/x_panic.ll 2>/dev/null") { bad("panic example did not compile"); return }
|
||
if not shq(`{cc()} -O2 /tmp/x_panic.ll -o /tmp/x_panic 2>/dev/null`) { bad("panic example did not link"); return }
|
||
let rc = sh("/tmp/x_panic > /tmp/x_panic.out 2>/tmp/x_panic.err")
|
||
let msg = capture_line("cat /tmp/x_panic.err")
|
||
if (rc != 0) and shq("grep -q 'panic: boom' /tmp/x_panic.err") { ok(`panic aborts non-zero with a located stderr message ({msg})`) }
|
||
else { bad2("panic", `rc={string(rc)} err=[{msg}]`) }
|
||
}
|
||
|
||
# issue #45: `bin/x 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/x_cov_{nm}.ll`
|
||
let bin = `/tmp/x_cov_{nm}`
|
||
let cov = `{dir}/{nm}.cov`
|
||
if not shq(`bin/ludicc --coverage examples/{path}.ludic > {ll} 2>/tmp/x_cov.err`) {
|
||
bad2(path, capture_line("tail -1 /tmp/x_cov.err")); return 1
|
||
}
|
||
if not shq(`{cc()} -O2 {ll} -o {bin} 2>/tmp/x_cov.err`) {
|
||
bad2(path, capture_line("tail -1 /tmp/x_cov.err")); return 1
|
||
}
|
||
run(`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 = COV_COVERED + covered
|
||
COV_TOTAL = 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.
|
||
run("mkdir -p bin build /tmp/x_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("x: cannot build bin/ludicc from the seed\n"); return 1 }
|
||
}
|
||
COV_COVERED = 0
|
||
COV_TOTAL = 0
|
||
print("== line coverage (bin/x test --coverage) ==")
|
||
cov_one("library/coverage", "/tmp/x_cov_out")
|
||
cov_one("library/testing", "/tmp/x_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_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) ==")
|
||
run("rm -f bin/ludicc")
|
||
if shq("bin/x app 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 ==")
|
||
run("bin/x selfhost-test > /tmp/x_sh.out 2>&1")
|
||
if shq("grep -q 'gen2.ll == gen3.ll' /tmp/x_sh.out") { ok("the compiler reproduces itself (fixpoint)") } else { bad("fixpoint") }
|
||
if shq("grep -q 'no C compiler' /tmp/x_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/x_sh.out")
|
||
ok(`self-host suite: {corr} checks passed (see: x 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/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/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/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/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("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", "os.ludic (Os args/env round-trip, platform/arch, known dirs)")
|
||
} 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_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/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/x_http_demo > /tmp/x_http.out 2>&1") {
|
||
let got = capture_line("/tmp/x_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/x_http.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")
|
||
|
||
print("== self-hosted front-end binaries (ludicc / ludic) ==")
|
||
# the two commands are one multi-call native binary built from the seed with
|
||
# clang alone. ludicc compiles; ludic compiles-and-runs.
|
||
if shq("bin/x build-cli > /tmp/x_cli.out 2>&1") and is_exec("bin/ludicc") and is_exec("bin/ludic") {
|
||
ok("x build-cli builds bin/ludicc and bin/ludic from the seed (clang-only)")
|
||
} else { bad2("x build-cli", capture_line("tail -1 /tmp/x_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/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("file /tmp/x_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/x_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/x_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/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("test -x /tmp/x_cli_snake") {
|
||
ok("ludicc app.ludic -o bin -> native executable (headless)")
|
||
} else { bad2("ludicc -o", capture_line("tail -1 /tmp/x_cli.out")) }
|
||
}
|
||
|
||
# --emit-llvm stops at the IR.
|
||
if shq("bin/ludicc examples/games/snake.ludic --emit-llvm -o /tmp/x_cli_snake.ll > /tmp/x_cli.out 2>&1") and shq("head -1 /tmp/x_cli_snake.ll | grep -q 'LLVM IR'") {
|
||
ok("ludicc --emit-llvm -> LLVM IR")
|
||
} else { bad2("ludicc --emit-llvm", capture_line("tail -1 /tmp/x_cli.out")) }
|
||
|
||
# ludic compiles, runs, and forwards the program's exit code.
|
||
write_file("/tmp/x_cli_exit.ludic", "entry { exit(42) }\n")
|
||
let rc = sh("LUDIC_HOME=. bin/ludic /tmp/x_cli_exit.ludic > /tmp/x_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 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 (x test-pkg) ==")
|
||
run("bin/x test-pkg > /tmp/x_pkg.out 2>&1")
|
||
if shq("grep -q '0 failed' /tmp/x_pkg.out") {
|
||
let pc = capture_line("grep -c PASS /tmp/x_pkg.out")
|
||
ok(`package-manager suite: {pc} checks passed (see: x test-pkg)`)
|
||
} else { bad2("x test-pkg", capture_line("grep -i fail /tmp/x_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 output is the tracked emit_color.ludic
|
||
# + palette.json, so a clean git diff proves it stayed byte-identical).
|
||
print("== docs site (Ludic generator, no Python) ==")
|
||
if shq("bin/x docs-gen --out /tmp/x_docs > /tmp/x_docs.out 2>&1") {
|
||
ok("x docs-gen -> the pages-branch payload")
|
||
} else { bad2("x docs-gen", capture_line("tail -1 /tmp/x_docs.out")) }
|
||
if shq("bin/x docs-check /tmp/x_docs > /tmp/x_docs_chk.out 2>&1") {
|
||
ok("x docs-check passes on the generated site")
|
||
} else { bad2("x docs-check", capture_line("tail -1 /tmp/x_docs_chk.out")) }
|
||
if shq("bin/x docs-palette > /dev/null 2>&1") and shq("git diff --quiet selfhost/backend/stdlib/emit_color.ludic docs/language/colors/palette.json") {
|
||
ok("x docs-palette regenerates emit_color.ludic + palette.json byte-identically")
|
||
} else { bad("x docs-palette drifted (git diff not empty)") }
|
||
|
||
return report()
|
||
}
|