diff --git a/.claude/launch.json b/.claude/launch.json index 2f8d274d..fd0e3829 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,12 +6,6 @@ "runtimeExecutable": "python3", "runtimeArgs": ["-m", "http.server", "8123", "-d", "build/web"], "port": 8123 - }, - { - "name": "ludic-docs", - "runtimeExecutable": "python3", - "runtimeArgs": ["-m", "http.server", "8124", "-d", "build/pages"], - "port": 8124 } ] } diff --git a/.forgejo/issue_template/bug.md b/.forgejo/issue_template/bug.md index 3995f0a1..e5100d79 100644 --- a/.forgejo/issue_template/bug.md +++ b/.forgejo/issue_template/bug.md @@ -24,7 +24,7 @@ labels: ## Environment -- Command used (e.g. `bin/ludic build foo.ludic --headless`): +- Command used (e.g. `bin/x app foo.ludic --headless`): - Target (native macOS / headless / web-wasm): - Commit (`git rev-parse --short HEAD`): - OS / arch: diff --git a/.forgejo/pull_request_template.md b/.forgejo/pull_request_template.md index 0a04e381..7202e9f3 100644 --- a/.forgejo/pull_request_template.md +++ b/.forgejo/pull_request_template.md @@ -8,13 +8,13 @@ Closes # ## Checklist -- [ ] `bin/ludic-dev test` passes. -- [ ] For compiler/runtime changes: `bin/ludic-dev reseed && bin/ludic-dev bootstrap-cfree` +- [ ] `bin/x test` passes. +- [ ] For compiler/runtime changes: `bin/x reseed && bin/x bootstrap-cfree` still reaches the self-hosting fixpoint with no C compiler in the loop. - [ ] `ludic-fmt` leaves the touched files unchanged (2-space, LF, UTF-8). - [ ] New/changed stdlib symbols are documented under `docs/language/**` and registered in `tools/docgen/inventory.json` - (`bin/ludic-dev docs-gen && bin/ludic-dev docs-check build/pages` passes). + (`python3 tools/docgen/check.py` passes). - [ ] Commits follow [Conventional Commits](https://www.conventionalcommits.org). - [ ] No new C / Python / JS in tooling (Ludic only), and no generated artifacts committed outside `build/` / `bin/`. diff --git a/.forgejo/workflows/bootstrap.yml b/.forgejo/workflows/bootstrap.yml index a41bb6e5..7049ebba 100644 --- a/.forgejo/workflows/bootstrap.yml +++ b/.forgejo/workflows/bootstrap.yml @@ -25,17 +25,14 @@ jobs: clang-16 --version | head -1 - name: Check out the triggering commit - env: - # the repository that triggered the run, so a fork or a mirror tests itself - REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git run: | set -eu git config --global --add safe.directory '*' - git clone "$REPO_URL" . + git clone https://git.workshopsoft.io/workshopsoft/ludic.git . git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}" git log --oneline -1 # See ci.yml for why the Linux build injects the stdio shim via LUDIC_CC. - echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll -lm" >> "$GITHUB_ENV" + echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV" echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV" - name: Bootstrap x from the seed @@ -43,11 +40,11 @@ jobs: set -eu mkdir -p bin clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc - bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev + bin/ludicc tools/x/main.ludic -o bin/x - name: Rebuild the compiler from the seed and assert byte-identity - # `ludic-dev bootstrap-cfree` assembles the seed with clang, has that seed + # `x bootstrap-cfree` assembles the seed with clang, has that seed # compiler recompile selfhost.ludic to out.ll, and `cmp`s out.ll against # the checked-in seed. It returns non-zero if they differ — i.e. if the # seed is stale relative to the compiler source. - run: bin/ludic-dev bootstrap-cfree + run: bin/x bootstrap-cfree diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 04dd75a3..f3df15e7 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -2,7 +2,7 @@ name: ci # Build the language toolchain from its IR seed and run the regression suites on # every push to main and every pull request. Until this landed the only workflow -# was docs.yml, so nothing gated a change on `ludic-dev test` / `ludic-dev test-tools` or on the +# was docs.yml, so nothing gated a change on `x test` / `x test-tools` or on the # compiler even building from the seed. See also bootstrap.yml, which proves the # C-free self-rebuild reproduces the seed byte-for-byte. on: @@ -17,38 +17,35 @@ jobs: # advertises `docker`, not the GitHub-ism `ubuntu-latest`. runs-on: docker # Reuse the runner's own base image (Debian bookworm with git + node already - # present) and add just the one thing the toolchain needs: a modern clang - # (LLVM 16 — the IR uses opaque pointers, so clang 15+ is required). The docs - # generator and its guards are now Ludic, so the job carries no Python. A - # prebuilt image with clang baked in is the obvious future speed-up (see - # issue #33's packaging work). + # present) and add just the two things the toolchain needs: a modern clang + # (LLVM 16 — the IR uses opaque pointers, so clang 15+ is required) and + # python3 for the docs/vocabulary checks. A prebuilt image with these baked + # in is the obvious future speed-up (see issue #33's packaging work). container: node:20-bookworm steps: - - name: Install clang-16 + - name: Install clang-16 and python3 run: | set -eu export DEBIAN_FRONTEND=noninteractive apt-get update -qq - apt-get install -y -qq --no-install-recommends clang-16 git ca-certificates + apt-get install -y -qq --no-install-recommends clang-16 python3 git ca-certificates clang-16 --version | head -1 + python3 --version - name: Check out the triggering commit - env: - # the repository that triggered the run, so a fork or a mirror tests itself - REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git run: | set -eu git config --global --add safe.directory '*' - git clone "$REPO_URL" . + git clone https://git.workshopsoft.io/workshopsoft/ludic.git . git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}" git log --oneline -1 # The toolchain is macOS-first; on this Linux runner it links against a # tiny C-free IR shim that supplies the Darwin standard-stream globals # (__stdoutp/__stderrp) over glibc's stdout/stderr. Injected through - # LUDIC_CC so every clang invocation — the seed bootstrap, `ludic-dev build`, + # LUDIC_CC so every clang invocation — the seed bootstrap, `x build`, # and each compiled test program — picks it up. Absolute path so it # still resolves if a step changes directory. - echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll -lm" >> "$GITHUB_ENV" + echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV" echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV" - name: Bootstrap the toolchain from the IR seed (clang only) @@ -60,30 +57,24 @@ jobs: # pre-built binaries: the language builds itself from source + seed. mkdir -p bin clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc - bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev - bin/ludic-dev build + bin/ludicc tools/x/main.ludic -o bin/x + bin/x build - - name: Regression suite (ludic-dev test) - run: bin/ludic-dev test + - name: Regression suite (x test) + run: bin/x test - - name: Editor-toolchain suite (ludic-dev test-tools) + - name: Editor-toolchain suite (x test-tools) # Grammar/lexer/vocabulary sync, ludic-fmt idempotence (the project's # formatting contract — hand alignment is deliberately preserved, so the # gate is fmt(fmt(x)) == fmt(x), not fmt(x) == x), and the JSON/XML editor # assets. Cross-file LSP behaviour and the golden renders are macOS-ABI # bound and skip here — visibly — until the runtime's directory walk and # windowing are portable. - run: bin/ludic-dev test-tools + run: bin/x test-tools - name: Docs cover the implementation run: | set -eu - # The whole docs toolchain is written in Ludic and runs through x — - # no Python anywhere. check-impl / check-vocabulary / check-docs guard - # the sources; docs-gen builds the site and docs-check is its coverage - # + integrity guard. (check-vocabulary also runs in `ludic-dev test-tools`.) - bin/ludic-dev check-impl - bin/ludic-dev check-vocabulary - bin/ludic-dev check-docs - bin/ludic-dev docs-gen --out build/pages - bin/ludic-dev docs-check build/pages + python3 tools/docgen/gen.py --out build/pages + python3 tools/docgen/check.py build/pages + python3 tools/docgen/check-impl.py diff --git a/.forgejo/workflows/commit-lint.yml b/.forgejo/workflows/commit-lint.yml index bbccce89..f5fbbc20 100644 --- a/.forgejo/workflows/commit-lint.yml +++ b/.forgejo/workflows/commit-lint.yml @@ -17,12 +17,13 @@ jobs: steps: - name: Check out with history env: - REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git + BEFORE: ${{ github.event.before }} + BASE: ${{ github.base_ref }} run: | set -eu git config --global --add safe.directory '*' # Full clone so both endpoints of the range are present. - git clone "$REPO_URL" . + git clone https://git.workshopsoft.io/workshopsoft/ludic.git . git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}" - name: Lint the new commits @@ -35,15 +36,10 @@ jobs: # - pull_request: base branch .. this commit # - push: the pushed range (event.before .. this commit) # - new branch / unknown: just the tip commit - # `event.before` is only usable if it still resolves: a force-push - # rewrites (and a gc can remove) the commit it names, which made this - # job fail with "Invalid revision range" on an otherwise clean push. - # Fall back to the tip commit in that case. if [ -n "${BASE:-}" ]; then git fetch --quiet origin "${BASE}" 2>/dev/null || true RANGE="origin/${BASE}..${GITHUB_SHA}" - elif [ -n "${BEFORE:-}" ] && ! printf '%s' "$BEFORE" | grep -qE '^0+$' \ - && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + elif [ -n "${BEFORE:-}" ] && ! printf '%s' "$BEFORE" | grep -qE '^0+$'; then RANGE="${BEFORE}..${GITHUB_SHA}" else RANGE="${GITHUB_SHA}~1..${GITHUB_SHA}" diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml index b606bf3e..f1edaec7 100644 --- a/.forgejo/workflows/docs.yml +++ b/.forgejo/workflows/docs.yml @@ -10,22 +10,9 @@ on: paths: - 'docs/**' - 'tools/docgen/**' - - 'tools/ludic-cli/**' - # the site publishes the installer, so a change to it has to redeploy the - # site — otherwise a fixed install.sh sits in main while the old one is - # still what `curl … | sh` fetches - - 'install.sh' - '.forgejo/workflows/docs.yml' workflow_dispatch: {} -# Deploying is a force-push of an orphan branch, so two runs racing can land out -# of order and leave `pages` holding the older build — the site would silently -# go backwards with both runs green. Serialise them, and let a newer push cancel -# an older one that is still building rather than queue behind it. -concurrency: - group: pages-deploy - cancel-in-progress: true - permissions: contents: write @@ -36,41 +23,22 @@ jobs: # GitHub-ism this runner does not register, so a job requesting it sits in # "Waiting" forever with "no online runner found matching this label". runs-on: docker - # The generator is now Ludic, so this builds the toolchain from its IR seed - # (clang assembles the seed into bin/ludicc, which compiles bin/ludic) exactly - # like the ci workflow, then runs `ludic-dev docs-gen`. node:20-bookworm carries git - # for the clone + publish; clang-16 is the only extra the bootstrap needs. - container: node:20-bookworm + # Run in a Python image: the generator is pure-Python stdlib, and this image + # already has git for the clone + publish. No node actions are used, so the + # job never depends on the runner's base image having python installed. + container: python:3.12 steps: - - name: Install clang-16 - run: | - set -eu - export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq --no-install-recommends clang-16 git ca-certificates - clang-16 --version | head -1 - - name: Generate the documentation site env: SOURCE_REF: ${{ github.ref_name }} - REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git run: | set -eu git config --global --add safe.directory '*' - git clone --depth 1 --branch "${SOURCE_REF:-main}" "$REPO_URL" src - cd src - # The toolchain is macOS-first; on this Linux runner it links against a - # tiny C-free IR shim supplying the Darwin stdout/stderr globals over - # glibc's, injected through LUDIC_CC. docs-gen is a pure CLI (no - # windowing), so the C-free bootstrap is all it needs. - export LUDIC_CC="clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll -lm" - export LUDIC_HOME="$(pwd)" - mkdir -p bin - clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc - bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev - bin/ludic-dev docs-gen --out ../public - bin/ludic-dev docs-check ../public - cd .. + git clone --depth 1 --branch "${SOURCE_REF:-main}" \ + https://git.workshopsoft.io/workshopsoft/ludic.git src + python3 --version + python3 src/tools/docgen/gen.py --out public + python3 src/tools/docgen/check.py public echo "--- generated files ---" ls -la public @@ -79,8 +47,6 @@ jobs: PAGES_TOKEN: ${{ secrets.PAGES_TOKEN }} AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }} SOURCE_SHA: ${{ github.sha }} - SERVER_URL: ${{ github.server_url }} - REPO: ${{ github.repository }} run: | set -eu TOKEN="${PAGES_TOKEN:-${AUTO_TOKEN:-}}" @@ -94,6 +60,5 @@ jobs: git config user.email "docs@workshopsoft.io" git add -A git commit -q -m "docs: regenerate site from ${SOURCE_SHA}" - # the same server and repository the run came from, with the token spliced in - git push -f "${SERVER_URL%%://*}://ludic-docs-bot:${TOKEN}@${SERVER_URL#*://}/${REPO}.git" pages + git push -f "https://ludic-docs-bot:${TOKEN}@git.workshopsoft.io/workshopsoft/ludic.git" pages echo "published $(git rev-parse --short HEAD) to pages" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index 1e323525..00000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: release - -# Cutting a release is `ludic-dev release` + `git push --tags`; everything after that -# happens here. Before this workflow existed the artifacts were built on whatever -# machine the maintainer happened to be sitting at, from whatever was in bin/ at -# the time, with no checksums and nothing proving the tagged tree even passed its -# tests. Now the tag is the trigger and CI is the only thing that publishes. -# -# The job refuses to publish unless: -# * the tag matches the VERSION file in the tagged tree, -# * CHANGELOG.md has a section for that version (it becomes the release notes), -# * the toolchain builds from the IR seed and the whole suite passes, -# * the C-free bootstrap still reproduces the seed byte-for-byte. -# -# Needs a repository secret FORGEJO_TOKEN with write access to releases. -on: - push: - tags: ['v*'] - workflow_dispatch: - inputs: - tag: - description: 'Tag to publish (e.g. v0.4.0)' - required: true - -jobs: - publish: - runs-on: docker - container: node:20-bookworm - steps: - - name: Install clang-16 - run: | - set -eu - export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq --no-install-recommends clang-16 git ca-certificates curl - clang-16 --version | head -1 - - - name: Check out the tag - env: - REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git - INPUT_TAG: ${{ github.event.inputs.tag }} - run: | - set -eu - git config --global --add safe.directory '*' - # A full clone: `git archive` needs the tag object, and the tarball is - # built from the tag rather than from the working tree. - git clone "$REPO_URL" . - TAG="${INPUT_TAG:-${GITHUB_REF_NAME}}" - git checkout "$TAG" - echo "TAG=$TAG" >> "$GITHUB_ENV" - # See ci.yml for why the Linux build injects the stdio shim via LUDIC_CC. - echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll -lm" >> "$GITHUB_ENV" - echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV" - - - name: The tag, VERSION and CHANGELOG must agree - run: | - set -eu - VERSION="$(cat VERSION)" - if [ "$TAG" != "v${VERSION}" ]; then - echo "::error::tag ${TAG} does not match VERSION (${VERSION})" - exit 1 - fi - if ! grep -q "^## v${VERSION} " CHANGELOG.md; then - echo "::error::CHANGELOG.md has no '## v${VERSION}' section to use as release notes" - exit 1 - fi - echo "publishing ${TAG}" - - - name: Build the toolchain from the IR seed (clang only) - run: | - set -eu - mkdir -p bin - clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc - bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev - bin/ludic-dev build - - - name: The tagged tree must pass its own suites - run: | - set -eu - bin/ludic-dev test - bin/ludic-dev test-tools - bin/ludic-dev bootstrap-cfree - - - name: Publish the release - env: - FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} - LUDIC_FORGEJO_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} - run: | - set -eu - if [ -z "${FORGEJO_TOKEN:-}" ]; then - echo "::error::No FORGEJO_TOKEN secret; cannot create the release." - exit 1 - fi - # ludic-dev publish builds dist/ (source tarball from the tag, this host's - # toolchain, SHA256SUMS), takes the notes from the CHANGELOG section, - # and creates the release. Re-running it only adds missing assets, so - # a maintainer can afterwards attach the macOS toolchain from a Mac - # with the same command. - bin/ludic-dev publish "$TAG" diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 759f9796..00000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -packages/*/lib/** filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index ec21dc03..5319bb15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Generated build tree: LLVM IR, objects, compiled apps, the headless render -# (build/out.ppm) and the docs site all land under build/ (see `bin/ludic-dev build` / -# `bin/ludic clean`). Root-anchored so a source dir named "build" elsewhere is never +# (build/out.ppm) and the docs site all land under build/ (see `bin/x build` / +# `bin/x clean`). Root-anchored so a source dir named "build" elsewhere is never # accidentally ignored. Nothing is written to the repo root any more. /build/ @@ -9,24 +9,16 @@ # packaged plugin .zip are local-only build inputs/outputs. *.zip -# the toolchain binaries (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp) — all built -# into bin/ by the one-line bootstrap + `bin/ludic-dev build`; never checked in. The +# the toolchain binaries (ludicc, ludic, x, ludic-fmt, ludic-lsp) — all built +# into bin/ by the one-line bootstrap + `bin/x build`; never checked in. The # only thing published is the source and the LLVM-IR seed (selfhost/ludicc.seed.ll). /bin/ -# package manager (issue #63): the per-project linked view into the global -# content-addressed store, and the optional hermetic copy from `ludic vendor`. Both -# are regenerated by `ludic get` / `ludic vendor` — package.ludic + package.lock.ludic -# are the tracked source of truth, so these stay out of the tree. -ludic_modules/ -vendor/ - # editor toolchain build artifacts tools/editors/vscode/node_modules/ tools/editors/vscode/*.vsix tools/editors/jetbrains/.gradle/ tools/editors/jetbrains/build/ -tools/editors/jetbrains/.kotlin/ # IntelliJ plugin SDK sandbox (tools/editors/jetbrains) .intellijPlatform/ @@ -39,24 +31,3 @@ tools/editors/jetbrains/.kotlin/ # Python bytecode cache from the docgen / release tooling __pycache__/ *.pyc - -# Release artifacts produced by `ludic-dev release` -/dist/ - -# Build/release tarballs anywhere in the tree. `git -C archive -o foo.tgz` -# resolves -o relative to the repo, not the caller's directory, so a stray -# archive lands in the root and a blanket `git add -A` will commit it. -*.tar.gz -*.tgz - -# The CC0 Poly Haven downloads are fetched, not committed (`ludic-dev fetch-assets` -# reads the manifest that ships with the renderer, packages/ludic.render3d/assets.manifest, -# so a game outside this repository fetches the same set with `ludic assets`). -assets/polyhaven/hdri/ -assets/polyhaven/textures/ -assets/polyhaven/models/ -# `ludic run` beside an example writes its binary into a build/ there -examples/**/build/ - -# a package native/build.sh writes its objects under the package (phase 15) -packages/*/build/ diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md new file mode 100644 index 00000000..b2cfbbde --- /dev/null +++ b/BOOTSTRAP.md @@ -0,0 +1,984 @@ +# Bootstrapping Ludic in Ludic + +**What it would take for Ludic to compile itself.** + +Today `ludicc` is a C program: 2,508 lines across `compiler/ludicc.c`, +`compiler/native.c` and `compiler/driver.c`. Everything it produces is +Ludic-or-IR — the runtime a game calls is 2,501 lines of `.ludic`, and no C is +generated, compiled or linked in a build. The compiler is the last C in the +pipeline, and this document is about removing it. + +Every claim about what the language can and cannot do below was **verified +against the built compiler**, not read off the docs. The probe programs are in +the appendix; each `✅`/`�` is a real compile-and-run. + +--- + +## 1. What "completely bootstrapped" means + +Self-hosting is not one property. It is three independent axes, and they cost +wildly different amounts: + +| Axis | Today | Target | +|---|---|---| +| **Compiler independence** — is the compiler written in the language? | � 2,508 lines of C | `ludicc` written in Ludic, compiling itself to a fixpoint | +| **Runtime independence** — is the library the language ships written in the language? | ✅ **already done** — 2,501 lines of `.ludic` (gfx, PNG/DEFLATE, TrueType, UI) | keep | +| **Toolchain independence** — does a build need a foreign compiler? | � `clang` assembles the IR and links | see §7 — three levels, only one is worth reaching | + +The runtime axis is already won, and that is the unusual part. Most languages +self-host the compiler long before they stop leaning on a C standard library; +Ludic did it backwards. **The remaining work is concentrated in one axis.** + +There is also a fourth, smaller thing: `runtime/native/cocoa.ll` (327 lines) and +`runtime/web/wasm.ll` are hand-written LLVM IR, not Ludic. §7.4 covers whether +that matters. + +Running alongside all of this is a question the bootstrap forces rather than +raises: **what the syntax should finally be.** A self-hosted compiler is written +in the language it compiles, so the grammar wants to be settled *before* the +port, not after. §5 audits what is irregular today and proposes the freeze; it +is scheduled as Stage 0.5, between the language features and the libraries. + +### The honest bar + +"Bootstrapped by itself completely" should mean: + +1. `ludicc` is written in Ludic. +2. A `ludicc` binary compiles the Ludic source of `ludicc` and produces a + **byte-identical** binary to itself (the fixpoint test, §6). +3. The C compiler is needed **only** to build the very first seed, and that seed + is a checked-in artifact rather than a live dependency. +4. No C source remains in the repo outside that seed. + +It should *not* mean writing an object-file writer and a linker. Rust and Swift +are self-hosted and both stand on LLVM; standing on `clang` as an IR assembler +is the same posture. §7 argues this explicitly so the goal does not quietly +inflate. + +--- + +## 2. Where the tree stands + +``` +compiler/ C split by concern; every file under 500 lines + ludicc.c 435 pipeline + codegen glue + main + util/ sb, diag 103 string builder; source registry + diagnostics + front/ lex, ast, parse 469 tokens; Node; recursive descent + imports + sem/ tables, uitree, validate 208 decl tables; widget flattening; static checks + back/ ir_* x10 953 the LLVM IR backend, one file per concern + driver/ toolchain, webbundle 382 IR -> object -> exe/dylib; the wasm bundle + fmt/ fmt 162 canonical AST printer (--fmt) + ------ + 2,712 C <- all of it, and all that must go +tools/ludic-tools/* 3,260 C ludic-fmt + ludic-lsp (not yet split) + +runtime/native/core.ludic 394 Ludic framebuffer, text, registers, RNG, input +runtime/native/image.ludic 436 Ludic PNG, sprites, alpha blend, 9-slice +runtime/native/inflate.ludic 276 Ludic DEFLATE (RFC 1951) +runtime/native/truetype.ludic 804 Ludic sfnt loader + AA rasterizer, Q16.16 +runtime/native/ui.ludic 591 Ludic retained widget tree, layout, focus + ---- + 2,501 Ludic <- proof the language is already load-bearing + +runtime/native/cocoa.ll 327 LLVM IR macOS window (objc_msgSend + CoreGraphics) +runtime/web/wasm.ll 369 LLVM IR browser shims +``` + +`util/`, `front/`, `sem/` and `fmt/` are separately compiled translation units; +`back/` and `driver/` are still one unit assembled by `back/native.c`, so their +include order is their definition order. The build list lives in +`compiler/sources.sh`, sourced by both `build.sh` and `test.sh`. + +`truetype.ludic` matters more than its line count. A from-scratch sfnt parser +with cmap format dispatch, composite glyph recursion and a Bézier rasterizer is +*structurally the same kind of program as a compiler*: binary input, recursive +descent, table lookups, a growing output buffer. It already works. That is the +strongest single piece of evidence that this port is feasible rather than +aspirational. + +--- + +## 3. What the language can already do + +All verified. A compiler needs each of these, and each one works today. + +| Capability | Status | Evidence | +|---|---|---| +| Recursion | ✅ | `fib(10)` → `55` | +| Mutual recursion / forward references | ✅ | `odd`/`even` cross-call | +| Deep recursion (recursive-descent parsing) | ✅ | 5,000 frames, no crash | +| Heap allocation | ✅ | `mem_alloc`, `mem_free`, `mem_copy`, `mem_set`; 1 MiB alloc verified | +| Byte-level memory | ✅ | `peek8`/`poke8`, `peek32`/`poke32`, `peekp`/`pokep`, `ptr_add` | +| `ptr` locals, params, returns | ✅ | `function make(n: int) -> pointer` | +| `ptr` in a property field | ✅ | `property Nd { kind: int = 0, a: pointer = ptr_null() }` | +| String literals as readable bytes | ✅ | `peek8("hello", 1)` → `101` | +| `str` accepted where `ptr` expected | ✅ | `f("A")` into `function f(p: pointer)` | +| String comparison, **hand-written in Ludic** | ✅ | `streq` over `peek8` | +| Integer → decimal, **hand-written in Ludic** | ✅ | `itoa(48291)` → `"48291"` | +| File read: open/seek/tell/read/close | ✅ | full round-trip of a written file | +| File write | ✅ | `file_open`/`file_write`/`file_close` | +| Module-level mutable state | ✅ | `var count: int`, `var heap: pointer` | +| `let` is mutable | ✅ | `i = i + 1` in a loop | +| `while`, numeric `for i in a .. b` with runtime bounds | ✅ | | +| `if` / `else if` / `else` chains | ✅ | | +| `match` with multi-value arms and `_` | ✅ | `1 => … 2, 3 => … _ => …` | +| Bitwise ops | ✅ | `band`/`bor`/`bxor`/`bnot`/`shl`/`shr` | +| `shr` is **logical**, not arithmetic | ✅ | `shr(-16, 1)` → `2147483640` | +| Character literals | ✅ | `'x'`, `'\n'`, `'\0'` lex to ints | +| Exit codes | ✅ | `os_exit(3)` → shell sees `3` | +| Separate compilation, C ABI | ✅ | `module` + `@export fn`, `extern fn … = "sym"` | + +**The consequence:** a compiler is *already expressible* in Ludic today. You +could write a lexer, a parser building nodes as hand-offset `peek32`/`poke32` +records, a symbol table, and an IR text emitter, using nothing above. It would +be miserable to read and maintain at 6,000 lines — but nothing in §4 is a +*capability* blocker except argv. The rest is about whether the resulting source +is something a human or a model can work in. + +That distinction shapes the whole plan: **this is mostly an ergonomics project +with one small hole in it**, not a language-design project. + +--- + +## 4. What the language is missing + +Each entry: the gap, why a compiler specifically needs it, the proposed design, +and the lowering. Verified-missing means it is a compile error today. + +### Tier A — real blockers + +#### A1. Command-line arguments � *the only true capability blocker* + +``` +ludicc: error: line 1: unknown function 'os_argc' +``` + +`ll_emit_main` in `compiler/native.c` emits `define i32 @main()` — **no +parameters**. A self-hosted `ludicc` has no way to learn which file to compile. +Everything else in this document has a workaround; this one does not. + +**Design.** Two intrinsics: + +```ludic +# doc-check: skip — proposed signature notation, not code +os_argc() -> int +os_arg(i: int) -> str +``` + +**Lowering.** Change the signature to `define i32 @main(i32 %argc, ptr %argv)`, +store both into `@L_argc` / `@L_argv` in the entry block, then `os_argc()` is a +load and `os_arg(i)` is exactly the existing `peekp(@L_argv, i)` path. Add to +`INTRINSICS[]` in `native.c`. + +**Cost.** ~30 lines of C. This is the single highest-value change in the +document: it is what turns "a Ludic program" into "a Ludic command-line tool". + +#### A2. Aggregate types (`struct`) � + +``` +ludicc: error: line 2: expected declaration (got 'struct') +``` + +An AST node, a token, a symbol-table entry and a type descriptor are all +records. Today there are two workarounds, and both are bad at compiler scale: + +- **Hand-offset memory** — `poke32(n, 0, kind)`, `pokep(n, 1, child)`. This is + what `truetype.ludic` does, and it works, but every field access becomes a + magic number. Across a 6,000-line compiler this is the difference between + maintainable and not. +- **ECS entities as nodes** — verified working (`property Nd { kind, a: pointer }`), + and initially seductive because queries give you free traversal. **Do not do + this.** `LUDIC_MAX_ENT` is 1024 in `native.c:18`; the entity world is a fixed + array of per-property storage. A compiler needs hundreds of thousands of + nodes. This is a dead end, and it is worth writing down because it is the + obvious wrong turn. + +**Design — reference semantics, not value semantics.** The cheap version that +unblocks everything: + +```ludic +# doc-check: skip — proposed syntax: struct does not exist yet +struct Tok { kind: int = 0, text: pointer = ptr_null(), line: int = 0 } + +let t = new Tok # heap-allocated, fields seeded from defaults +t.kind = T_ID +print_int(t.line) +free Tok t # or leak it; see §8 on arenas +``` + +No copying, no by-value passing, no nested-struct inlining — a `struct` value +*is* a `ptr` with a known layout, so it costs nothing in the type system beyond +a layout table. + +**Lowering.** This is largely already built. `native.c` already emits +`%Cmp_` LLVM struct types for properties and already resolves +`a.b` through `ll_member_addr` with `getelementptr`. A `struct` is a +`%Cmp_`-style type *without* the parallel entity arrays: `new` is +`malloc(sizeof)` plus a default-seeding memset/store sequence, and `.field` is +the existing `getelementptr` path. Reusing the property machinery is why this +is far cheaper than it looks. + +**Cost.** ~250 lines of C across `ludicc.c` (parse) and `native.c` (layout, +`new`, member access). Highest cost in the document, and the highest payoff. + +#### A3. Arrays and indexing � + +``` +ludicc: error: line 2: expected identifier (got '[') +``` + +Token buffers, string tables, keyword tables, scope stacks. Currently +`mem_alloc` + `peek32`, which works but reads badly. + +**Design.** + +```ludic +# doc-check: skip — proposed syntax: array types do not exist yet +var keywords: [str; 64] # fixed-size module-level storage +let toks: [Tok; 0] = mem_alloc(n * size_of(Tok)) # or a growable buffer +toks[i].kind = T_ID # composes with A2 +``` + +**Lowering.** `[T; N]` is `[N x ]`, already exactly how `@L_alive` and +`@S_` are emitted. `a[i]` as both rvalue and lvalue is a +`getelementptr` — the same code path as member access, indexed instead of +named. The important part is that `toks[i].kind` composes: index then member, +one GEP chain. + +**Cost.** ~150 lines. Should land *with* A2, since neither is much use alone. + +#### A4. `break` / `continue` � + +``` +ludicc: error: line 3: unknown identifier 'break' +``` + +Lexers and parsers are made of `while (1) { … break; }`. The workaround — +sentinel booleans threaded through every loop condition — is the kind of thing +that makes a 6,000-line port unreadable. + +**Design.** `break`, `continue`. No labels; nested loops in a compiler rarely +need them, and adding labels later is compatible. + +**Lowering.** `native.c` already maintains `ll_loopstk[64]` (for `self()` inside +queries). Extend each frame with `break_label` and `continue_label`, then +`break` is `br label %`. Note the existing gotcha recorded in the native +backend notes: **stack slots must be emitted in the entry block** — no new +allocas at the break site. + +**Cost.** ~40 lines. Best value-per-line in the document. + +#### A5. `mem_realloc` � + +``` +ludicc: error: line 1: unknown function 'mem_realloc' +``` + +Every table in a compiler grows: tokens, nodes, the output buffer. Hand-rolling +alloc-copy-free works but is written once per table and gotten wrong once per +table. + +**Design.** `mem_realloc(p: pointer, n: int) -> pointer`. + +**Lowering.** `declare ptr @realloc(ptr, )` plus one `INTRINSICS[]` +entry. **Use `ll_size_t()` / `ll_widen()` for the size argument — do not +hardcode `i64`.** `size_t` is `i32` on wasm32, and `native.c` now routes every +size-taking intrinsic through those helpers for exactly this reason. + +**Cost.** ~6 lines. + +#### A6. Diagnostics on stderr � + +``` +ludicc: error: line 1: unknown function 'print_err' +``` + +Only stdout exists (`print_str` → `printf`, `write_byte` → `putchar`). This is +not cosmetic: **`ludicc --emit llvm` writes IR to stdout.** A self-hosted +compiler that printed errors to stdout would interleave diagnostics into its own +output, corrupting it in exactly the case you most want a diagnostic. + +**Design.** Prefer an intrinsic that yields a handle, so the existing file +plumbing is reused rather than duplicated: + +```ludic +# doc-check: skip — proposed signature notation, not code +file_stderr() -> pointer # then file_write(f, buf, n) as usual +``` + +**Lowering — note the portability wrinkle.** There is no portable `@stderr` +global in LLVM IR: Darwin exports `@__stderrp`, glibc exports `@stderr`, and +wasm has neither in the same shape. So `file_stderr()` must select per target, +alongside the existing `target_os()` logic in `driver.c`. This is the one item +here that is genuinely target-dependent rather than merely unimplemented, and +it should be designed with that in mind rather than bolted on. + +**Cost.** ~40 lines including the per-target selection. + +### Tier B — needed for *complete* bootstrap, not for the compiler + +#### B1. Function pointers � + +``` +ludicc: error: line 3: unknown type 'fn' for var h +``` +`&cb` also fails to compile. + +The compiler itself does **not** need these — `match` dispatch covers every +place a C compiler would use a function pointer table. + +But they are what would let `cocoa.ll` become Ludic. The macOS window builds an +`NSView` subclass at runtime with `objc_allocateClassPair` and installs **an IR +function as its IMP**. Without the ability to take the address of a Ludic `fn`, +that shim can never move out of hand-written IR. So: irrelevant to §6, and +load-bearing for §7.4. + +**Design.** `&fnname` yields a `ptr`; call through it via +`call_ptr(p, args…)` or a typed `fn(int)->int` type. + +**Cost.** ~120 lines. Defer until after the fixpoint. + +#### B2. String operations — **no language change needed** + +`str + str` is worth calling out as a *bug*, not a gap. It passes the front-end +and then emits invalid IR: + +``` +build/probe_t_headless.ll:13401:17: error: global variable reference must have pointer type +``` + +That is a front-end/backend mismatch: the typechecker accepts an operation the +backend cannot lower. Until strings exist properly, `str + str` should be a +clean compile error rather than a `clang` error in generated code. + +Everything else a compiler needs from strings is **already writable in Ludic +today** — `streq` and `itoa` are verified. This is not a language gap; it is a +library to write (§6 Stage 1), and it is the largest pure-typing chunk of the +whole project. + +### Tier C — explicitly out of scope, recorded so they are not rediscovered + +| Gap | Why it does not block | +|---|---| +| **64-bit integers** � (`100000*100000` → `1410065408`, wraps at i32) | Line numbers, offsets, node indices and string lengths all fit in `i32`. Only matters for source files > 2 GiB. | +| **A non-ECS entry point** | A `Start`-phase system plus `os_exit(n)` gives correct exit codes — verified. You do pay for an unused 1024-entity world; that is a constant, not a blocker. A `tool Name { function main() -> int }` form would be nicer, not necessary. | +| Closures, generics, unions, sum types | A compiler in the style of `ludicc.c` uses none of them. | +| GC | A compiler should leak deliberately (§8). | +| Unsigned integer types | `shr` is already logical and `band`/`bor` are bit-level — sufficient. | +| Multiple return values | `ptr` out-parameters work today. | + +--- + +## 5. Designing for readers — human and model + +The goal: Ludic source should be obvious to a person skimming it and +unambiguous to a model generating it. Those two goals agree far more than they +conflict, and where they conflict the resolution is **regularity, not +verbosity** (§5.2). + +Everything in this section was verified against the built compiler. The probes +are in the appendix under "Syntax audit". + +### 5.1 Why this belongs in the bootstrap document, and why now + +**Syntax changes are cheap today and expensive after Stage 3.** This is a hard +ordering constraint, not a preference. + +Today, changing the grammar costs: edit `ludicc.c`, `sed` three examples and +five runtime files, run `bin/x test`. An afternoon. + +After the fixpoint, `ludicc` is *written in the syntax it parses*. Every change +becomes a four-step dance: build a compiler that accepts both old and new forms +→ compile it with the old seed → rewrite every source file → remove the old +form and regenerate the seed. That is what every mature language does, and it +is why mature languages change syntax slowly. It is not a reason to avoid the +change; it is a reason to **make it before the port, not after**. + +So the plan gains a stage: + +> **Stage 0.5 — Syntax freeze.** Between Stage 0 (language features) and +> Stage 1 (libraries). Nothing in Stage 2 starts until the grammar is final. + +The port should be *the first large program written in final Ludic*, not the +last large program written in provisional Ludic. + +**This work also strengthens the bootstrap itself.** Stage 2b uses `--fmt` +equality as the oracle proving two parsers agree. That oracle is only as tight +as the language is regular: every alternative spelling is surface variance the +formatter must erase. Reduce the variance and the oracle gets sharper. The +readability project and the self-hosting project are not competing for the same +time — one makes the other more trustworthy. + +### 5.2 What actually helps a model — and what is folklore + +Worth being precise here, because "AI-friendly syntax" attracts a lot of +confident nonsense. + +**Genuinely helps:** + +| Property | Why it matters | +|---|---| +| **Low syntactic variance** — one spelling per concept | Every alternative is a branch point during generation and a case in the parser. Two ways to write a list is two chances to be inconsistent within one file. | +| **Leading-keyword, bounded lookahead** | Every declaration and statement identifiable from its first token. Helps the hand-written recursive-descent parser Stage 2b will be, *and* a model predicting forward. | +| **No silent no-ops** | If the language accepts a construct it must either honour it or reject it. Accepting-and-ignoring teaches a falsehood (see R6 — the worst thing in the audit). | +| **Recoverable structure** — explicit terminators | A slightly-wrong generation fails *locally*, with an error pointing at the mistake, instead of cascading into a confusing error 40 lines later. | +| **Locality** — meaning readable from the construct | No action-at-a-distance. Ludic is already strong here; keep it. | +| **Greppable unique anchors** | `property Pos` is findable. Retrieval quality is a language design property. | +| **Errors that name the fix** | Already partly true: a missing builtin errors naming `rt_`. Extend that everywhere. | + +**Folklore, and false:** + +- *"More verbose is more AI-friendly."* No. Ceremony without information hurts + both audiences. What helps is redundancy that **encodes intent** — an explicit + type, a closing keyword — not boilerplate. +- *"Significant indentation reads better."* It reads fine and **generates + badly**: indentation drift across a long generated block is unrecoverable and + survives review. Ludic uses braces. Keep them. +- *"Natural-language-like syntax helps."* Prose-shaped keywords add ambiguity. + Consistent symbols beat English words that read three ways. +- *"Terseness is bad for models."* Terseness is fine; *irregularity* is the + problem. A short form used consistently is easy to predict. + +**The real tension:** humans skim, so terseness helps them; machines benefit +from redundancy. Regularity resolves it — the same shape everywhere costs a +human nothing once learned, and costs a model nothing to predict. + +### 5.3 Audit — what is irregular in Ludic today + +Each row verified by compiling a probe, not by reading docs. + +| # | Irregularity | Evidence | Cost | +|---|---|---|---| +| **R1** | **No statement terminator at all.** `block()` is `skipnl(); stmt()` in a loop. A newline *stops* an expression (it lexes as `T_NL`, and `binlevel` only continues on `T_OP`) but is never *required*. `let x = 1 x = x + 1 print_int(x)` on one line is three legal statements — verified compiling. | `ludicc.c` `block()`, `binlevel` | The reader cannot see where a statement ends without re-deriving operator precedence. Blocks error recovery entirely. | +| **R2** | **Commas are optional everywhere.** `if(isop(",")) pi++` appears in `comp()`, `arche()`, `fn` params and `spawn`. `{ x: int = 0 y: int = 0 }` and the comma'd form both compile. | 4 parser sites | Two spellings, zero semantic difference. | +| ~~**R3**~~ | ~~**`and`/`or` alias `&&`/`\|\|`.**~~ **RESOLVED** — `and`/`or`/`not` are the only boolean operators; `&&`, `\|\|` and `!` are each rejected with a diagnostic naming the fix, and all three words are reserved. `!=` is unaffected. | landed via S3 | — | +| **R4** | **`{ }` means seven different things** — statement block; property fields (`n: T = e`); model list (bare idents); spawn initialisers (`N = { … }`); ui props + children (`k=v` juxtaposed, no commas); match arms (`p, p => …`); machine states (`state N = v { … }`). | `block/comp/arche/spawn/parse_widget/match/machine` | The delimiter carries no information. You must already know the head keyword to know the inner grammar. | +| **R5** | **Contextual keywords, not reserved.** `phase`, `query`, `reads`, `writes`, `needs`, `uses`, `where`, `in`, `on`, `layer`, `state`, `start` are matched with `isid()` — ordinary identifiers. `let query = 5 let phase = 6` compiles and prints `11`. | `sys()`, `scene_decl()` | A local named `enter` or `match` produces a baffling error far from the cause. | +| **R6** | **Contracts are parsed and thrown away.** `requires`/`ensures`/`invariant` parse an expression and **discard it** (`pi++; expr();`). `reads`/`writes`/`needs`/`uses`/`effects` are `skip_brackets()`. `pure` is consumed and ignored. Verified: `function half(n: int) -> int requires n > 100000 ensures false` compiles, and `half(8)` returns `4`. Verified: a system declaring `reads [Pos]` that **writes** `p.x = 99` compiles. | `fn()`, `sys()` | **The worst item in the audit.** The language accepts a contract and does nothing. A model writing `requires n > 0` is rewarded with a clean compile and zero enforcement — it learns a lie, and so does a human reader trusting the annotation. | +| **R7** | **`str + str` typechecks, then emits invalid IR.** | verified (§4 B2) | The front-end accepts what the backend cannot lower. | +| **R8** | **Two formatters, opposite philosophies, both called "format".** `ludicc --fmt` canonicalises hard (one statement per line, `and`→`&&`, full parenthesisation) but drops comments and inlines imports. `ludic-fmt` is token-based and preserves comments — but **normalises nothing**: handed the one-line `let a = 1 a = a + 1 if true and false { … }`, it returned it unchanged. | verified side-by-side | **Neither tool enforces a single spelling.** The canonicaliser is unusable on real source; the source formatter has no opinion. | +| **R9** | **Two ways to spell a tag** — `property Player { }` (empty property) or `model`. | LANGUAGE.md | | +| **R10** | **Stale docs are stale training data.** LANGUAGE.md still says "the current compiler is a tree-to-C translator" (it emits LLVM IR) and lists arrays under "Not yet implemented" beside things never planned. | LANGUAGE.md | Docs are the highest-leverage model input in the repo. A wrong doc is worse than a missing one. | + +### 5.4 Proposals + +Ordered by value per line of work. Each is a Stage 0.5 item unless noted. + +**S1. Require a statement terminator.** A statement ends at a newline, `;`, or +`}`. Make `T_NL` significant inside `block()` instead of discarding it. +*Why:* fixes R1, and it is the precondition for error recovery — without it a +parser cannot resynchronise, so every syntax error stays a cascade. +*Cost:* ~30 lines. *Ripple:* one-line bodies like `if x { a }` still work; +multi-statement one-liners in the runtime need a `sed`. + +**S2. Make separators mandatory.** Commas required in every comma-list; +remove the optional path. *Fixes R2. Cost:* ~10 lines + tree-wide `sed`. + +**S3. One spelling for boolean operators. ✅ LANDED.** `and`/`or` are the only +boolean operators. Ludic already spells bitwise operations as functions +(`band`/`bor`), so the symbols bought nothing, and dropping them removes the +`&` vs `&&` bug class by construction. + +What shipped: `&&`/`||` still *lex* as single tokens, purely so the parser can +emit `'&&' is not a Ludic operator - write 'and'` instead of tripping over a +stray `&`; `and`/`or` became reserved words, so `let and = 5` is rejected at the +mistake; the AST op string is now `"and"`/`"or"`, which is **exactly the LLVM +opcode**, so the lowering ternary collapsed to passing `op` straight through; +and `--fmt` emits the new spelling for free, since it prints the op string. +Six regression tests in `bin/x test` (64 → 70), including one asserting no `.ludic` +source uses the symbols outside a comment. *Fixed R3.* + +**S4. Reserve every keyword.** One table, shared by the lexer, parser, +`ludic-fmt` and `ludic-lsp` — those tools already share a vocabulary in +`ludic_syntax.h`, so there is one obvious home. Reject `let query = 5` at the +point of the mistake. *Fixes R5. Cost:* ~40 lines. + +**S5. Delete or implement every silent no-op.** � **highest value in the +section.** Two honest options per construct, no third: + +- `reads` / `writes`: **implement them.** The compiler already knows every + property a system touches — it builds the query and walks the body. Checking + the declaration against actual access is a genuine static analysis the + language claims to have and doesn't. This converts dead syntax into a real + guarantee, which is exactly what an "AI-first" language should offer a model + reasoning about a system in isolation. +- `requires` / `ensures`: either lower to a checked assertion in debug builds + (`if !cond { print_err(...) os_exit(1) }` — cheap, and A6 stderr lands in + Stage 0 anyway), or remove them from the grammar until they mean something. +- `pure`, `needs`, `uses`, `effects`, `invariant`: remove until implemented. + +*Fixes R6. Cost:* ~150 lines for `reads`/`writes` checking, ~60 for assertions, +~10 to delete the rest. + +**S6. Cut the block grammars from seven to two.** Full unification is too +invasive to be worth it. The achievable version: every `{ }` is either a +**statement block** or a **field list** (`name: type = default`, comma-separated, +one shape), and `ui` props adopt the same separator rule as everything else. +Document all remaining shapes in one grammar table. *Partially fixes R4. +Cost:* ~120 lines. + +**S7. One formatter with one contract.** Merge the philosophies rather than +keeping two half-tools: `ludic-fmt` gains `--fmt`'s normalisation decisions +(statement-per-line, single spelling, consistent commas) while keeping its +token-based comment preservation, and becomes **normative** — `ludic-fmt +--check` gates CI. `ludicc --fmt` reverts to being an honest debug dump and is +renamed `--dump-ast`. *Fixes R8.* After S1–S3, canonical form is the *only* +form, so the formatter stops being a style preference and becomes a check. +*Cost:* ~200 lines, mostly in `ludic_fmt.h`. + +**S8. Machine-readable grammar and diagnostics.** Emit the grammar as one EBNF +file, and give every diagnostic a stable code plus a one-line suggested fix +(`ludicc --explain L0412`). Feeds the LSP, the docs and any model at once. +*Cost:* ~250 lines. *Defer to after the fixpoint* — valuable, not ordering-critical. + +**S9. Documentation hygiene as a build step.** `bin/x test` already understands +` ```ludic ` fences. Extend it so **every fence in every `.md` must compile**, +and fix R10's stale claims. *Cost:* ~60 lines of shell. Do this early — it is +cheap and it stops the docs drifting further while the rest of the work lands. + +### 5.5 What not to change + +Recording these so they are not relitigated: + +- **Braces, not indentation** (§5.2). +- **`#` comments** — unambiguous, one spelling already. +- **The ECS vocabulary** — `property` / `system` / `query` / `phase` are + unusually self-describing and greppable. This is the language's best existing + readability asset. +- **`fixed` / Q16.16** — determinism is a design constraint, not a style choice. +- **Do not add** operator overloading, implicit conversions beyond `int`→`fixed`, + macros, or anything else with action-at-a-distance. Every one of them trades + local readability for cleverness. + +### 5.6 Sequencing + +| When | What | Why there | +|---|---|---| +| **Now, before Stage 1** | S9 (doc hygiene) | Cheap; stops further drift immediately. | +| **Stage 0.5** | ~~S3~~ ✅ done · S1, S2, S4, S5, S6, S7 | Must precede the port (§5.1). | +| **After Stage 3** | S8 (EBNF + diagnostic codes) | Valuable, not ordering-critical; better written in Ludic against the self-hosted parser. | + +**S3 was the one genuinely contentious call** — which boolean spelling — because +it is pure taste and touches every file. It was decided in favour of `and`/`or` +and has landed. Everything remaining in this section is a choice between "one +spelling" and "two", where the answer is not in doubt. + +**`!` → `not` has since landed too**, on the same reasoning and by the same +mechanism: `!` still lexes (so `!=` is untouched) purely so the parser can say +`'!' is not a Ludic operator - write 'not'`. Ludic's three boolean operators are +now `and`, `or`, `not`, all reserved words, with no symbol spellings at all. + +--- + +## 5.7 Status — self-hosting achieved + +Updated 2026-08-27. `bin/x test` = 93/93, `bin/x test-tools` = 28/28, +`bin/x selfhost-test` = 5/5 including the bootstrap fixpoint. + +**Ludic is fully self-hosted.** The compiler is written in Ludic +(`selfhost/*.ludic`, ~2,400 lines), compiles every example to byte-identical +output and its own source to a fixpoint, and is built from a checked-in IR seed +with **no C compiler** — the former C compiler has been deleted. + +From a clean checkout, build the compiler and the task-runner in one line: + +```bash +clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x +``` + +Thereafter `bin/x build` rebuilds the entire toolchain into `bin/` (`ludicc`, +`ludic`, `x`, `ludic-fmt`, `ludic-lsp`), `bin/x bootstrap-cfree` reproduces the +compiler from the seed with no C compiler, and `bin/x help` lists every command. +Run `bin/x` from the repository root. + +| Stage | What | State | +|---|---|---| +| **0** | language features (argv, struct, arrays/slices, break/continue, mem_realloc, stderr) | ✅ done | +| **0.5** | S3 (`and`/`or`/`not`), S9 (doc checking), short-circuit `and`/`or` | ✅ done | +| — | S1/S2/S4/S5/S6/S7 (statement terminators, mandatory commas, reserved-word audit, no-op removal, block unification, one formatter) | not done — polish of the *full* language, not needed for self-hosting | +| **1** | support libraries in Ludic (`str`, `buf`, `io`) | ✅ done | +| **2** | the compiler ported to Ludic (`lex`, `parse`, `emit_*`) | ✅ done | +| **3** | the fixpoint (`gen2.ll == gen3.ll`) | ✅ done | +| **4** | retire the C as a *live dependency* (IR seed, C-free rebuild) | ✅ done — `bin/x bootstrap-cfree` | +| **4+** | retire `ludicc.c` entirely (port the game backend) | ✅ **done** — `compiler/` deleted; the compiler is `selfhost/*.ludic` | + +### What "self-hosting" means here, precisely + +The self-host compiler (`selfhost/`) implements the **compiler-subset**: `struct` +(reference), `[]T` slices with `push`/`len`, functions, a plain `main` entry, +the full control flow, the operators (with short-circuit `and`/`or`), and the +low-level intrinsics. It deliberately does **not** implement the game half of +Ludic — ECS, queries, models, scenes, UI, save/load, `match`/`machine`, +fixed-point. It targets native (macOS/clang) and emits LLVM IR text that clang +assembles, exactly the posture the C `ludicc` has. + +It is written entirely in that subset, which is why it compiles itself. The +three-generation proof (`bin/x bootstrap`): + +``` +stage0 build/ludicc (C) compiles selfhost.ludic -> gen1 (a Ludic-written compiler) +stage1 gen1 compiles selfhost.ludic -> gen2.ll -> gen2 +stage2 gen2 compiles selfhost.ludic -> gen3.ll +assert gen2.ll == gen3.ll # the compiler reproduces itself, independent of its seed +``` + +`gen1`'s IR legitimately differs (a different compiler built it); `gen2 == gen3` +is the property that matters — the Ludic compiler has no dependency on how it was +built. It is also verified *correct*, not merely self-consistent: it compiles a +corpus (`selfhost/tests/`) of struct, slice, and control-flow programs to +binaries that produce the expected output. + +### Stage 4 — the C is retired as a live dependency + +The self-hosted compiler no longer needs the C `ludicc` to exist. Its own LLVM +IR is checked in as `selfhost/ludicc.seed.ll` — a proven fixed point — and +`bin/x bootstrap-cfree` assembles that with clang (an IR assembler, the +floor Rust and Swift stand on) and rebuilds the compiler, which reproduces its +own IR. **The C source is never invoked.** This is the seed path §8 recommended. + +Crucially, the compiler **evolves** without the C compiler: `bin/x reseed` +uses the *current* seed to build a compiler with new source, then takes that +compiler's own output as the new seed. New features (this session: `match`, +bitwise ops, `peek32`/`poke32`) landed and reseeded entirely C-free. The C +compiler is now a historical seed, not a dependency. + +### Stage 4+ — `ludicc.c` is deleted + +The self-host compiler was extended to the **whole** language — properties, +models, systems, phases, `for … in query` (with `where`), spawn/despawn, +`self()`, `machine`/`become`, `match`, `save`/`load` snapshots, the retained +`ui` widget tree, multi-file `import`, fixed-point Q16.16, and every runtime +intrinsic. It auto-splices the Ludic runtime exactly as the C compiler did. + +It now compiles **every example** — `snake`, `menu`, and the 6-file JRPG +`chronorift` — to output byte-identical to the original C compiler (checked +against golden renders in `selfhost/golden/`), and still compiles its own source +to a fixpoint. The C compiler (`compiler/`, ~2,700 lines) has been **deleted**. +`bin/x build` builds `bin/ludicc` from the IR seed with clang, and `bin/x app` +drives the native link (headless, or windowed via `cocoa.ll`). + +What did not come across: the old C driver's **wasm target, cross-compilation, +and shared-library** paths. Those are driver features, not codegen — the +self-host compiler emits native-ABI IR — and re-implementing them on the +self-hosted toolchain (wasm needs i32 `size_t`; the others are clang flags in +the `bin/x app` build path) is the remaining follow-up. + +--- + +## 6. The plan + +### Stage 0 — Extend the C compiler (~500 lines of C) + +The C `ludicc` must be able to compile the Ludic `ludicc`. Land Tier A only, in +this order — cheapest-and-unblocking first: + +1. **A4** `break`/`continue` (~40) — immediate readability win on everything after. +2. **A5** `mem_realloc` (~6). +3. **A1** `os_argc`/`os_arg` (~30) — unblocks the entire notion of a CLI tool. +4. **A6** `file_stderr` (~40). +5. **A2 + A3** `struct` + arrays (~400, landed together). + +Each gets a test in `bin/x test` as it lands. The suite is at 64/64; Stage 0 should +leave it green and larger. + +**Explicitly not in Stage 0:** function pointers, 64-bit ints, a `tool` entry +form. They are not on the path to the fixpoint. + +### Stage 0.5 — Syntax freeze (~600 lines of C + a tree-wide `sed`) + +**The grammar must be final before Stage 2 starts** (§5.1): after the fixpoint, +every syntax change costs a four-step reseed instead of an afternoon. + +Land S1–S7 from §5.4: mandatory statement terminators, mandatory separators, +one boolean spelling, reserved keywords, no silent no-ops, two block shapes +instead of seven, one normative formatter. S9 (doc hygiene) can land earlier — +it is cheap and independent. + +Exit criterion: `ludic-fmt --check` passes on the whole tree and there is +exactly one legal spelling of every construct. That is also what makes the +Stage 2b oracle tight. + +### Stage 1 — Support libraries in Ludic (~800 lines of Ludic, zero language work) + +Nothing here needs Stage 0 except `struct`/arrays for pleasantness. This is the +part that is pure writing, and it can start immediately and in parallel. + +| File | Contents | +|---|---| +| `runtime/native/strings.ludic` | `str_eq`, `str_len`, `str_dup`, `str_cat`, `substr`, `str_chr`, `str_hash`, `itoa`, `atoi`, `hex` | +| `runtime/native/buf.ludic` | growable byte buffer — `buf_new`, `buf_putc`, `buf_puts`, `buf_putint`, `buf_len`, `buf_ptr`. This is `SB` from `ludicc.c`, and the IR emitter is nothing but calls to it. | +| `runtime/native/io.ludic` | `read_whole_file` (the open/seek/tell/read/close dance, verified working), `write_whole_file`, stderr diagnostics | +| `runtime/native/map.ludic` | open-addressing `str -> int` hash table: keyword lookup, string interning, symbol tables | +| `runtime/native/arena.ludic` | bump allocator — see §8 | + +### Stage 2 — Port the compiler, each piece against a differential oracle + +Port in dependency order. The critical discipline: **never port a stage without +an automated way to prove it agrees with the C one.** Ludic is unusually well +set up for this, because it already ships two canonical serializers of compiler +internals. + +| Sub-stage | Port | Differential oracle | +|---|---|---| +| 2a | `lex.ludic` | Dump the token stream from both compilers; `diff` over every `.ludic` in the tree. | +| 2b | `parse.ludic` (AST) | **`--fmt` is a free oracle.** The formatter is already a canonical AST printer, and `bin/x test` already asserts formatting never changes a program. If both compilers' `--fmt` output is byte-identical on every file, the parsers agree. | +| 2c | `check.ludic` | Diagnostic text must match on a corpus of deliberately-broken programs. `bin/x test` already checks diagnostics — extend that corpus. | +| 2d | `emit.ludic` (IR) | **`--emit llvm` must be byte-identical** for every example. This is the strongest oracle available: pass/fail on exact text, no judgement. | +| 2e | `drive.ludic` | Assemble and link via `clang`; compare final binaries. | + +Sub-stage 2b deserves emphasis. Most self-hosting projects have no cheap way to +prove two parsers agree. Ludic has one already built and already tested, which +removes the single largest source of silent divergence. + +### Stage 3 — The fixpoint + +``` +stage1 = C-ludicc compiles ludicc.ludic -> binary A +stage2 = A compiles ludicc.ludic -> binary B +stage3 = B compiles ludicc.ludic -> binary C + +assert B == C byte-for-byte <- THE bootstrap test +``` + +`A != B` is expected and correct: `A` was built by a different compiler, so its +codegen differs. `B == C` is the real property — a compiler that reproduces +itself has no dependency on how it was built. Also assert that `A`, `B` and `C` +all emit identical IR for every example. + +If `B != C`, the cause is almost always nondeterminism in the compiler itself: +hash-table iteration order, an address baked into output, uninitialised memory. +Those are worth hunting rather than working around. + +### Stage 4 — Retire the C + +Once the fixpoint holds, the C compiler becomes a seed. Options: + +| Option | Trade-off | +|---|---| +| **Commit the generated `ludicc.ll`** ✅ recommended | Auditable text, diffable in review, builds with `clang` alone — already a dependency. Large but honest. | +| Commit prebuilt binaries per platform | Smallest process, worst auditability; a binary blob nobody can read. What Rust does. | +| Keep `ludicc.c` forever as the seed | Zero risk, but §1's bar is never met — the C never leaves. What Go did for years. | + +Recommend the IR seed: it is the only option that both removes the C and leaves +a reviewer something to read. + +`tools/ludic-tools/` (3,260 lines of C: `ludic-fmt`, `ludic-lsp`) is a separate +port and should follow, not lead — once the Ludic compiler exists, both tools +should be thin front-ends over its lexer and parser instead of maintaining a +second copy of the vocabulary. + +--- + +## 7. Toolchain independence — and where to stop + +`driver.c` shells out to `clang` (overridable via `$LUDIC_CC`) to assemble IR +into an object and to link, plus `wasm-ld` for wasm. Three levels of removing +that, and only one is worth doing: + +**Level 1 — self-hosted compiler, hosted toolchain. � the goal.** +`ludicc` is Ludic; `clang` remains the IR assembler and linker driver. This is +exactly where Rust and Swift stand. Achieved at the end of Stage 4. + +**Level 2 — own object writer.** Emit Mach-O / ELF / COFF directly, replacing +IR-text + `clang -c`. Requires instruction selection, register allocation and +relocations: realistically 5,000–15,000 lines of Ludic, and it *loses the LLVM +optimizer* — the generated code gets slower, which for a game language is a +real regression, not a neutral trade. **Not recommended.** + +**Level 3 — own linker.** Platform-specific, deep, and buys nothing a user can +perceive. **No.** + +**7.4 — The hand-written IR.** `cocoa.ll` (327 lines) and `wasm.ll` are LLVM IR, +not Ludic. Two defensible positions: keep them as *platform glue written in the +platform's own assembly language* (precisely how Rust uses `asm!` shims and how +every libc has hand-written syscall stubs), or move them into `.ludic` — which +needs **B1 function pointers**, because the `NSView` subclass installs a +function as an Objective-C IMP. Keeping them is the honest default; the README's +existing framing ("the same floor Rust and Swift stand on") already covers it. + +--- + +## 8. Risks and gotchas + +- **Do not build the AST out of ECS entities.** `LUDIC_MAX_ENT` is 1024 + (`native.c:18`) and property storage is fixed arrays. It compiles, it looks + elegant, and it caps the compiler at 1024 nodes. Use `struct` (A2). +- **Do not inherit the C compiler's fixed caps.** `ludicc.c:22` has + `g_srcpath[128]`; `native.c:161` has `Val a[8]`. The Ludic port should grow + its tables (A5) rather than reproduce the limits. +- **Leak on purpose.** A compiler runs once and exits. A bump arena + (`arena.ludic`) that never frees is faster and simpler than tracked + ownership, and it sidesteps having no GC. Free at process exit — i.e. never. +- **Determinism is a feature now.** Anything order-dependent — hash iteration, + pointer values in output, uninitialised reads — breaks `B == C` in Stage 3. + Iterate tables in insertion order, not bucket order. +- **Error handling has no exceptions.** Mirror the C `die()`: write the + diagnostic to stderr (A6), then `os_exit(1)`. +- **The `str + str` mismatch (B2)** is a live example of the front-end accepting + what the backend cannot lower. Worth auditing for siblings before trusting + the typechecker as a Stage 2c oracle. +- **Size-taking intrinsics must use `ll_size_t()` / `ll_widen()`.** `size_t` is + `i32` on wasm32. Any new intrinsic with a size argument (A5) that hardcodes + `i64` will break the wasm target at link time. +- **Recursion depth is fine** — 5,000 frames verified, well past what a + recursive-descent parser needs on real source. + +--- + +## 9. Effort + +| Stage | Work | State | +|---|---|---| +| 0 | Tier A language features (argv, struct, slices, break/continue, mem_realloc, stderr) | ✅ done | +| 0.5 | `and`/`or`/`not` + short-circuit; doc checking (S9) | ✅ done (S1/S2/S4/S5/S6/S7 deferred — full-language polish) | +| 1 | `str`, `buf`, `io` support libraries in Ludic | ✅ done (`selfhost/`) | +| 2 | lexer + parser + AST + IR emitter, in Ludic | ✅ done (`selfhost/`, ~1,300 lines) | +| 3 | the fixpoint (`gen2.ll == gen3.ll`) + harness | ✅ done (`bin/x bootstrap`) | +| 4 | port the game backend, retire `ludicc.c` | ⛔ out of scope — mechanical continuation | + +The self-host compiler is **~1,300 lines of Ludic** covering the compiler-subset. +A `main`-tool entry point and short-circuit `and`/`or` were the two language +additions that made it self-compilable; the rest of Stage 0 was already in place. + +Roughly **6,000 lines of Ludic and 1,100 lines of C** to reach Level 1 — larger +than the 2,508-line C compiler it replaces, which is normal: the C version leans +on libc for everything in Stage 1. + +**Two independent critical paths, and they can run in parallel.** Stage 0 + Stage 0.5 +are C work on the existing compiler; Stage 1 is Ludic work that needs almost none of +it. The only hard barrier is that Stage 2 starts after *both*. + +**The critical path is short.** A1 (argv, ~30 lines of C) plus A2/A3 +(`struct` + arrays, ~400) plus A4 (`break`, ~40) is nearly all the *design* risk +in the project. Everything after it is typing against oracles that already +exist. + +--- + +## Appendix — probe programs + +Each was compiled with `bin/x app probe.ludic --headless` and run against the +current tree (`bin/x test` = 64/64). + +**Recursion** ✅ → `55` +```ludic +program P { + function fib(n: int) -> int { if n < 2 { return n }; return fib(n-1) + fib(n-2) } + handler B phase Start { print_int(fib(10)); quit() } +} +``` + +**String comparison, hand-written** ✅ → `1` +```ludic +function streq(a: pointer, b: pointer) -> bool { + let i = 0 + while true { + let ca = peek8(a,i) + let cb = peek8(b,i) + if ca != cb { return false } + if ca == 0 { return true } + i = i + 1 + } + return false +} +``` + +**Integer → string, hand-written** ✅ → `48291` +```ludic +function itoa(v: int, buf: pointer) -> int { + let n = 0 + let x = v + if x == 0 { poke8(buf,0,48); return 1 } + let tmp = mem_alloc(16) + while x > 0 { poke8(tmp, n, 48 + x % 10); x = x / 10; n = n + 1 } + let i = 0 + while i < n { poke8(buf, i, peek8(tmp, n-1-i)); i = i + 1 } + mem_free(tmp) + return n +} +``` + +**Read a whole file** ✅ → the compiler's front door +```ludic +let f = file_open("/tmp/x.txt", "rb") +file_seek(f, 0, 2) +let n = file_tell(f) +file_seek(f, 0, 0) +let b = mem_alloc(n+1) +file_read(f, b, n) +poke8(b, n, 0) +file_close(f) +``` + +### Syntax audit — every one of these compiles today + +Each is a spelling the language accepts; the point is that the *alternative* +spelling is equally legal (§5.3). + +**R1 — statements now require a separator (Rule B, syntax-redesign Phase 2)** → parse error +```ludic +# doc-check: skip — intentionally rejected under Rule B: needs a newline or ';' +program P { handler B phase Start { let x = 1 x = x + 1 print_int(x) quit() } } +``` +Statements no longer sit adjacent with only spaces between them; the compiler +reports `expected newline or ';' between statements`. Put each on its own line, +or separate them with `;` (both lex to the same separator token): +```ludic +program P { handler B phase Start { let x = 1; x = x + 1; print_int(x); quit() } } +``` + +**R2 — commas omitted throughout** → `7` +```ludic +# doc-check: skip — composite: declaration plus statements +property Pos { x: int = 0 y: int = 0 } +spawn Hero { Pos { x: 7 y: 2 } } +``` + +**R3 — RESOLVED.** Every symbol form is now rejected where it is written: +``` +ludicc: error: line 1: '&&' is not a Ludic operator - write 'and' (got '&&') +ludicc: error: line 1: '||' is not a Ludic operator - write 'or' (got '||') +ludicc: error: line 1: '!' is not a Ludic operator - write 'not' (got '!') +ludicc: error: line 1: 'and' is a reserved operator and cannot be used as a name +``` +`--fmt` prints `if ((true and false) or (1 < 2))` and `(not true)`, while unary +minus keeps its tight spelling `(-x)`. `!=` is untouched. + +**R5 — reserved-looking words used as locals** → `11` +```ludic +let query = 5 +let phase = 6 +print_int(query + phase) +``` + +**R6 — contracts accepted and discarded.** Both are violated; it compiles and +prints `4`: +```ludic +# doc-check: skip — composite: declaration plus statements +function half(n: int) -> int requires n > 100000 ensures false { return n / 2 } +print_int(half(8)) +``` +And a system may declare read-only access, then write — also compiles: +```ludic +handler Violate phase Update reads [Pos] query (p) [Pos] { p.x = 99 } +``` + +**R8 — the two formatters disagree about what "format" means.** Given +`property Pos { x: int = 0 y: int = 0 }` and a multi-statement one-liner, +`ludicc --fmt` rewrites both (one statement per line, `and`→`&&`, full +parenthesisation) while `ludic-fmt` returns the input **unchanged**. + +**Verified-missing** — each a compile error today: +```ludic +# doc-check: expect-error — every line here is a compile error by design +while i < 10 { i = i + 1; if i == 3 { break } } # unknown identifier 'break' +struct Node { k: int, a: pointer } # expected declaration (got 'struct') +var t: [int; 8] # expected identifier (got '[') +var h: fn = a # unknown type 'fn' for var h +let p = &cb # fails to compile +print_int(os_argc()) # unknown function 'os_argc' +print_err("x") # unknown function 'print_err' +let p = mem_realloc(ptr_null(), 10) # unknown function 'mem_realloc' +print_str("ab" + "cd") # passes front-end, invalid IR +let a = 100000; print_int(a*100000) # 1410065408 — i32 wrap +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ed5947..cb8b3e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1260 +1,14 @@ # Changelog -All notable changes to the Ludic toolchain, newest first. Each section is -generated from the changesets under changes/ by `ludic dev release`, grouped by -change type. Preview the next one with `ludic dev release --dry-run`. - -A released section may carry a hand-written summary paragraph above its groups -(v0.2.0 has one); the generated bullets below it are not edited by hand. - -## v0.22.0 — 2026-09-18 - -### Features - -- **Every key on the keyboard is bindable** — the F-row, Home/End/PageUp/PageDown, - Insert/Delete, Caps Lock and the numpad reach a game as codes 132-166. - - Until now the platform gave them no code at all, so a game's rebinding screen could - not take one and nothing said why. Windows asked the active layout what they type and - got nothing back (`w_vk_char` answers 0 for a key with no character); macOS let them - fall through to `charactersIgnoringModifiers`, which reports `NSF1FunctionKey` and its - neighbours at 0xF704 and up - outside the 256-bit held set either way. - - - **The codes are the same on both platforms**: 132-143 are F1-F12, 144-149 are Home, - End, PageUp, PageDown, Insert and Delete, 150 is Caps Lock, 152-161 are the numpad - digits and 162-166 its `*`, `+`, `-`, `.` and `/`. The numpad's Enter is Enter. - - **`Key.F1`, `Key.Home`, `Key.Numpad0`** and the rest fold at compile time like the - other named keys. - - **`Input.key_label`** names them - "F1", "Home", "CapsLock", "Num 0", "Num /" - and - does *not* ask the layout, because a key that types nothing is called the same thing - on every layout. -## v0.21.1 — 2026-09-17 - -### Fixes - -- A windowed game opens under its package's `app name`. `ludic build`, `ludic run` and - `ludic bundle` pass it to the compiler (`ludicc --title `), so `game_title()` - the - window's first title - is the name the player knows rather than the `program` name, which - showed for up to two seconds before the renderer retitled the window. Without an `app name` - it is the `program` name, as before. -- `Crypto.random_bytes`, `Crypto.random_hex`, `Crypto.random_u32` and `Uuid.*` draw from the - system CSPRNG on Windows (`RtlGenRandom`, advapi32). They read `/dev/urandom`, which Windows - does not have, and every byte came back zero. -## v0.21.0 — 2026-09-17 - -### Features - -- **A launcher's runtime** — start a program, step aside while it runs, and download large - files with a progress bar. - - - **`Process.*`** — `Process.spawn(path, args)` starts a program directly (no shell) with this - process's environment and working directory and returns at once; `Process.poll(h)` is `-1` - while it runs, then its exit code (`128 + signal` for a signal death, `137` after - `Process.kill`); `Process.free` lets a handle go. `posix_spawn` on macOS - (`runtime/native/process.ll`); `CreateProcessW` on Windows (`process_win.ll`), with each - argument quoted by the MSVC rules and no console window. Linked only into a program that - uses `Process.*`. - - **`Http.save_to(h, path)`** — before `Http.send`, streams the response body straight into a - file (written at `path` itself, created on send) instead of memory; `Http.text` of such a - request is empty and `Http.body_len` is the bytes written. **`Http.received(h)`** and - **`Http.expected(h)`** (the `Content-Length`, or `-1`) are live while it is pending. macOS - streams through an `NSURLSession` with a delegate built at run time, Windows through the - WinHTTP read loop. Freeing a pending request now cancels it and parks its slot until the - worker has let go, rather than freeing what the worker is still writing. - - **`App.window_hide()` / `App.window_show()`** — take the game's window off the screen and - bring it back without closing it or its GL / Vulkan surface; the run goes on while it is - hidden. No-ops headless and before there is a window. - -### Fixes - -- A game's window takes the title it asks for. The runtime opens a game's window before `main`, - titled with the `program` name, and `Gl.open` / render3d's `gl_open` and `gvk_open` attached - to it without passing their title on, so a `program Valley` that opened "Maroon Lake" showed - "Valley". `win_set_title` (cocoa.ll `setTitle:`, win32.ll `SetWindowTextW`, UTF-8 to UTF-16) - now retitles it, and a second `win_open` on macOS retitles the open window instead of making - another, as it already did on Windows. -## v0.20.0 — 2026-09-17 - -### Features - -- The JetBrains plugin (1.6.0) has a Ludic tool window for the package, like the Gradle one: - - an overview of the package (what it is, entry, version, dependencies, scripts, hooks, app); - - its commands, scripts and hooks, run by double-click, with each command's hooks shown; - - its dependencies against package.lock.ludic: the locked version, whether it is fetched - and linked, the indirect ones, and Fetch, Update, Verify, Vendor, Add and Remove; - - the app preview the `app` lines describe, and the asset roots with missing ones marked. - The bar over package.ludic now appears only when something needs doing (fetch, or no - toolchain), instead of carrying every command. -## v0.19.0 — 2026-09-17 - -### Features - -- `Udp.*`: polled IPv4 datagrams - the transport under a game's own netcode. `Udp.open(port)` - binds a non-blocking socket (port 0 picks one, `Udp.port` says which), `Udp.send` sends one - datagram to an address and port, and `Udp.recv` drains what arrived without ever blocking; - `Udp.from_ip` / `Udp.from_port` name the sender. `Udp.ip` / `Udp.ip_text` convert addresses, - `Udp.resolve` looks a host name up and `Udp.local_ip` is the address a player on the same - network would dial. BSD sockets on macOS (`runtime/native/udp.ll`), Winsock on Windows - (`udp_win.ll`, linked with ws2_32), linked only into a program that uses `Udp.*`. -## v0.18.1 — 2026-09-16 - -### Fixes - -- The release build links libm on Linux, which `float`/`double` code needs (glibc keeps - `sinf`, `sqrtf` and the rest there). 0.18.0's release job stopped on this before - publishing, so 0.18.1 is the first published build with float types, barrel imports, - package scripts and hooks, and the JetBrains 1.5.0 support. -## v0.18.0 — 2026-09-16 - -### Features - -- Run a single test by name: a compiled test program takes the test's exact name as its - first argument (`./tests "adds"`), and `ludic test` gains `--test NAME` to pass it through. - A name that matches no test is reported and fails the run. `ludic test --verbose` (`-v`) - prints every test's `ok`/`FAIL` line, framed by `RUN ` … `PASS|FAIL `, which is - what editor test views parse. -- The JetBrains plugin covers much more of the IDE: - - Run configurations for `ludic run`/`build`/`test` (plus any other `ludic` command), with - gutter run buttons on `program` and on every `test "name"` block. - - Test results appear in the IDE's test tree, with navigation back to each test block. - - `file.ludic:line` locations are clickable in every console. - - Smart indentation on Enter and when typing a closing bracket; quotes pair up. - - A Color Scheme page and a Code Style page. - - Live templates, New Ludic File templates, and a Ludic project in File | New | Project. - - A Tools | Ludic menu, and a warning when the toolchain is missing. - - `package.ludic` is treated as a package manifest: - - a banner offers Fetch, Update, Verify, Run, Test, Build and Bundle, and warns when the lock is - missing or older than the manifest; - - gutter actions on `package` and `require` lines; - - completion and quick documentation for directives and `app` keys; - - its own icon. - - `script` and `hook` lines complete and are validated; each script has a run button, the - package banner has script shortcuts, and Tools | Ludic | Run Script… lists them all. - - The toolchain's and the project's packages are listed under External Libraries. - - The Structure view, parameter info and code-block navigation now come from `ludic-lsp`. - - The plugin is now 1.5.0 and requires IntelliJ Platform 2024.2 or newer and LSP4IJ 0.21. - - Fixed: Enter no longer inserts an extra `}`, and Comment Line no longer writes two spaces. -- `float` and `double`: IEEE floating point with ordinary operators, so renderer math reads - `a * b + c` instead of `f_add(f_mul(a, b), c)`. - - Decimal literals take their type from context (`let s: float = 0.1` is exactly 0.1), and - stay `fixed` elsewhere, so existing programs keep their meaning. - - `int`/`long` promote; `float(x)`, `double(x)`, `int(x)`, `long(x)` and `fixed(x)` convert. - - `floats(n)` / `doubles(n)` buffers; float fields, globals, constants and parameters. - - `Math.*` computes in float when given one; `string`/`print`/interpolation write the - shortest round-tripping decimal; `float_bits` / `float_from_bits` expose the IEEE pattern. - - `@deterministic` code may not use floats (it is now checked). -- `import "camp"` imports a directory through its barrel, `camp/index.ludic` (a fragment - listing the directory's own imports). A directory without one is a clear error. Packages - resolve the same way: `import "ludic.render3d"` reads `ludic.render3d/index.ludic`. -- `ludic-lsp` navigates like the compiler resolves: - - Package imports (`import "ludic.render3d/r3d.ludic"`) resolve from `ludic_modules/` and the - toolchain's `packages/`, so their names hover, complete and jump. Import strings are links. - - `var` locals are tracked (declaration, usages, rename). - - Hover shows the declared or inferred type of locals, parameters and globals. - - Hover and go-to-definition on built-ins, built-in types and namespaces show the reference - page, with a link to the online docs. Release archives now ship `docs/language` for this. - - Go to type declaration (`textDocument/typeDefinition`) is supported. - - Signature help carries per-parameter ranges and follows named arguments. - - Completion inside a call offers the parameter names not given yet, `Namespace.` offers its - members, and results are ranked: locals first, then globals, built-ins and keywords. -- `package.ludic` gains `entry`, scripts and lifecycle hooks: - - `entry "src/game.ludic"` names the program `ludic run`/`build`/`bundle` compile. Without - it, the one file under `src/` that declares a `program` is used. - - `script "dev" "ludic run --headless"` defines a command: `ludic dev` (or `ludic script dev`) - runs it with any extra arguments, and `ludic scripts` lists them. - - `hook before|after "…"` wraps `build`, `run`, `test`, `bundle`, `pack`, `get`, … - or a script. A failing `before` hook stops the command; `after` hooks run only on success. - - Quoted manifest values accept `\"` and `\\` escapes. - -### Fixes - -- **`==` / `!=` on records is identity again** — only two strings compare by content. - - Every pointer-typed comparison used to be lowered to a C-string content compare, - so two distinct records (properties, slices, enums) compared their bytes up to - the first zero byte: `a == b` could be true for different objects that shared a - leading field, depending on layout. References now compare by identity - (`icmp eq ptr`); `string` (and untyped `pointer`/`pointers` text) still compares by content, - and comparing a `string` with a non-string reference is a compile error. -## v0.17.0 — 2026-09-16 - -### Features - -- **Renderer switches stay out of shipped games** — every `R3D_*` environment switch in - `ludic.render3d` (debug views, feature kills, file writers, hardware fakes, the Windows - feature overrides) now goes through one gate: a headless build honours them as before, a - windowed build only when `R3D_DEV` is set to anything but `0`. See `docs/SHIPPING.md`. - -### Fixes - -- **A lake can be the water that mirrors the world** — a reflecting body is clipped to its - ellipse like any other unless it is an unbounded sea, so a map whose reflection belongs to - its lake no longer floods every hollow in the survey with that lake's level; and the - terrain's wet shore, forest and scree gates read the carved lake's line inside its outline - (`u_lake`), the rule the grass already used, so a lake above the sea keeps its wet bank and - no forest is painted down its bed. - - `terrain_lake_carve(false)` keeps a lake's line, outline and shore but skips carving its - bed, for a height map that already carries a shaped one. -## v0.16.2 — 2026-09-15 - -### Fixes - -- **Keys are physical positions on Windows and macOS, whatever the layout** - `Input.key_down('w')` - is the key above S on every keyboard. - - - **Windows** keyed the held set by the character the active layout gave a virtual key, so - a game's WASD belonged to whatever the layout put there: on AZERTY W and A were other - keys, and with an input method on (Chinese, Japanese, Korean) every letter arrived as - VK_PROCESSKEY and no letter key worked at all. The typing block is now read from the - scancode; the arrows, the numpad and the F-keys still go by virtual key, and the frame - key (`Input.key`, typing) keeps the layout's character. - - **macOS** did the same through `charactersIgnoringModifiers`; it now reads `keyCode`. - - **`Input.key_label(key)`** names a key for the player in their own layout - `'w'` shows - as "W" on QWERTY and "Z" on AZERTY - so a game that shows its bindings never shows a - key the player cannot find. Windows reads the layout; headless, macOS and the browser - give the US character. -## v0.16.1 — 2026-09-15 - -### Features - -- **HDR calibration, and two HDR fixes** — the HDR10 picture follows the player's display instead of one fixed curve. - - - **`r3d_hdr_calibrate(peak, paper, black)`** — nits as float bits: the brightest the display shows (100-10000), - where the picture's and the interface's white sit (80-1000, never above the peak) and how far the darkest shade is - lifted (0-5, fading out by paper white). The tonemap's and the overlay's HDR10 variants read them, and the display's - HDR metadata is sent again with the new peak. The defaults are the old constants: 1000, 200 and 0. - - **`ov_hdr_nits(nits)` / `ov_hdr_paper()`** — what the overlay draws next is at that many nits on an HDR10 frame, - for a calibration screen's test patches; it closes the batch so far. No effect on an SDR frame. - - **Fixed: a crash toggling HDR on the Vulkan renderer.** `gpu_caps_probe()` made and destroyed a second Vulkan - instance under NVIDIA Streamline's interposer; the next swapchain rebuild then called through a pointer that - instance had left behind, at address 0. With the Vulkan renderer running, the probe asks its instance instead. - - **Fixed: yellow read as red in HDR.** The overlay drew sRGB values straight into the HDR10 swapchain (it now has an - HDR10 variant, chosen while `gpu_hdr_active()`), and the tonemap extended highlights per channel, which boosted a - bright yellow's red far more than its green; it now brightens the graded colour by one factor. -## v0.16.0 — 2026-09-15 - -### Features - -- **A loading screen can show the renderer's start-up as it happens.** `r3d_open(w, h, title)` opens - the window and the graphics backend with nothing baked, and `r3d_load_count()` / `r3d_load_step(i)` - run the rest - the sky and its light, the terrain, the shadow and screen targets, the scattered cover - and actors, the grass - one step at a time, so a game can draw and present a frame between them. - `r3d_init` is those same calls in a row and is unchanged for everything that uses it. -- **HDR10 output on the Vulkan renderer** — `r3d_hdr(on)` asks for an ST 2084 / BT.2020 swapchain where the - display offers one. - - The tonemap's HDR10 variant keeps the SDR picture up to a 200-nit paper white and rolls highlights on to - 1000 nits; the overlay converts the interface to the same white; HDR metadata is set where the loader has - the command. The screen and LDR images are 10-bit while it is on, and screenshots refuse rather than write - a PQ image as SDR. OpenGL and the Vulkan SDR frame are unchanged. `R3D_HDR` overrides the setting. -- **Mesh-shader grass** — on a card with `VK_EXT_mesh_shader`, `r3d_mesh_grass(on)` draws each grass tile as one - mesh-shader dispatch sized to that tile's blades, and a blade that is culled emits no vertices at all. - - - **`ludic-dev shaders`** — a variant whose first file is `*.mesh` compiles that stage as a Vulkan 1.3 mesh - shader; its SPIR-V keeps the `.vert` name, so the manifest is unchanged. - - **The seam** — `gpu_has_mesh()` and `gpu_draw_mesh_tasks(x, y, z)`; a mesh program's pipeline has no - vertex input and its bindings the mesh stage. The device enables `meshShader` and `maintenance4`. - - Not yet faster: at 4K on an RTX 3070 Ti the grass pass takes 5.0 ms against the chunked path's 1.7, so - `gpu_feature_implemented(GF_MESH_GRASS)` stays false and a game should not offer it as a finished setting. -- **More of the renderer's quality is a switch a game can offer.** - - - `sky_set_quality(width)`: the prefiltered sky light at 256, 512 or 1024 wide (half as tall), - baked again at once. - - `post_set_msaa(samples)`: 1, 2 or 4 samples for the scene on OpenGL, remaking the screen targets in - place (`post_msaa_live()` is false on Vulkan, which has no sample counts yet); `post_free` now - frees the multisampled framebuffer too. - - `STREAM_BUDGET_US` is a variable: the microseconds a frame may spend generating streamed cover. - - `r3d_fog_scale`: a multiplier over the fog density the daylight and the weather set. -- **NVIDIA DLSS super resolution and Reflex on the Vulkan renderer** — through NVIDIA Streamline 2.14.1. - - - **The loader** — on Windows `Vk.open()` takes `sl.interposer.dll` beside the executable in place of - `vulkan-1.dll` when a program asks (`Vk.sl_prefer`), falling back to plain Vulkan without it; the `sl*` - exports and feature functions are reachable from Ludic. - - **render3d** — `r3d_dlss(mode)` upscales the lit HDR frame before bloom and the tonemap, jittered on a - Halton cycle, with preset K in every mode (the default M cost 18 ms a frame at 4K on an RTX 3070 Ti); - `r3d_dlss_render_w/h` give the size to render at. `r3d_reflex(mode)` sleeps each frame and marks - simulation, submit and present. `R3D_DLSS`, `R3D_REFLEX`, `R3D_DLSS_PRESET`, `R3D_SL_LOG` for tests. - - **Commands the interposer lacks** — it exports no `vkSetHdrMetadataEXT`, `vkCmdDrawMeshTasksEXT` or - acceleration-structure command; `Vk.has` checks one and `vkGetDeviceProcAddr` reaches it. -- **Real OS threads behind `Job.*` and `Sync.*`** — `Job.parallel_for` runs work across every core. - - - **`fn name`** — an expression naming a top-level function as a value, for a worker's entry point. - No closures: the data a worker needs is passed in. - - **`Job.parallel_for(count, fn work, ctx)`** — `work(i, ctx)` for every `i` in `[0, count)`, split - into chunks across a worker pool (one thread per core but one, pthreads or Win32) and the calling - thread; returns when all are done. `Job.is_worker()` says whether code is on a pool thread. - - **`Sync.*` is thread-safe** — mutexes are real mutexes, atomics are compare-and-swap, channels are - guarded, and `Sync.cpu_count()` reports the machine's cores. - - **A worker must not change the world** — `spawn` and `despawn` on a pool thread stop the program with - a located message. -- **Texture filtering and shadow resolution as settings a game can change while it runs.** - - - `r3d_set_anisotropy(level)` sets the anisotropic filtering of every mipmapped texture already - loaded, not only of later uploads, on OpenGL and Vulkan. - - `shadow_set_res(size)` remakes the cascades' depth layers at 1024, 2048 or 4096 (`shadow_res` - replaces the `SHADOW_RES` constant); the lighting reads the texel size from the map. -- **`app native ""` in `package.ludic`** — `ludic bundle` on Windows copies that directory's files beside - the executable: native libraries loaded at run time (NVIDIA Streamline's DLLs, say) and their licence texts, - which cannot live in the pack. - -### Fixes - -- **The meadow's grass draws on the Vulkan renderer again** — the chunked grass path uploaded its `vec4` tile - table with `u_fv`, which on Vulkan copies one float per array element, so every tile had no blades. - `u_f4v` uploads arrays of `vec4`. - -### Performance - -- **The sky's light is baked once at start, not twice.** A game that turns the sky at boot sets - `sky_start_yaw` before `r3d_init`, and `sky_load` bakes the image-based light at that yaw; turning to - a yaw the light is already baked at (`sky_set_yaw`) no longer bakes it again. Without it, nothing - changes. -## v0.15.0 — 2026-09-15 - -### Features - -- Vulkan for Ludic, and the start of a second renderer beside OpenGL. - - - **`Vk.*`**: every command of Vulkan 1.0-1.4 and of the extensions a modern renderer is - built around (swapchain, HDR colour spaces, ray query and acceleration structures, opacity - micromaps, mesh shaders, variable rate shading, memory budget, pipeline libraries, NVIDIA - low latency, portability for MoltenVK), with every constant and every struct's size - (`_sizeof`) and field offsets (`_`). Generated from the Vulkan - registry by **`ludic-dev vkgen`** into `runtime/native/vk_api.ludic` and `vk_thunks.ll`; - structs are plain memory filled by name with `Vk.put_i32` / `put_i64` / `put_ptr`. Every - size and offset was checked against the SDK's C headers (3128 facts). A C float is float - bits in an int; 64-bit values and non-dispatchable handles are `long`. - - **The loader is opened at run time**, never linked: `vk_win.ll` (`vulkan-1.dll`) and - `vk_mac.ll` (`libvulkan.1.dylib`, MoltenVK). `Vk.open()` returning 0 means no Vulkan, and - the program carries on. `ludicc` and `ludic build` link both files for any program that - uses `Vk.*`. - - `examples/rendering/vk_probe.ludic` reports what a machine's Vulkan can do; - `vk_compute.ludic` runs a Slang compute shader (`vk_compute.slang`) and reads the picture - back - on an RTX 3070 Ti and on an M4 Pro through MoltenVK, clean under the validation layer. - - **render3d `gpu.ludic`**: the seam between the renderer and a graphics API. Render state - and uniforms go through `gpu_*` / `u_*` and nowhere else (OpenGL frames unchanged); - `R3D_GFX=gl|vk` or `gpu_request` chooses a backend, falling back to OpenGL with a reason; - `gpu_caps_probe()` detects, on Windows, the Vulkan 1.3 floor, ray tracing, mesh shaders, - the NVIDIA RTX generation, Reflex and HDR, for a settings screen to grey out what a machine - cannot use (`R3D_CAPS=rtx50|rtx40|rtx30|amd|intel|none` pretends, for tests). -## v0.14.2 — 2026-09-14 - -### Fixes - -- **`Input.mouse_dx` / `mouse_dy` no longer jump on the first frame or when the cursor mode changes.** - The delta was the position minus the previous frame's, and the previous position started at 0,0, - so the first frame reported the cursor's whole distance from the corner as motion, and switching - between a locked cursor's virtual reticle and the real cursor did the same. A camera that adds - `mouse_dy` to its pitch came up pointing at the ground with nobody touching the mouse. Both frames - now report no motion; every other frame is unchanged, windowed or headless, on macOS and Windows. -## v0.14.1 — 2026-09-13 - -### Fixes - -- **A rim from `outline_model` no longer stays on after the caller stops asking** — the queue - was only emptied by the next `outline_model` call, so a game that highlights what the - crosshair is on left the last highlighted tree outlined for good once the player looked - away. `r3d_frame` now drops a batch nobody reopened this frame (`outline_frame`). -## v0.14.0 — 2026-09-13 - -### Features - -- **Overlay text is UTF-8, and a font atlas can carry any script** — `ludic.render3d`'s `ov_text`, - `ov_text_w` and `ov_text_wrap` decode UTF-8 instead of drawing bytes 32–126, so a game can show - Turkish, German, Polish, Greek, Cyrillic or whatever its atlas holds. - - - `font.json` may list the atlas's code points in `"codes"` (atlas order). An atlas without it is - read as before: consecutive code points from `"first"`, so existing ASCII fonts keep working. - - A code point the atlas lacks draws as `?`; a control character as a space; a malformed or - cut-off sequence as one `?`, so a string sliced mid-character still draws. - - `overlay_font(dir)` loads or swaps the atlas at run time, for a language that brings its own font. -## v0.13.3 — 2026-09-13 - -### Performance - -- **Dense forest renders faster** — a depth prepass for the near tree foliage in `ludic.render3d`. - - A crown of needle cards is many cut-out quads deep, and a shader that can `discard` turns - early depth rejection off, so every card behind the front one ran the full lighting shader. - In a dense stand at 3840x2160 that made the vegetation pass the largest in the frame. The near - tree LODs now write depth first with `depth.frag` (the same alpha coverage test), the terrain - beneath them is rejected before shading, and the lit pass draws them with no `discard` and an equal depth test. `R3D_NOPREPASS=1` restores the old path for comparison. -## v0.13.2 — 2026-09-13 - -### Fixes - -- **`ludic build` links `http.ll` only into a program that uses `Http.*`.** - - The check that added it grepped the IR for `@hs_`, which every program's header declares, - so the HTTP transport and Foundation were linked into everything. That cost nothing visible - on macOS and failed the link on Linux, where `ludic build`, `ludic run` and a new project all - broke in CI. It now looks for a call to `hs_send`. -## v0.13.1 — 2026-09-13 - -### Fixes - -- **The toolchain links on Linux again** — CI builds, tests and publishes releases. - - The asset-pack boot finds the directory beside the executable with `_NSGetExecutablePath`, - which Darwin's libc has and glibc does not, so every Linux link of the compiler seed failed - from the release that added packs onward: CI's build, suite and C-free bootstrap jobs, and - every `publish` run, so no release after v0.7.0 carried a Linux toolchain or was created by - CI at all. `tools/ci/linux_stdio_shim.ll` now supplies it, over `/proc/self/exe`. -## v0.13.0 — 2026-09-13 - -### Features - -- **Windows target** — `ludicc` builds and runs headless programs on Windows, and builds itself there. - - - **`--target `** — a triple naming `windows` selects the Windows runtime; without - the flag the target is the host, read at run time from `OS=Windows_NT`. The IR still - carries no triple, so clang assembles it for the machine it runs on. - - **The libc surface, in IR** — on Windows the header emits `emit_win.ludic`, which defines - the POSIX names the backend calls (`fopen`, `ftell`, `rename`, `opendir`, `mmap`, - `fmemopen`, `uname`, …) over the UCRT and Win32. Three of them fixed silent breakage - rather than link errors: `rename` onto an existing file (every `Fs.write_text` after the - first), a 32-bit `ftell`, and text-mode `fopen` rewriting `\n` as `\r\n`. - - **Known folders** — `Os.save_dir` / `config_dir` are `%APPDATA%\`, `cache_dir` is - `%LOCALAPPDATA%\`, `temp_dir` is `%TEMP%`, all with forward slashes; a bundle's - `home` line points at `%APPDATA%\`. - - **The driver** — finds `C:\Program Files\LLVM\bin\clang.exe` when clang is not on - `%PATH%`, writes `.exe` outputs, speaks cmd.exe for its directory and cleanup commands, - and reads `argv[0]`, `%PATH%` and `$LUDIC_HOME` with either separator. - - **A window** — `win32.ll` is `cocoa.ll`'s contract over user32: the held-key set and frame - key in Ludic codes, the mouse in framebuffer pixels with raw input for cursor mode 2, - cursor hide/lock/confine that lets go when the window loses the foreground, XInput pads - in SDL order with rescaled deadzones, and the software `win_present`. `win32_gl.ll` puts - the WGL context on that window with vsync and borderless full screen; a headless build - links `gl_win_nowin.ll` instead. The process is per-monitor DPI aware. - - **Sound** — `audio_win.ll` is `audio.ll`'s contract over XAudio2: one source voice per - clip, loops, volume, rate and a balance pan through the output matrix, RIFF WAVE in PCM or - float. It reads a clip through the asset pack, so a bundled game's first `Audio.load` - succeeds - AVAudioPlayer takes a filesystem path, which is why macOS cannot. - - **The CLI on Windows** — `ludic build`, `ludic run` and `ludic-dev build` work from a Windows - checkout. Every shell command the CLI issues runs through Git for Windows' bash - (`$LUDIC_BASH` names another), `compile_app` links through `ludicc -o`, and a checkout - bootstraps from the new `selfhost/ludicc.win.seed.ll`, which `ludic-dev reseed` now - writes beside the macOS seed. - - **`ludic bundle` on Windows** — `build//` holding a GUI-subsystem `.exe`, the - `game.lpak` and `packs.index`; an `.ico` beside `app icon` is compiled with `llvm-rc` and - linked in. `ludicc` gains `--gui` (no console behind the window) and `--link `. - - **`Http.*` on Windows** — `http_win.ll` is `http.ll`'s contract over WinHTTP: a request - built on the game thread, the exchange on a worker thread, TLS with the system's - certificate checks, and response headers answered from the kept request handle. - - **The splash and the window icon** — decoded by WIC from the bytes in the pack. The splash - is a topmost borderless window at the artwork's own size in points times the display's - scale; `App.set_icon` sets the title bar and taskbar icon of a build with no icon resource. - - **`Gl.*` on Windows** — `gl_win.ll` makes a WGL 4.1 core context on a hidden window's DC, - and `gl_thunks_win.ll` (generated by `ludic-dev glgen` beside `gl_thunks.ll`) calls - every entry point through a table `@lgl_win_load` fills from `wglGetProcAddress`, falling - back to `opengl32.dll` for the 1.1 functions it will not return. A headless GL program - renders on the GPU there: `gl_triangle` matches the macOS frame to within one level per - channel. - -### Fixes - -- **Backspace fires on macOS** — the Delete key reports `Key.Backspace` (8). - - AppKit gives the key marked delete (kVK_Delete, 51) the character `NSDeleteCharacter`, 127, - which is what both the held-key set and the per-frame key received, so `Key.Backspace` never - matched. `cocoa.ll` maps key code 51 to 8 in both. -- **Held arrow keys register** — `Key.Up`, `Key.Down`, `Key.Left` and `Key.Right` are 128-131. - - They folded to the codes of w, s, a and d, which is what the single per-frame key - (`Input.key`) reports for an arrow. The held-key set has always stored an arrow under - 128-131, so `Input.key_down(Key.Up)` tested the W bit and `Input.move_i`'s arrow half never - moved anything. `Input.key` keeps its WASD alias: a game comparing it with `'w'` still - takes the arrows. -- **`ludic build` links `Http.*`** — a program that uses the HTTP client builds through the CLI. - - `compile_app` linked the OpenGL backend when a program named `@lgl_*` but never - `runtime/native/http.ll` and Foundation for `@hs_*`, so a `Http.*` program failed to link - under `ludic build` / `ludic run` while `ludicc -o` built it. It now links them the same way, - windowed and headless. -## v0.12.1 — 2026-09-13 - -### Features - -- **ludic.render3d: `r3d_fog_base`** — the height the height fog's density is measured from (default 0, the world's y = 0). Two maps sharing one height datum, one far lower than the other, no longer give the lower one many times thicker air. - -### Fixes - -- **ludic.render3d: lakes are ellipses** — a water body other than the reflecting one was drawn as the whole rectangle of its bounds, so the corners between that rectangle and the lake's carved ellipse showed sheets of water over dry ground. Those bodies are now clipped to the ellipse; the reflecting body (the sea) still fills its rectangle. -## v0.12.0 — 2026-09-13 - -### Features - -- **ludic.render3d: replace the world at run time** — a game can swap its terrain, scatter, colliders, actors and water for another map's without restarting. - - - `terrain_reload(dem, emin, emax, base, ox, oz, ortho, half)` releases the current map's height field, survey, photograph and patch bounds and generates another at any `TERRAIN_HALF`; `terrain_unload()` is the release on its own. - - `scatter_clear_all()` (with `stream_clear_all()`), `actor_clear_all()` and `col_reset()` empty the scene for the next map; `mesh_free()` releases a mesh's GL objects. - - **Water bodies** — `water_body_add(level, cx, cz, ex, ez, reflect)` and `water_bodies_clear()` draw several still-water planes at their own levels; the first that reflects gets the planar reflection. `water_init` still means one reflecting plane. - - **Fix:** the terrain's patch quadtree placed patches at a fixed 32 m leaf, so any `TERRAIN_HALF` other than 4096 put patch bounds in the wrong place; leaves now scale with the map. - - **Fix:** `water_init` built a new mesh and compiled a new program on every call. - - `terrain_sea(level)` separates the sea's level (the coast, the strand, the shoreline, forest and scree shading) from the carved lake's, so a lake can sit above the sea. Unset, the sea is the lake's level as before. -## v0.11.1 — 2026-09-12 - -### Fixes - -- **`col_resolve` no longer throws a body across the map when it starts dead centre on a - collider.** The degenerate branch — a point exactly on a circle's axis, where there is - no direction to push it — chose a unit normal `(1, 0)` and then divided it by the - clamped `d = 0.001` anyway, along with the real normals. The push came out a thousand - times too large: a body standing exactly on a 0.5 m trunk was moved about 800 m instead - of the 0.85 m that clears it. - - Off-centre the arithmetic was correct, and off-centre is how anything arrives at a - trunk while walking, so it never showed up in play — only a teleport, a spawn or a - world generator placing something on an existing collider could land on the axis. - - `(ex, ez) / d` is always a unit vector for `d > 0`, because `d` is its own length: there - was never anything to clamp. The normal is now built once, explicitly, and the clamp is - gone. -## v0.11.0 — 2026-09-12 - -### Features - -- **`App.set_icon(path)`** — an icon for a binary that has no bundle to take one from. - - `ludic bundle` builds an `AppIcon.icns` and macOS reads it from the `.app`, so a - shipped game has an icon. `ludic build` produces a bare executable, which has no - bundle, no `CFBundleIconFile` and therefore no icon at all — macOS draws the generic - green "exec" tile in the Dock. That is the build a developer runs all day and the one - they see every session, so "my game has no icon" is true long before it ships. - - ```ludic - App.set_icon("assets/app/icon.png") - ``` - - The path resolves through the pack first and the filesystem second, like every other - asset, so the same call works packed and unpacked — it does not repeat `Audio.load`'s - trick of taking a filesystem path only. An image that cannot be found or decoded - leaves the current icon alone rather than clearing it, calling it in a bundled app is - a harmless no-op, and a headless build compiles it away to nothing. -## v0.10.1 — 2026-09-11 - -### Fixes - -- **`Os.save_dir` / `Os.config_dir` / `Os.cache_dir` follow the platform.** They were the - macOS layout everywhere, so a game built on Linux wrote its saves to - `~/Library/Application Support` — a directory that means nothing there. Naming the - right directory is the entire reason a program calls these instead of building a path. - - | | macOS | Linux | - | --- | --- | --- | - | `save_dir` | `~/Library/Application Support/` | `$XDG_DATA_HOME` or `~/.local/share/` | - | `config_dir` | `~/Library/Application Support/` | `$XDG_CONFIG_HOME` or `~/.config/` | - | `cache_dir` | `~/Library/Caches/` | `$XDG_CACHE_HOME` or `~/.cache/` | - - An XDG variable that is set but empty falls back to the default, as the spec requires. - macOS is unchanged to the byte — `save_dir` and `config_dir` stay the same directory - there, because Apple's home for a config file that is not an `NSUserDefaults` plist is - Application Support too, and a shipped game's settings must not move out from under it. - On Linux XDG separates the two and so does this. - - One consequence worth stating plainly: a game already shipped on Linux was writing to - the old macOS-shaped path, and this moves it. Those files are not migrated for you — - they are wherever `~/Library/Application Support/` ended up on that machine, and a - game that has Linux players should move them once on startup. -## v0.10.0 — 2026-09-11 - -### Features - -- **`.packignore`** — keep build artefacts out of the shipped asset pack. - - A pack root is packed wholesale, so everything a model or texture pipeline leaves - beside its output ships too: preview renders, bake intermediates, the `.blend` a - `.gltf` came from. Nothing errors and nothing looks wrong — the app is just bigger - than the game. The only way out was naming every file in `package.ludic`, a list - that goes stale the day someone adds a texture. - - Put a `.packignore` beside the assets instead. The rules are **gitignore's**, down - to the parts people rely on without thinking about them: patterns anchored by a - slash or floating without one, `preview/` for directories only, `*` and `?` stopping - at a separator where `**` crosses it, `[a-z]` classes, `!` re-includes with the last - line winning, a deeper file beating a shallower one, and no re-including out of an - ignored directory. - - `ludic pack` reports what it left out, and `--no-ignore` packs everything so you can - see what a rule costs. `ludic bundle` gathers the same way. `.packignore` itself is - never packed, and the file only governs your own roots — a package's resources, such - as the renderer's shaders, are added afterwards and cannot be excluded by accident. -## v0.9.1 — 2026-09-11 - -### Fixes - -- **`outline_model`'s batch survives the whole frame.** A frame is drawn by more than one - pass — a shadow map, a water reflection, the scene — and `actor_draw` runs in each of - them. An Actor's rim survives that because it is a field on the actor; the queue did not, - because the first pass to run emptied it, so a queued outline was drawn into whichever - target happened to come first and was gone by the time the scene was drawn. Nothing - appeared, with every uniform, matrix and mesh correct. - - A flush now *closes* the batch rather than clearing it, and the next `outline_model` - opens a new one: every pass in a frame sees the same requests, and a caller still needs - no frame hook. -## v0.9.0 — 2026-09-11 - -### Features - -- **`Audio.play_at(id, gain:, pitch:, pan:)`** — fire a one-shot with its own gain, pitch - and stereo position, leaving the master settings alone. - - `Audio.volume` and `Audio.pitch` are global: they are there so a player can turn the game - down, and a game that used them to place a sound in the world would be fighting its own - options screen. This is the per-voice version, and it is what distance attenuation is made - of — a 3D game works out how far away a sound is and which side it is on, and says so. - `gain` rides on top of the master volume, so the options screen still wins; `pan` runs - -1 (hard left) to 1 (hard right). - - A loaded sound is still one player, so firing the same handle again restarts it rather - than layering a second copy. Load a handle per variant when several need to overlap. -- **`ludic.render3d`: `outline_model(model, mat, width, r, g, b)`** — a rim around something - that is not an Actor. - - An Actor has had an `outline` since the outline pass landed, and everything else in a - scene had nothing: the pass walked the actor list and stopped. Instanced scatter was the - gap that mattered, because a game that highlights whatever the crosshair is on could - highlight every object in the world *except* the twenty thousand most common ones — the - trees. - - `outline_model` queues a model at a transform from anywhere in the frame and the outline - pass flushes it alongside the actors', which is what gets the depth test right: the rim has - to be drawn after the scene it is tested against, and a caller does not control pass order. - A layer whose vertex shader moves its instances — wind, or standing them on the drawn - terrain — should be handed the transform that shader arrives at. -- **`ludic.render3d`: `terrain_coast(cx, cz, margin, fall)`** — the other way to make an - island, and the one a real survey wants: the sea goes around the survey's **own edge** - rather than being cut out of the middle of it. - - `terrain_island` measures a radius out from a centre, which suits a made-up map and - drowns most of a real one — an 8 km mountain survey loses two thirds of itself to make an - island of the rest. `terrain_coast` measures inward from the boundary instead: everything - the data covers stays land, the outer `margin` metres go under water, and the `fall` metres - inside that are scaled down into it. The band is wobbled by low-frequency noise, so what - comes out is headlands and bays rather than the square the data arrived in. - - Both modes also gained a **strand**. Scaling alone hands a 500 m mountainside a 40-degree - plunge into the sea, which is a cliff coast and nothing else; the last few metres of height - either side of the water line are now compressed, which stretches them out horizontally - into beach and shallows. Inland lakes are untouched — they have their own bed. -## v0.8.0 — 2026-09-10 - -### Features - -- **A boot splash**, customised by the game. `app splash` in `package.ludic` names - the artwork and `app splash_bg` the colour behind it; the runtime raises a - borderless window from a constructor that runs before `main`, reading the image - out of the asset pack, so it is on screen while the process is still starting - rather than after the slow part it exists to cover. Nothing hides it - automatically - only the game knows when its first real frame is ready, so the - game calls `App.splash_hide()`. -- **Asset packs.** `ludic pack` writes every asset a game opens into one `.lpak` - beside the binary, and the runtime mounts it before `main`. Nothing about how a - game is written changes: the pack is spliced in at `file_open`, the one place - every asset comes through, so `gltf_load`, `tex_load`, `Audio.load`, - `Fs.read_text` and the renderer's own shader loads all find it without knowing - it exists. `Fs.exists` and `Fs.size` consult the packs too. Entries are stored - rather than compressed and the pack is `mmap`'d, so 165 MB of terrain costs one - syscall at startup and pages in only what is touched. Several packs can be - mounted in order, and a later one shadows an earlier one - which is how a patch - replaces individual files without rewriting the base pack. See `docs/SHIPPING.md`. -- **`ludic bundle`** turns a built game into a real macOS `.app`: `Info.plist` and - `PkgInfo` from the manifest, an `.icns` built from one source PNG at every size - macOS asks for, the asset pack in `Contents/Resources`, and an ad-hoc signature - so it launches on Apple silicon. Everything comes from `app` lines in - `package.ludic`, so the command takes no arguments. A bundled game is also moved - to `~/Library/Application Support/` at startup, because Finder starts a - `.app` with its working directory at `/` where no save could ever be written. -- **`ludic.render3d`: `terrain_island(cx, cz, r, fall)`** keeps land out to `r` and then - scales the terrain down into the water over `fall` metres. Scaling rather than blending - to a fixed bed is what makes the coastline come out of the terrain already there: low - ground becomes beach and shallows, high ground becomes cliff. `r = 0` leaves the survey - untouched. -- **`ludic.render3d`: a rim outline on an actor.** An actor gains `outline` (metres of - rim) and `ocol` (its colour). The pass draws the model again with its vertices pushed - along their normals and its front faces culled, so only the far side of the swollen - shell survives - a silhouette exactly `outline` wide, depth-tested against the scene, so - anything standing in front of the actor hides its rim too. -- **`ludic.render3d`: the moon has a phase.** One number, 0 new .. 0.5 full .. 1 new - again, decides the disc's terminator, how much of its light reaches the ground, and - where in the sky it rides - a full moon rises as the sun sets, a new moon travels with - the sun and is never seen. A new moon is now a properly dark night, which is what makes - a carried light worth having. - -### Fixes - -- **`ludic.render3d`: water read the window's size, not the frame it drew into.** The - water shader's `u_screen` and the reflection target were sized from the drawable, but - `gl_FragCoord` there runs over the scene target. They match only at a render scale of 1; - below that the refraction and depth reads landed in the wrong corner of the frame and the - lake showed a squashed copy of it instead of its own bed. -## v0.7.0 — 2026-09-10 - -### Features - -- **A game can use the renderer from outside this repository.** `ludic.render3d` reads two - things from disk at run time — its GLSL, and the scanned CC0 materials — and both were - found only by a path relative to the working directory, so the renderer worked in a - Ludic checkout and nowhere else. A game living in its own repository now needs to copy - neither. - - **The shaders come from the package**, wherever the package is. They belong to - `ludic.render3d` and ship with it, so the renderer looks for them beside the project - first (a Ludic checkout, where they are under `packages/`) and then under the install - root, `$LUDIC_HOME/packages/ludic.render3d` — the same place the compiler already - resolves `import "ludic.render3d/r3d.ludic"` from. Nothing to vendor, and no version of - the shaders that can drift from the version of the code that compiles them. - - **`ludic assets [--force]`** fetches the scanned materials and the HDRI sky into - `assets/polyhaven/` of whatever project you run it in. The list of what to fetch is the - renderer's own — the renderer decides which materials it wants — so it moved out of the - repository's `assets/` and into the package as - `packages/ludic.render3d/assets.manifest`, where it ships with the toolchain. A game - does not keep its own copy of that list and so cannot fall out of step with the - renderer's material set. `ludic dev fetch-assets` is the same command from a checkout. - - **A URL-shaped module built its binary into directories.** `project_name` took - everything after the last dot of the manifest's module path, which for a package - identified the way the package manager identifies them — - `git.host.io/user/name` — is inside the *host*: the build wrote - `build/io/user/name` instead of `build/name`. The last path segment comes first now, and - a dot inside that segment still separates namespace from package, so `ludic.render3d` - still builds as `render3d`. - - **The camping game has moved out** to its own repository — - [Maroon Lake](https://git.workshopsoft.io/workshopsoft/maroon-lake) — taking - `examples/rendering/valley.ludic`, `hiker.ludic`, `camp/`, and 175 MB of survey data and - scanned kit with it. It was here as a demo of the renderer and became a game, and an - engine repository should not be carrying a game's assets. It is now the first consumer - of everything above, which is the point: what the renderer needs a game to be able to do, - it can now do from outside. `examples/rendering/smooth.ludic` stays as the renderer's - example in this tree. -- **OpenGL for Ludic, and a 3D renderer on it.** `Gl.*` binds the whole OpenGL 4.1 - core API — every `gl*` entry point of the platform `gl3.h` as `Gl.(…)` - with every `GL_*` constant, generated by `ludic-dev glgen` with per-call ABI thunks - (`runtime/native/gl_thunks.ll`; float/double parameters take `fixed`). Windowed - builds get an `NSOpenGLContext` on the existing window at Retina resolution - (`cocoa.ll`); headless builds render into an offscreen CGL context, so a program - that uses `Gl.*` renders and screenshots identically under the test harness. - `Gl.open / swap / screenshot / program / vao / floats …` cover the glue, and the - IEEE-float helpers (`f_add`, `mem_put_f32`, …) let Q16.16 programs fill real - float vertex and uniform data. `Gl.*` links `gl.ll + gl_thunks.ll + OpenGL.framework` - only when used; every other build is byte-identical. - - The `ludic.render3d` package (`packages/ludic.render3d`) is a physically based - renderer written on `Gl.*`: HDRI sky with image-based lighting (irradiance, GGX - prefiltered, split-sum BRDF, sun extracted from the map), GPU-generated terrain - with scanned PBR materials (stochastic anti-tiling, triplanar rock, slope/altitude - splatting), cascaded shadow maps with PCF and world-unit biasing, a glTF loader - for scanned models, instanced vegetation with baked impostors, procedural grass - and lupines with wind and translucency, 4x MSAA with alpha-to-coverage, SSAO, - still water, cloud shadows, aerial perspective, an HDR pipeline with bloom, - auto-exposure, ACES tonemapping, grading, sharpening and grain. See - `examples/rendering/smooth.ludic`, and the Maroon Lake game (git.workshopsoft.io/workshopsoft/maroon-lake) for a - game built on it. The renderer's CC0 materials are fetched with `ludic assets`. - - **16-bit PNGs**: the renderer's texture loader keeps 16-bit samples (normal / - displacement maps) and uploads them as `RGB16` / `R16`. - -### Fixes - -- **Resizing the window (or entering fullscreen) no longer empties the world.** It left - `gl error 1286` — `GL_INVALID_FRAMEBUFFER_OPERATION` — on every frame from there on, with - the terrain, the trees, the grass and the water gone and only the sky drawn. - - The sun-visibility pass added in `changes/terrain-perf.md` borrows the depth buffer the - frame is about to be drawn with, so that rasterising it doubles as a depth prepass. It - was storing that borrowed texture in its `Target`, and a `Target` deletes whatever its - `depth` names when it is freed. On the first resize the sequence was: `post_free` deletes - the frame's depth texture, `post_init` immediately makes the replacement — and GL hands - back the name that was just freed — and then the visibility target, rebuilt for the new - size, deleted that name believing it was its own. The scene framebuffer lost its depth - attachment. What is left is a colour-only framebuffer, which is *complete*, so drawing - carried on with no depth test at all: the sky is a fullscreen quad drawn last, and with - nothing left to fail against it painted over the entire valley. The 1286s came from the - passes whose own attachment now named a texture that no longer existed. - - A borrowed attachment is never written into the target now, and the frame's depth is - attached afresh at the start of each pass — it is a different texture every time the - screen-sized buffers are rebuilt, and one `glFramebufferTexture2D` per pass is cheaper - than any scheme for noticing that it changed. - - `R3D_RESIZE_AT=` rebuilds every screen-sized buffer from that frame on, cycling - through four drawable sizes every few frames. A window cannot be resized in a headless - run, so this is the only way to reach the path; it reproduced the fault in one frame and - now runs twenty resizes, with the fly camera and with the game, without an error. -- An upgrade keeps the package store. The installer replaces the whole install - root, and `ludic add` caches packages in `~/.ludic/store` — so re-running the - one-liner deleted every package a project had fetched. The store is carried - across now; everything else in the root belongs to the toolchain and is replaced. - -### Performance - -- **Ground cover stops re-growing itself.** Walking a streamed world hitched, and the - hitch got worse the longer you played. Measured in the Maroon Lake game, with a new hitch - report rather than guessed at. - - **The chunk cache had a cliff, not a slope.** A stream cached 4096 chunks and then - stopped remembering: past that the chunk was generated, used for one frame and thrown - away, so every ring walk regenerated it, for the rest of the session. It arrives after - enough of the map has been walked — six evictions' worth over seven kilometres, so an - ordinary session reaches it — and it is the point where cover starts visibly re-growing - as you turn. `stream_evict` now drops the half of the cache nobody has asked for in the - longest time (chunks carry the walk that last wanted them) and rebuilds the index over - what is left. Over a 7 km traversal: generation total **9073 ms → 2230 ms**, the worst - single frame's generation **11.4 ms → 3.1 ms**, median frame 10.8 → 9.0 ms. With a cache - deliberately sized to saturate early, the same run goes from 2748 frames generating to - 1548, and from a 13.3 ms median to 9.2. `R3D_NOEVICT` restores the old behaviour for - comparison, `R3D_STREAM_CAP=` sets the cache size. - - The other half of that hitch was in the game's own cover generator, and went with it to - the Maroon Lake game's repository: its candidates were paying for a second noise field, four - height samples and a path distance before the drift field that rules out most of the - meadow — 2341 µs → 518 µs per chunk, bit-identical output. Worth repeating in any - generator: a `stream_fill` is called for tens of thousands of candidates per chunk, so - the order of its tests is most of its cost. - - **The hitch report** (`R3D_PROF=1`) is what found both. It prints the slowest frames of - the run with what was in each: CPU versus GPU wait, cover generated, instance bytes - uploaded, the game's own tick, and the renderer phase that took longest. Alongside it, - per-chunk generation cost by stream and band, a census of what the caches hold, and a - stutter figure — the frame time a run spent beyond 1.2x its own median — because a mean - cannot show a hitch and a maximum is one unlucky frame. - - It also found two content bugs in the game it was measured on, which is the report doing - its job: a cover stream whose placement rule never fires still pays full generation cost, - and the census makes that visible — 3364 cached chunks holding zero instances. -- **The ground costs half what it did.** Measured in the Maroon Lake game, the terrain was 10.3 ms of - a 22.2 ms frame; it is now 5.4 ms of 16.4 ms — 45 fps to 61 fps at 1080p, with the - frame otherwise unchanged (every viewpoint tested stays above 54 dB PSNR against the - old renderer, with no channel differing by more than 7/255). - - **Measure by frame time, not by the pass timers.** `R3D_PROF`'s per-pass - `GL_TIME_ELAPSED` queries cannot be trusted on this driver: with the ground's shading - work removed the terrain query fell from 10.5 ms to 1.3 ms while the frame time did - not move at all. Every number above and below is a median real frame time, taken by - switching one thing off (`prof_ft_report`); the pass timers are still printed, and are - still useful for spotting a pass that appears out of nowhere, but they cannot size one. - `R3D_NOTERRAIN` skips the ground, `R3D_TNEARONLY` / `R3D_TFARONLY` draw every patch - with one tier's program, and `R3D_RES=x` renders at another size — the three - switches that say whether a cost is the ground, which tier it is in, and whether it is - pixels at all. - - **Each detail tier is its own program.** `terrain.frag` holds a detailed near tier and - a cheap far one and chose between them per pixel, so every pixel of the valley walls - was compiled — and scheduled — for a near path it never ran. CDLOD selection now knows - which tiers a patch can contain: one that never comes within the split draws with - `FAR_ONLY`, one wholly inside it with `NEAR_ONLY`, and only the ring of patches that - straddle the band needs the program that holds both and cross-fades. Pixel-identical, - and it makes the tiers separately measurable: the near tier costs 7.5 ms over a whole - frame, the far tier 2.3 ms. - - **The sun visibility is its own pass** (`tersun.frag`). The same CDLOD patches are - rasterised once into a screen-sized R8 buffer that holds nothing but each ground - pixel's sun visibility, and `terrain.frag` fetches it by fragment coordinate. The pass - costs 0.27 ms, shares the frame's depth buffer so it doubles as a depth prepass, and - takes the cascade read out of the shader that covers the screen. It picks its tier — - filtered PCF near, a single tap far — over the same cross-faded band the ground uses, - so the boundary is not a contour you can find on the hillside. - - **Nothing is sampled for a weight of zero.** The ground sampled all four of its - materials for every pixel and then blended three of them at zero: a meadow pixel took - nine taps of triplanar rock, a cliff pixel nine taps of stochastic grass, and every - pixel in the valley took the snow tile and the four noise fields behind the lake's - shore wash — a wash that is a hairline along one shore within 120 m of the camera. The - survey photograph's classification now runs first, because it is what decides which - materials are present; each material block sits behind its own weight; the ridge field - that ragged the snow line is skipped 160 m below it, where it cannot change anything; - and inside the stochastic blend a cell's rotation, offset and rotated gradients are - computed inside its own test, so a cell whose sharpened weight rounds away costs - nothing. All of it exact where the weight is zero, and it is most of the win. - - Material sampling is what remains (2.8 ms of the 5.4): scanned 2K tiles taken at 16x - anisotropy on ground seen at a grazing angle. `R3D_ANISO=` sets the filter (the - default is unchanged at 16; 4 is worth 1.0 ms and 1 is worth 1.7 ms). - - The shadow pass is 1.2 ms of the frame and has nothing to give: re-using the far - cascades between frames — their windows are snapped to a 14 m and a 64 m grid — is - worth 0.15 ms standing still and nothing while walking, so it is not in the tree. -## v0.6.1 — 2026-09-05 - -### Fixes - -- **`ludic new` scaffolded a project that would not compile.** The project name went - straight into the `program ` identifier, so `ludic new my-game` wrote - `program My-Game` — a subtraction — and the first `ludic run` failed with - `expected '{', got '-'`. A name is now turned into a valid identifier - (`my-game` → `MyGame`, `2048` → `Game2048`), and a name that cannot be a - directory or a package is refused with the rule rather than mangled. - - Found while auditing every command's flags and arguments, along with: - - - **Unknown options are errors.** `ludic build --headles` silently built a - windowed binary; `ludic build -o` with no path silently ignored it. Both now - say what is wrong and exit non-zero. - - **`ludic fmt` formats in place**, as its help always claimed — it was printing - the file to stdout and changing nothing. `ludic fmt --check` reports drift - without writing, for a hook or CI. - - **`ludic test nosuch.ludic`** says the file does not exist instead of passing it - to the compiler. - - **`ludic build-lib`** reported failures as a shell syntax error (an - interpolation written in a non-interpolating string), and its no-argument - auto-detection picked up `package.lock.ludic` as a module to compile. - - Error messages that still began with `x:` — the CLI's old name — now say - `ludic:`. -## v0.6.0 — 2026-09-05 - -### Refactoring - -- **`ludic` is only the language's command line now.** The toolchain's own tasks — - building the compiler from its IR seed, the regression suites, the docs site, - releases — moved out of it into a separate `ludic-dev` binary that is built from - a checkout and is not part of an install. - - - **`ludic help` is what a user can actually do**: `new`, `run`, `build`, `test`, - `add`, `fmt`, `lsp`, `doctor`, `upgrade`. No section about a repository they do - not have. Typing `ludic dev …` says where those tasks went rather than failing - as an unknown command. - - **`ludic dev ` becomes `ludic-dev `** for contributors; every task - is otherwise unchanged. The bootstrap is now - `bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev && bin/ludic-dev build`. - - The shipped binary drops from ~880 KB to ~190 KB, since none of the release, - docs-generation or bootstrap machinery is linked into it any more. - -### CI - -- The docs deploy triggers on a change to `install.sh`. The site publishes the - installer, but the workflow's path filter did not mention it — so a release that - only fixed `install.sh` left the old script live at the URL the landing page - tells people to pipe into `sh`. -## v0.5.2 — 2026-09-05 - -### Fixes - -- The installer also covers a non-login interactive `bash` — the shell most Linux - terminal emulators start, which reads only `~/.bashrc`. That file is now created - when it is missing (its presence changes nothing else about how bash starts), - while `~/.bash_profile` is still only appended to when it already exists, since - creating that one would stop bash reading `~/.profile`. -## v0.5.1 — 2026-09-05 - -### Fixes - -- `ludic version` reports the version of the toolchain it belongs to. It looked for - `bin/ludicc` and `VERSION` beside the *current* directory, so it answered - "(version unknown)" from a project — which is the only place a user ever runs it. - It now resolves the compiler through the install root, like every other command. - - `install.sh` also now puts `ludic` on `PATH` for every shell, not just the one - `$SHELL` names. The PATH edit lives in one file (`~/.ludic/env`) that each - profile sources, and the profiles are chosen to cover what people actually open: - `~/.profile` for sh and login bash, `~/.zshenv` because zsh never reads - `~/.profile`, `~/.bashrc`/`~/.bash_profile` when they already exist, and fish's - config when fish is installed. Re-running the installer does not add a second - copy, and `--no-modify-path` still touches nothing. -## v0.5.0 — 2026-09-05 - -### Features - -- **One command installs Ludic, and `ludic` is the command you use.** Getting - started no longer means cloning the repository and learning a task runner called - `x`. - - - **`curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh`** installs a complete - toolchain — compiler, CLI, engine runtime, bundled `ludic.*` packages, - formatter and language server — into `~/.ludic` and puts it on your `PATH`. - Prebuilt artifacts are verified against a published checksum; where none - exists for the platform, the installer bootstraps from the compiler's own IR - seed with clang. Uninstalling is `rm -rf ~/.ludic`, and `ludic upgrade` - re-runs the same script. - - **`ludic` replaces `x`** and is the only command a user of the language meets: - `ludic new` scaffolds a project that builds and plays as it stands, `ludic - run` / `ludic build` compile it (`--headless` for a deterministic render), - `ludic test` runs every `test` block in the project, and `ludic add` / `get` / - `update` / `verify` / `vendor` drive packages. `ludic fmt` and `ludic lsp` are - the formatter and language server, so an editor needs no path configuration. - `ludic doctor` reports whether the install is complete and usable. - - **The toolchain's own tasks moved under `ludic dev`** — `dev build`, `dev - test`, `dev reseed`, `dev bootstrap-cfree`, `dev docs-gen`, `dev release` and - the rest are unchanged apart from the namespace. `bin/x` is gone; the - bootstrap is now `clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc - tools/ludic-cli/main.ludic -o bin/ludic`. - - **An install root is a first-class layout.** The compiler derives it from its - own location — the parent of its `bin/` directory — so `~/.ludic` and a repo - checkout are the same shape, and `$LUDIC_HOME` is no longer needed to build a - windowed game outside the repo. The bundled `ludic.*` packages resolve from - `$LUDIC_HOME/packages`, so `import "ludic.core/components.ludic"` works with no - `ludic_modules/` to set up. Release artifacts are complete install roots - (`bin/` beside `runtime/`, `packages/` and `VERSION`) rather than bare - binaries, and the installer ships with the documentation site it is served - from. - -### Fixes - -- **Token cards are back on the docs site.** Clicking a keyword, type, builtin or - namespace method in any code sample opens its summary card again. The card's - styling had been left behind in `docs.css` during the site redesign, so on the - landing page — which links only `base.css` and `site.css` — the card rendered - unstyled at the foot of the document instead of beside the token. It now lives - in `base.css` with the rest of the highlighter chrome, and is positioned - `fixed`, matching the viewport coordinates the script computes, so a card opened - on a scrolled reference page lands on its token rather than off it. -- Release checksums are one `.sha256` file per artifact instead of a single - `SHA256SUMS`. A release is assembled from more than one host — a Linux runner - cannot build the macOS toolchain — and `ludic dev publish` never overwrites an asset that - is already attached, so a shared `SHA256SUMS` was written by whichever host - published first and then never covered anything added afterwards. Per-artifact - names compose across hosts. Verify one with - `shasum -a 256 -c ludic-X.Y.Z-src.tar.gz.sha256`. - -### Documentation - -- CONTRIBUTING documents what a self-hosted Forgejo runner needs for CI to work at - all: every workflow clones `${{ github.server_url }}`, which on a self-hosted - instance is an internal address, so job containers must be able to resolve it. - The runner's default is a fresh per-job network the Forgejo container is not on, - which fails the clone — intermittently, because Docker forwards unresolved names - to the host resolver, so CI can look healthy for a while before it stops. -- The README is rewritten around what a reader needs first: what the language is, - a code sample, how to build it, and an honest status. Removed the repo-layout - table and the Chrono Rift keybindings (a game manual in a language README), the - nine links to wiki pages that no longer exist, and a "language at a glance" - bullet describing a retired vocabulary — it advertised `system`, `reads`, - `writes`, `requires` and `ensures`, none of which are keywords; the declaration - keyword is `handler`. - -### CI - -- The commit-lint workflow survives a force-push. It linted - `${{ github.event.before }}..${{ github.sha }}` without checking that `before` - still resolves, so rewriting or garbage-collecting that commit failed the job - with `fatal: Invalid revision range` on a push whose messages were all valid. It - now falls back to linting the tip commit when `before` is gone. -- The docs deploy is serialised. Publishing is a force-push of an orphan `pages` - branch, so two runs racing could land out of order and leave the site holding the - older build — with both runs reporting success. A `pages-deploy` concurrency - group with `cancel-in-progress` means a newer push cancels an older in-flight - build instead of queueing behind it. -## v0.4.0 — 2026-09-05 - -### Features - -- **Prefabs.** `prefab Grunt: Creature { Stats { hp: 30 }, Weapon { def_id: 1 } }` names a model with preset fields; `spawn Grunt { Position { x: 40 } }` spawns it, the spawn's own fields winning over the presets. Prefabs chain (`prefab Grunt: Foe` where `Foe` is a prefab) so shared presets live once. `spawn` is now also an expression yielding the new entity (`let e = spawn Grunt { … }`), and `Prefab.spawn(name: "Grunt")` spawns one chosen at runtime by name (-1 when none matches). -- **`countdown` fields.** A component field declared `frames_left: countdown = 0` is an `int` the engine steps toward 0 once per Update for every live entity carrying the component (never below 0). Roll timers, invulnerability frames, hit flashes and cooldowns need no hand-written "decrement each frame" handler: set the field, test it. -- A `machine` over an enum-typed store maps its states to the enum by name: with `enum HeroState { Idle, Rolling }` and `var hero_state: HeroState = HeroState.Idle`, `machine hero_state { state Idle { … } state Rolling { … } }` dispatches on `HeroState.Idle` / `HeroState.Rolling` — no `state Idle = HeroState.Idle` repetition, and a state that names no variant is a compile error. A bare enum is now a first-class `int`-sized type for `var`, params, fields and returns (`llty`), and `Enum.Variant` folds in a global initializer. -- A handler inside a scene `layer` may carry `@Queries(these: […], on: Model)`, so a scene can own its per-entity systems (`@Queries(these: [Particle]) handler AgeSparks phase Update { … }` runs once per matching entity only while that scene is active). Any other annotation on a layer handler is reported. -- A program-scope `var` may be initialized with any expression: `var run: Progress = new Progress`, `var speed: int = BASE_SPEED * 2`, `var origin: IVec2 = IVec2.zero()`. Initializers the compiler cannot fold run once at startup (`@L_init_globals`, after the runtime boots and before the `Start` phase), in declaration order. Previously such an initializer was silently replaced by `0` / `null`. -- Engine managers for what every action game hand-rolls: **`Fx.sparks` / `Fx.number` / `Fx.clear`** — engine-owned sparks and floating damage numbers, moved and aged each Update and drawn after the sprites, with no component, model, handler or draw call in the game; **`Audio.define(name:, path:)` + `Audio.play(name:)` / `Audio.play_music(name:)` / `Audio.named`** — a sound bank by name (the handle form still works); **`Camera.shake_for(amount:, frames:)`** — a timed shake the engine decays; **`Assets.enqueue`** now loads `.wav` / `.mp3` into the sound bank and `.ttf` / `.ttc` into a font table (**`Assets.font(name:)`**) alongside images, so one loading scene covers everything; **`Prop.count()`** — how many live entities carry a component. -- Every compiler diagnostic is reported as `file:line: error: message` — the file the line really lives in, even through imports — so editors can jump to it. Unexpected characters are errors (they used to be skipped silently), and defining one `function` twice is reported in source terms instead of failing in the IR assembler. -- Less to write for a game. **`Map` cell API** — `Map.get/set/fill/rect/border/random_cell/is_solid/is_solid_at/width/height`: a game edits the engine's tilemap in place and asks it what is solid (from the `Solids` config) instead of keeping its own grid. **`Sprite` does the small animation work**: `move_id` is the strip drawn while the entity moves, `face: 1` turns it toward its movement, and the `flash` / `blink` countdowns give a white hit flash and an invulnerability blink with no handler. **`scene X shows Menu`** — the engine opens the menu (and frees the cursor) on enter, draws it last in Overlay, and closes it on exit. **`import "dir/*.ludic"`** imports a directory in name order. **`IVec2.distance2/within/heading/along/step`** and **`Angle.diff_degrees`** cover the geometry every action game rewrites; **`List.sample`** draws distinct random picks; **`Input.move_i`** is the standard top-down movement intent; **`AimMode.Auto`** aims with the mouse, or the right stick while a pad is connected; **`Weapon.set_rate` / `Weapon.rate`** change a fire rate in place; projectiles now die on `Solids` tiles by themselves; **`Screen.bar`** draws a meter. - Handlers inside a scene's layers are scene-qualified (`Play_Draw`), so two scenes may both name a handler `Draw`; `enable` / `disable` of a scene's own handler by its bare name still works from inside that scene. -- The engine's numeric parameters have names. ludic.core: `BodyPolicy { Platformer, TopDown }`, `BoundsPolicy { Clamp, Wrap, Bounce, Kill }`, `AnimMode { Loop, Once, PingPong }`; ludic.shooter: `AimMode { Mouse, RightStick, MoveDirection, NearestEnemy }`, `WeaponPattern { Single, Cone, Ring, Spiral }`; ludic.npcai: `BrainModel { StateMachine, Utility, BehaviourTree }`, `AiState { Patrol, Chase, Attack, Flee }`; ludic.gameplay: `StatKind { MaxHp, Attack, Defense, Speed }`, `ModifyOp { Flat, Percent }`; ludic.rpg: `StatusKind { Poison, Regen }`; the input runtime: `CursorMode { Normal, Hidden, Locked, Confined }`. `Body { policy: BodyPolicy.TopDown }` reads as what it is; the old integers still work. The input runtime also names the gamepad buttons: `PadButton { A, B, X, Y, LeftShoulder, RightShoulder, Back, Start }` for `Input.bind_pad(button:)`. -- The second "write less" round, all generic. **ludic.gameplay**: `Stats` carries the build stats every action game bolts on — `damage_pct`, `crit_pct`, `leech_pct` / `leech_hp`, `thorns`, `fire_rate_pct` — and `Combat.damage` applies them itself (a `Crit` event fires; thorns never reflect thorns); `Stats.add(e, stat, amount)` changes a base stat in place and `Stats.scale_hp(e, percent)` scales hp and max_hp; `StatKind` names every code. **ludic.shooter**: `Dash { frames, speed, cooldown_frames }` with `Dash.start(e, dx, dy)` / `Dash.active(e)` — a dodge roll with i-frames the package guards; `Melee { range, half_arc, damage, knockback, frames, cooldown_frames, arc }` with `Melee.swing(e)` (hits every hostile in the arc, knocks back, fires `MeleeHit`) / `Melee.ready` / `Melee.active`; projectiles drawn by the engine in their weapon's colour (`Weapon.set_color`); `TopDown { reticle, reticle_length }` draws the aim line and a mouse cross; the weapon system honours `Stats.fire_rate_pct`. **ludic.dungeon** (new package): `Dungeon.arena / open_arena / random_style / set_exit / entry_point / opposite / at_edge`, with `Side` and `RoomStyle` — arena rooms with mirrored cover, door lanes and exits by side, built into the engine tilemap. **Compiler**: `scene Splash lasts N then Next` (a timed scene), `button … goto: Scene` (a click changes scene, no listener to write). **Runtime**: `Map.random_cell_far`, `Sprite.draw_meter` (hearts / pips), `Assets.enqueue_dir`, `Collider.center`, `Prefs.max`. Also `scene X loads then Y` (the loading scene: pumped, drawn, `AssetsReady` fired), `Prop.despawn_all()`, `Map.to_tile`, and `Brain { hunt_blind }` in ludic.npcai (seek the nearest hostile without line of sight). `Prefab.spawn_at(name:, at:)` spawns and places; `Weapon.reset(id)` restores a definition (no pierce, no homing, its fire rate). Five regression examples cover the additions (`examples/library/prefabs`, `component_access`, `scene_menus`, `managers`, `combat_kit`). `Random.weighted(weights:)` draws an index by weight; `ui` widgets inherit `font` / `size` / `fg` / `align` from their panel; the input runtime names `MouseButton { Left, Right, Middle }`. -- `@ClearColor(expr)` takes any constant expression, so a named palette colour (`@ClearColor(COLOR_FLOOR)`) works as well as a hex literal. -- `Ai.seek` + path-aware `brain_seek` — when a Solids tilemap is present the NPC-AI routes a blocked straight line around obstacles with `Grid.a_star`, so foes flow around pillars instead of getting stuck. -- `Key.*` compile-time key constants (`Key.Space`, `Key.Escape`, `Key.A`, `Key.Up`, ...), folded like `Color.*`; and `Font.*` / `Ui.* / File.*` namespaces so `png_load`/`font_load`/file I/O are namespaced. -- `Os.pid()` returns the process id, for scratch files that concurrent runs of one tool must not share. -- `Overlay` render phase — runs after the engine Render systems (sprites, lights) and before present, so a game's HUD / menus are never painted under an actor. Byte-identical when unused. -- `Prop.of(entity)` and `Prop.has(entity)` — typed access to one entity's component from an entity handle, the same binding a query loop makes. `Hero.of(player).iframes = 20` reads and writes fields directly (no `World.prop_id` / `World.field_id` / `World.get` reflection chain); `Prop.has(e)` is true when `e` is in range, alive, and carries the property, so `-1` is a safe "no entity". A package that declares a real `prop_of` / `prop_has` function keeps it. -- `Solids.solid2` — an optional second solid glyph (e.g. a closed door) the move system also blocks. -- `Sprite.strip(sheet, col, row, count, rows)` — register N consecutive animation frames in one call (the base id for `SpriteAnim`). -- `Sprite` component draws atlas ids (#90) — `Sprite.atlas = 1` routes `esys_sprite` through `atlas_draw_ex` (scale/flip/tint) so atlas cells / multi-cell spans (tall characters) use the engine sprite-render system, not just the 16x16 table. -- `Ui.close()` — deactivate the retained UI (no menu open); the readable form of `Ui.open(id: -1)`. -- `[a, b, c]` list literals build a slice in place; the first element fixes the element type, later elements must match, and `[]` is an error (use `new []T`). Tables of records read as `[Row { … }, Row { … }]`. -- `ludic.prefs` package — `Prefs.*`, a human-readable `key=value` text store for scores / options (the right tool for "remember my best run"; a whole-world `Save.write` is not). -- `v.x` and `v.y` read the components of an `IVec2` value (a local, a global, a record field, or a call result) — the readable form of `IVec2.x(v)` / `IVec2.y(v)`. The compiler's static typing now also follows function return types, namespace calls, `Prop.of(e)` and record fields, so `@Computed` fields expand in those positions too. -- engine tilemap-render system (`TileSkin`, shipped from ludic.core) — one entity per glyph paints the whole `Map.*` grid each Render frame *before* sprites, so a game stops hand-looping the map. `esys_tileskin` registered ahead of `esys_sprite`. -- engine-driven retained UI — a program with a `ui` block has its navigation ticked by the frame loop automatically (from the frame key) and an activation now emits a `UiClicked { id }` event, so scenes react with `@On(UiClicked)` instead of polling `Ui.clicked`. New `Ui.*` namespace (`Ui.open/tick/clicked/set_text/render/build`). - -### Fixes - -- A `UI_Name` handle can be read from any code — a plain function, an `@On(UiClicked)` listener, a global initializer — not only from handlers and scene hooks. The widget table it indexes is now built on first use instead of when `@ui_build` is emitted, which came after functions and listeners and crashed the compiler on such a reference. -- A `var` declared twice — including a game `var` whose name the spliced engine runtime already uses (`ui_font`, `grid`, …) — is now a compile error naming the variable and, when it is the runtime's, saying so (`variable ui_font is also a variable of the engine runtime; choose another name`). Previously the two became one LLVM global and clang reported a redefinition in generated IR. The same check covers `property` names (`property Cell is also a property of the engine runtime; choose another name`); before, the first declaration silently won and field lookups failed with a confusing message. -- A `{` or `}` inside a string literal within an interpolation hole (`` `{f("{")}` ``) is text, not structure; the hole scanner used to miscount it. -- A windowed build that reaches the audio runtime indirectly — through the atlas / `Assets.*` preload queue (which feeds `.wav`/`.mp3` into the sound bank) or the engine sprite-render system, without any `Audio.*` call in the game — now links the native audio backend (`audio.ll` + AVFoundation). Previously `ludicc -o` failed at link with undefined `snd_*` symbols for any windowed game declaring a `Sprite` component; the import of `runtime/native/audio.ludic` now flags the backend link itself. -- Character literals accept the same escapes as strings (`'\''`, `'\\'` and `'\"'` were silently read as 0); an unterminated character literal is now an error. -- Hand-written runtime preludes (string, Os.*, Fs.*, Crypto.*, …) now live under their own `@lp_` symbol prefix, so a user `function` named `is_ws`, `str_eq`, `path_join` and the like no longer collides with them at link time. -- Named arguments now work on namespace functions (`@Namespace(Foo)` and `namespace Foo { export function … }`), not only on builtins and bare functions: `Weapon.def(name: "pistol", fire_rate: 9, damage: 14, speed: 8, spread: 0, pellets: 1, pattern: 0)` reorders to the declared parameter order like any other call. Previously every named call on a namespace function failed with "wrong number of arguments". -- The documented bootstrap works on a fresh clone. `bin/` is gitignored and not - checked in, so `clang selfhost/ludicc.seed.ll -o bin/ludicc` — the first command - in the README, in COMPILING, in CONTRIBUTING and on the site — failed with - `ld: open() failed, errno=2 for 'bin/ludicc'`. Every copy now begins with - `mkdir -p bin`, which is what CI had been doing all along. -- Unary minus keeps its operand type: `-f` on a `fixed` is a `fixed` (it was typed `int`, which broke mixed arithmetic and comparisons). -- `become Scene` now works from an `@On(Event)` listener, a global handler, or a plain function (#91). Code outside a scene's own layers cannot know the leaving scene at compile time, so the compiler emits `@L_scene_leave()` — a dispatch on the live scene id that runs its `on exit` — and calls it there. Previously a listener's `become` reused the last emitted handler's scene (or crashed), and a global handler's `become` skipped the leaving scene's `on exit` entirely. -- `ludic-fmt` keeps `rows[i]`, `new []int`, `s[a..b]`, `emit(…)`, `~x` and list-literal braces tight, and recognises `<< >> & | ^ ~` as operators. -- `ludic-fmt` no longer glues an opening parenthesis to a preceding operator: `let moving = (a or b)` stays as written instead of becoming `let moving =(a or b)`. -- `self()` inside an `@OnSpawn(Model)` or `@OnAttach(Property)` body is now the entity being constructed. Previously it was the entity of the innermost query loop — or the constant 0 when the spawn happened outside any loop — so a hook such as `@OnSpawn(Hero) handler Remember { player = self() }` silently recorded entity 0. -- `x += y` / `-=` / `*=` / `/=` now lower exactly like `x = x op y`: a Q16.16 `fixed` multiplies and divides through the 64-bit path, a string `+=` concatenates, and an `int` added to a `long` widens (they previously emitted raw integer arithmetic on the LLVM type). -- `x release` keeps a changeset's markdown intact. Bodies used to go through - `tr '\n' ' '`, which flattened every multi-line changeset into one paragraph — - nested bullets came out as inline `" - "` runs and a release read as a single - unbroken wall of text. A section is now grouped by change type (**Features**, - **Fixes**, **Performance**, …) with one bullet per changeset and continuation - lines indented to stay inside it. - - - `x release --dry-run` renders the next section to stdout and writes nothing, - so a release can be read before it is cut. - - `x changelog-section ` prints one release's section from - `CHANGELOG.md`; `x changelog-render` re-renders a section from a directory of - changesets. The v0.1.0 and v0.3.0 sections were re-rendered with these. -- cursor `mode 3` (confined) now keeps the OS cursor **associated** (absolute position preserved) and hidden, instead of dissociating it like `mode 2` (lock/relative). Only true-lock `mode 2` uses relative deltas now; `win_mouse` reports the absolute position on `mode 3` and clamps it to the framebuffer. And `mode 3` now **physically confines** the cursor: each frame the platform layer warps it back to the window's content rect (`CGWarpMouseCursorPosition`) whenever it strays past the edge, so clicks can't land outside and the window keeps focus. This lets a top-down game hide + confine the cursor while `aim_mode 0` (mouse aim) keeps resolving to where the reticle points — previously any confine/lock mode silently broke absolute mouse aim, and a confined cursor still escaped the window (#89 follow-up). -- reserved words (`new`, `match`, `spawn`, ...) can no longer name a function — the compiler errors instead of miscompiling. -- the shooter aims / homes / fires from a body's **centre** (Position + Collider offset + half-size) instead of the Position anchor, so auto-aim and homing target what is drawn, not a corner. - -### Documentation - -- The generated site is redesigned around reading rather than launching: a warm - paper ground with a serif display face, one ink-blue accent, and rules instead - of floating cards. Colour is reserved for code. Dark mode is the same design - with the ground inverted, driven entirely by tokens under one - `prefers-color-scheme` block, and the landing page's scroll-reveal animations, - gradient headline, glowing badge and emoji feature icons are gone. - - - Stylesheets are linked files (`base.css` + `site.css`/`docs.css`) instead of - being inlined into all 900+ pages, which cuts the published site from 16 MB to - 5 MB and means a design change no longer requires regenerating to be seen. - - Fonts are the platform's own; the site makes no webfont request. - - `api.css` was dead — the generator never referenced it — and is removed along - with `item.css`, which `docs.css` replaces. - - A page no longer flashes its own title on every plain visit; only a deep link - highlights its target, and under `prefers-reduced-motion` the highlight no - longer stays on the element permanently. - - The copy leads with what is verifiable — ahead-of-time compiled, an ECS in the - syntax, deterministic fixed-point, no C in a build — and the "get started" - steps now begin with the clang-plus-seed bootstrap, without which `bin/x` does - not exist on a clean checkout. - -### Build - -- `x` no longer prints a clang warning on every build. Each `clang` invocation the - task runner makes now passes `-Wno-override-module`, the same flag `ludicc` - already passes for its own link step: the emitted IR names no target triple, so - clang substitutes the host's and says so — four times per `x build`, with - nothing to act on. (The comment in `selfhost/main.ludic` claimed the opposite, - that the IR *does* carry a triple; it does not.) - -### CI - -- Releases are published by CI from a tag instead of by hand from a laptop. The - new `release` workflow triggers on a `v*` tag, builds the toolchain from the IR - seed, runs `x test`, `x test-tools` and `x bootstrap-cfree` against the tagged - tree, and only then creates the Forgejo release. It refuses to publish when the - tag and `VERSION` disagree or `CHANGELOG.md` has no section for that version. - - `x publish [vX.Y.Z]` is the command behind it and works locally too: it builds - `dist/` (a source tarball from the tag, this host's toolchain, and a - `SHA256SUMS` covering both — releases previously shipped no checksums) and takes - the release notes from that version's `CHANGELOG.md` section, so the notes and - the changelog cannot drift. Re-running it only adds assets the release is - missing, which is how a macOS build gets attached to a Linux-built release. -## v0.3.0 — 2026-09-02 - -### Features - -- **Tiled map support (#67–#74)** — load and draw [Tiled](https://www.mapeditor.org/) maps (TMX/TSX/TX and TMJ/TSJ/TJ), the design record from #66. - - - **P0 parsing primitives (#67)** — a minimal pure-Ludic XML reader (`Xml.*`) for the element/attribute/CDATA subset TMX/TSX/TX use; standard base64 decode/encode (`Base64.*`, RFC 4648), whose decoder ignores the whitespace Tiled wraps into ``; and gzip framing (`z_gunzip`, RFC 1952) wrapping the existing DEFLATE inflater. zlib and the JSON reader already shipped. A curated, attributed golden corpus lands under `assets/tiled-fixtures/`. - - **P0.5 TMX/TSX reader (#68)** — `Tiled.read` / `Tiled.read_tsx` map the native XML formats onto the *same* intermediate the JSON path produces — a `Value` tree in Tiled's JSON schema, with every tile layer's data decoded to a dense GID list (CSV, base64, base64+zlib, base64+gzip). A CSV `.tmx` and a base64+zlib `.tmj` of the same map read structurally identically; the in-repo Kenney `sampleMap.tmx` + external `sampleSheet.tsx` load with no manual JSON re-export. - - **P1 core load + render (#69)** — the runtime `rt_tmap` model (heap-allocated to `w·h`, lifting the old `96×64` cap), the GID resolver (`Tiled.resolve` → tileset / local id / H·V·D flips), image-backed rendering (`Tiled.draw`, flips applied at blit), and the legacy-tilemap compatibility projection so `Grid.*`/`Path.*`/`esys_move` keep working. `Tiled.load` reads either format, resolves external tilesets + images, and auto-projects a `collision` layer. The Kenney sample loads and renders pixel-identically from `.tmx` and `.tmj`; the `grid` and `physics_tiles` demos now run off a loaded map. - - **P2 collision & grid (#70)** — normalise three collision sources into the byte tilemap `esys_move`/`Grid.*`/`Path.*` read, in the design's priority order: per-tile `` hitboxes, the `solid`/`oneway`/`trigger` property convention (`Tiled.collision_kind`/`Tiled.tile_shapes`), and the designated collision layer (`Tiled.project`, any non-zero GID solid) — or drive collision from a visual layer's per-tile metadata alone (`Tiled.collide`). The property convention and the collision-layer fallback produce the same feed; `Path.a_star` over a loaded map matches the hand-authored baseline. - - **P3 animated tiles + tile objects (#71)** — a tileset `` advances deterministically as a pure function of the fixed 60/s engine frame clock (`Tiled.frame_gid`/`Tiled.animated`), so an animated GID resolves at draw to the current frame's GID with its flip flags preserved and reproduces frame-for-frame across runs; `Tiled.draw_anim` draws a map with animations advanced. Object-layer entries with a `gid` render the tile image (with their own flips), bottom-anchored, as placeable sprites. - - **P4 objects, properties, templates, spawning (#72)** — all object shapes (rectangle / ellipse / point / polygon / polyline / text) and custom properties parse and are queryable (`Tiled.object`, `Tiled.object_shape`, `Tiled.prop`/`Tiled.prop_int`/`Tiled.prop_type`); class properties resolve their defaults against a project custom-type table (`Tiled.load_types` over `objecttypes.xml`); template instances inherit their `.tx`/`.tj` template's fields; and an object maps onto Ludic components on demand (`Tiled.spawn`/`Tiled.spawn_layer`, off by default, via the reflection ABI). - - **P5 breadth (#73)** — image layers (parallax + repeat) and group layers (flattened, with recursive offset/opacity/tint/visible; `Tiled.layer_kind`/`Tiled.layer_offsetx`/`Tiled.layer_tint`); the isometric / staggered / hexagonal orientation coordinate transforms (`Tiled.cell_x`/`Tiled.cell_y`, driving the tile draw so cells land at the correct screen coords); and Wang-set GID resolution through the standard resolver (the terrain-corner authoring concept is editor-side and ignored). - - **P6 scale (#74)** — infinite/chunked maps: `` (TMX) and JSON `chunks[]` decode and flatten into the dense layer; `.world` stitching (`Tiled.world`/`Tiled.world_count`/`Tiled.world_map`) lists member maps at their offsets; and a self-contained pure-Ludic **zstd** decompressor (`z_zstd`, RFC 8878) for base64+zstd layers — frame + raw/RLE/compressed blocks, raw/RLE/direct-weight-Huffman literals, and the full FSE sequence path — decoding the low-entropy GID streams a tilemap produces (a high-entropy FSE-compressed-Huffman-weights block fails cleanly with -1 rather than emitting wrong bytes). -- Builtin NPC AI (#61) — the source package **ludic.npcai**, a perception → decision → action stack that plugs into the other controllers instead of re-implementing movement. The AI never moves a body directly: it writes the SAME intent fields the player controllers read (`want_x`/`want_y`/`want_fire`, `want_jump`), so an enemy gunner reuses the shooter's weapon/projectile/auto-aim systems verbatim (set the body's `TopDown.aim_mode = 3`) and a companion reuses the mover — friendly vs enemy is faction + goal, not different code. **Perception** (`Vision` + `Memory`, throttled `esys_perception` with faction filtering and optional `Grid` line-of-sight) remembers the nearest hostile and emits `TargetSpotted`/`TargetLost`. **Decision** offers three models writing one `Brain` intent — a finite-state machine (patrol/chase/attack/flee), a utility scorer, and a canonical behaviour tree — each decision veto-able via `cancellable DecisionMade`. **Steering** adds Reynolds flocking (separate/cohere), and a `Follower` component gives companion stances. Reuses ludic.gameplay Faction (who is hostile) + Stats (hp for flee). Fully deterministic: perception + replan are frame-throttled and fixed-order. Example: `examples/games/npcai_demo.ludic` — one enemy perceives, chases and shoots a target through the shooter controller, flees at low hp under the utility model, and a companion follows its leader. -- Builtin Platformer controller (#58) — the reference implementation of the six-lever extensibility contract, shipped as the source package **ludic.platformer**. Movement feel is all defaulted POD data (`Platformer { move_speed, jump_height, apex_frames, fall_gravity_mul, coyote_frames, jump_buffer_frames, air_jumps, policy, … }`); the controller is decomposed into small engine-owned sub-systems — input (Input phase), move/gravity/jump (FixedUpdate, before the shared `esys_move` sweep) and animation-state (LateUpdate, after it) — each independently switch-off-able with `disable system `. It owns movement *policy* only and reuses the engine Body/Collider swept-AABB collision. Jump feel derives gravity + impulse from height/apex, with coyote time, jump buffering, variable jump height and multi-jump; every jump decision emits a `cancellable JumpRequested` / `JumpPerformed` / `Landed` / `StateChanged` event, and a gravity `policy` enum (asymmetric / symmetric / floaty) is the formula hook. Ships the opt-in game-loop layer too (`scaffolding.ludic`): moving & crumbling platform blocks with rider carry, collectibles + `Score`, springs, hazards + a light `Life`/i-frames model, and checkpoint/goal triggers. Deterministic integer Q16.16 throughout. Examples: `examples/games/platformer_demo.ludic`, `examples/games/platformer_scaffolding.ludic`. Also fixes a latent codegen bug in `ludic_sweep_entity` (an SSA register name collided once a program declared ≥11 events). -- Builtin RPG systems suite (#59) — the source package **ludic.rpg**, seven independently-usable modules on the six-lever contract, with name-keyed data registries so a game or mod adds content with zero code. **A Movement** — one `Mover` with a `mode` selector (grid / free / grid-tween) and 4/8-axis, tilemap walkability, and `cancellable MoveRequested` (locked doors/ice) / `TileEntered` (encounters) / `Interacted` (the action-button raycast). **B Inventory** — a name-keyed item registry, per-owner counts, gold, `ItemUse` veto, and `Equipment` whose bonuses flow through the gameplay Stats modifier stack. **C Crafting** — a data-driven recipe + ingredient registry; `Craft.can`/`Craft.make` consume from the inventory. **D Quests** — quests + objectives whose progress is driven by `Quest.notify` (route any gameplay signal in), auto-completing when met, plus a global flag store for branching. **E Dialog** — an Ink/Yarn-style graph registry (nodes + choices) with a per-speaker `Dialog` component and `cancellable DialogChoice` for skill-check gating. **F Puzzles** — Sokoban `Pushable` + a switch / pressure-plate / gate signal graph (logic puzzles with no code). **G Status** — over-time poison/regen effects routed through the shared Combat pipeline. Everything is integer-deterministic, so save/load (world_save) and rollback hold. Example: `examples/games/rpg_demo.ludic` (21 self-checks across all seven modules). -- Builtin top-down Shooter controller (#60) — the source package **ludic.shooter**, conforming to the six-lever contract and built on the engine Body/Collider + ludic.gameplay Faction/Combat/Stats. `TopDown` decouples movement from aim (`aim_mode`: mouse / right-stick / move-direction / nearest-enemy auto-aim, with a `turn_rate` for tank-style rotation). Weapons are a name-keyed **registry** (`Weapon.def("shotgun", …)` — add a gun with zero code) with per-weapon fire-rate, damage, speed, spread, pellet count, pattern (single / spread cone / ring / spiral), plus data-driven pierce (`Weapon.set_pierce`) and homing (`Weapon.set_homing`); `esys_weapon` reads a `want_fire` intent so the **same** weapon fires for a player (input) and an NPC (AI). `Projectile` + `esys_projectile` is a self-contained deterministic pool: integrate, TTL, faction-filtered hit through `Combat.damage`, pierce, and homing that curves onto the nearest enemy — every step observable via `ProjectileSpawned` (mutable/veto) / `ProjectileHit` / `ProjectileExpired`. A budgeted `Spawner` wave director emits `SpawnRequested` / `WaveCleared`. Example: `examples/games/shooter_demo.ludic` (11 self-checks: movement, aim, faction damage, friendly-fire immunity, spread, kill, ring, homing, waves). -- Canonical engine-ABI components (#77) — the shared `Position` / `Body` / `Collider` / `Solids` bundles the engine-owned movement system (`esys_move`, #65) reads by name are now shipped from a base source package, **ludic.core**, instead of being re-declared by hand in every game and example. A game `import "ludic.core/components.ludic"` and the engine moves and collides its entities for free; extend by *composition* (attach your own components on the same model). AOT means the properties compile straight into the consumer's compile-time ECS with no ABI seam, and everything stays integer + Q16.16 deterministic (lockstep / replay / `world_save` hold). Example: `examples/library/core_components.ludic`. -- Cursor capture (#89) — `Input.cursor_mode(mode)` hides / locks / confines the OS mouse for a windowed game: `0` normal (visible, free), `1` hidden (hide the OS cursor while focused so a game draws its own reticle), `2` locked (hidden + dissociated — the mouse feeds *relative* motion through `Input.mouse_dx/dy`, and `Input.mouse_x/y` becomes a clamped virtual cursor, the FPS / twin-stick aim mode), `3` confined (dissociated but visible; the mouse cannot leave the window). The platform auto-releases (shows + reconnects the cursor) while the window is not key (Cmd-Tab) and on close, so the cursor is never left captured. Adds the native macOS implementation in `cocoa.ll` (`[NSCursor hide]/[unhide]`, ref-counted and toggled only on change; `CGAssociateMouseAndMouseCursorPosition`; `CGGetLastMouseDelta` for the relative virtual cursor) behind a new `win_cursor_mode` intrinsic; headless / non-windowed it is a no-op (DCE'd). Example: `examples/library/cursor_capture.ludic`. -- Declarative Render clear + present (#86) — `@ClearColor(0xRRGGBB)` makes the engine own the per-frame clear and flip: at the top of the Render phase it clears the framebuffer to the declared colour, and after the Render handlers run it presents the frame, so a game's Render handler no longer repeats `Screen.clear(color)` / `Screen.show()` and the clear colour is configured *declaratively* rather than in the handler body. Opt-in and backward-compatible: a program with no `@ClearColor` is byte-for-byte identical (it clears/presents itself, or the light system owns the present). Example: `examples/library/clear_color.ludic`. -- Deterministic camera zoom (#78) — `Camera.zoom(scale)` scales the whole view about the screen centre by a Q16.16 factor (`1.0` = none, `2.0` = 2x in, `0.5` = out). It rides on the same two framebuffer chokepoints (`rt_put_px` / `rt_fill_rect`) that already carry the camera offset, so it composes with `Camera.set`/`follow`/`shake`, and it is a *render-time* transform — the world coordinate types stay integer pixels + Q16.16 velocity, so lockstep, replay and `world_save` are untouched, and the zoom itself is deterministic. Gated by an internal `rt_cam_zoomed` flag so a game that never zooms renders byte-for-byte identically (golden renders unchanged); `Camera.zoom(1.0)` turns it back off. This is the concrete outcome of the #78 position-types investigation (`docs/RFC-POSITION-TYPES.md`), which rejected hardware floats for the deterministic coordinate core and identified zoom as the one genuinely-missing render feature. Example: `examples/library/camera_zoom.ludic` (pixel-readback verified). -- Directional int input (#79) — `Input.axis_i(neg, pos) -> int` returns a -1/0/1 movement intent (`+1` positive key held, `-1` negative, `0` neither or both) read from the multi-key device set, so turning WASD into movement no longer needs the `ki(key_down('d')) - ki(key_down('a'))` bool-to-int glue and feeds an int mover directly: `dx = Input.axis_i('a', 'd')`, `dy = Input.axis_i('w', 's')`. Complements `Input.axis` (fixed) / `Input.vector` (normalized). Example: `examples/library/input_movement.ludic`. -- Engine sprite-render system (#85) — the engine already auto-*ticks* SpriteAnim and Motion; it now auto-*draws* too. Declare a `Sprite` component (id + optional offx/offy/scale/flip/tint/hidden, shipped from **ludic.core**) on an entity with a `Position` and the engine draws it each Render frame — no hand-written Render handler querying positions and calling `draw_sprite` per entity, and no hand animation (when the entity also carries `SpriteAnim`, the current frame is added to the base id). Registered on the compile-time engine-system registry for the Render phase and spliced only when a game declares `Sprite`, so a game that never declares it compiles byte-identically; a game wanting a custom draw omits `Sprite` (or `disable system esys_sprite`). Also **deprecates the bare `draw_sprite` / `draw_sprite_scaled` globals** in favour of the namespaced `Screen.sprite` / `Screen.sprite_scaled`: a direct bare call now emits a one-time compile-time deprecation note (the bare form still lowers, since `Screen.sprite` uses it), and the in-repo `chronorift` demo is migrated to the namespaced calls. Example: `examples/library/sprite_render.ludic`. -- Entity-pool stats (#80). Ludic's ECS is already pool-based: the allocator recycles freed entity slots through a freelist (a `despawn`ed slot is reused by the next `spawn` before any new slot is taken), and component storage is fixed per-entity arrays — so spawning and despawning many entities per frame (bullet-hell / horde) does **no per-spawn heap allocation** and cannot fragment. Exposes that with a `Pool.*` namespace so a game can watch the reuse and budget against the cap: `Pool.live()` (entities alive now), `Pool.free()` (freed slots waiting to be reused), `Pool.reserved()` (high-water — slots ever allocated; stays flat across a steady spawn/despawn loop, the proof that slots are pooled not reallocated), and `Pool.capacity()` (the fixed entity cap). Zero-cost — they read the existing allocator counters inline. Example: `examples/library/pool.ludic`. -- Gameplay-controller foundation (#57) — the shared, cross-genre building blocks the builtin controllers stand on, shipped as the source package **ludic.gameplay**: a deterministic `Cooldown` frame timer (engine-ticked), a `Stats` attribute bundle with an unbounded timed **modifier stack** (`Stats.total` computes base+flat then percent on demand; expired modifiers self-despawn), a `Faction` friend/enemy/neutral relationship table (same-id-friendly / different-hostile by default), and a `Combat` damage pipeline whose `cancellable DamageAboutToApply` hook lets a game veto a hit *or rewrite the amount* (`Combat.set_amount`) and which emits `Damaged`/`Died`/`Healed`. Everything is integer-only so lockstep, replay and `world_save` snapshots hold. Also adds extensibility **lever 5** to the language: `disable system ` drops exactly one engine-owned system's tick at compile time, so a game can carry a well-known component but tick it with its own handler (byte-identical when nothing is disabled; the C-free bootstrap fixpoint is untouched). Example: `examples/library/gameplay_foundation.ludic`. -- Incremental asset preloading (#82). Assets used to load synchronously inside Boot/Start (`png_load`, `Audio.load`), stalling the first frame(s) as content grows, with no built-in loading phase. Adds an `Assets.*` preload queue: `Assets.enqueue(name, path)` queues a named image without loading it, `Assets.pump(max)` loads up to `max` queued assets per frame (returning how many it loaded), and `Assets.total` / `loaded` / `ready` / `progress` (0..100) drive a progress bar. A loading scene pumps a few assets per frame, draws `Assets.progress()`, and `become`s the play scene once `Assets.ready()`, so the game shows a responsive loading screen and only enters play once content is ready — the deterministic, no-threads form of async preloading (the work is spread across frames instead of stalling one, and the same enqueue+pump order loads identically every run). Loaded assets are reachable by name via `Assets.get` / `Sprite.named`. Builds on the #81 atlas. Example: `examples/library/preload.ludic`. -- Input Manager + automatic device-layer drive (#83). The generated frame loop now commits the input device layer itself — when a game uses any Input action-map / device method it calls `input_poll` each frame (reading the live key, recording/replaying, and rebuilding the held-key/mouse/gamepad state), so `Input.active` / `Input.key_down` / the mouse and pads read live **without the game calling `Input.poll` by hand** (previously the loop fed only `Input.key`, and the device layer read empty unless the game polled at the top of its Input phase). A game that uses no Input runtime keeps the plain `rt_poll` path, byte-identical. Adds the Input-Manager API on top: `Input.action(name, key)` ships a **default** binding (kept if already bound, so a player's `Input.rebind` or a loaded key-map is not clobbered); `Input.bind_pad(name, button)` makes an action **device-agnostic** (fires from keyboard *or* gamepad); and `Input.active` / `Input.just_pressed` / `Input.just_released` read the whole multi-key device layer with clean on-press / on-release edges (the deterministic, dispatch-free equivalent of event handlers — a handler polls the edge and reacts, so a replay fires identically). Examples: `examples/library/input_manager.ludic`, `examples/library/input_auto.ludic`. -- Namespace block form (#76) — `namespace Name { export function foo(…) … internal function bar(…) … }` declares a `Name.*` namespace once and controls its public surface declaratively, instead of annotating every function with `@Namespace(Name)` one at a time. Inside the block each `function short(…)` is emitted as `namelower_short`; an `export` function (the default) is callable as `Name.short(…)`, while an `internal` function is a private helper — emitted and callable by its short name from siblings in the block (calls are rewritten to the emitted name), but not part of the `Name.*` surface (`Name.internalOne()` is a compile error). It is the block sugar for the per-function `@Namespace` annotation, so a package's public API reads at a glance. A namespace declared the old per-function way is unchanged. Example: `examples/library/namespace_block.ludic`. -- Namespaced spritesheet / atlas API (#81). Sprite loading was a bare `png_load("floor0.png")` — one file per 16x16 sprite, with no way to load one sheet and address a cell by grid coords or name. Adds a `Sprite.*` / `Assets.*` runtime (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, cellw, cellh)` -> handle, `Sprite.cell(sheet, col, row)` and `Sprite.cell_span(sheet, col, row, cols, rows)` -> id (a sprite may span more than one cell — a tall character, a wide object), `Sprite.define(name, …)` / `Sprite.named(name)` to name and look up a cell, `Sprite.draw` / `Sprite.draw_scaled` (through the camera / zoom / clip, like `Screen.sprite`), `Sprite.width` / `height`, and `Assets.image(path)` / `Assets.load` / `Assets.get(name)`. Spliced on demand; a program that uses neither compiles byte-identically. Example: `examples/library/atlas.ludic`. -- Optional world boundaries (#84) — declare a single `Bounds` config entity (a rect `x, y, w, h` plus a policy, shipped from **ludic.core**) and the engine-owned world-bounds system keeps every moving `Body` inside the play area each frame, so a game no longer hand-clamps `Position`. Four policies: `0` clamp (walls), `1` wrap (toroidal), `2` bounce (clamp + flip the Body velocity on the axis that hit), `3` kill (despawn a body fully outside). It reads each body's `Collider` size so the whole box stays inside; off by default (no `Bounds` entity = open world). Registered on the engine-system registry for LateUpdate (after movement integrates) and spliced only when a game declares `Bounds`, so a game that never does compiles byte-identically. Also adds `World.despawn(entity)` — the reflective, by-id form of the `despawn` statement (runs `@OnDespawn` + frees the slot), which the kill policy uses and any system can call. Example: `examples/library/world_bounds.ludic`. -- Package manager (#63) — `x add` / `x get` / `x update` / `x verify` / `x vendor` bring third-party packages to Ludic with no new infrastructure. Dependencies are named by their git import path (URL-as-identity, no registry — a `git tag vX.Y.Z` is publishing), resolved by Go-style Minimum Version Selection, fetched into a content-addressed global store (`~/.ludic/store`, keyed by a file-content hash) and linked into each project under `ludic_modules/`. A `package.ludic` manifest declares dependencies, the provided `Foo.*` namespace(s), the kind (source or prebuilt) and, for prebuilt libs, the shipped targets; `package.lock.ludic` pins the resolved versions and content hashes for reproducible, verifiable builds. A source package's Ludic compiles into the consumer via a new module-root import fallback in the compiler (`import "git.workshopsoft.io/user/pkg/foo.ludic"` resolves against `$LUDIC_MODULES`, default `ludic_modules/`), so a package registers a namespace the same way the built-in stdlib does. Namespace collisions and missing prebuilt targets are hard errors. Existing programs compile byte-for-byte identically; the C-free bootstrap fixpoint is untouched. See docs/PACKAGES.md. -- Package-declarable namespaces & engine systems (#62) — the two hooks that made `Foo.*` stdlib namespaces and engine-owned systems compiler-hardcoded are now data-driven registries, so a package registers them with no compiler edit. `@Namespace(Name)` on a function opens a `Name.method(…)` namespace that dispatches to the bare `name_method` through the same generic path the built-in namespaces use (applied only after them, so it never shadows a core one). `@EngineSystem(Component, Phase)` registers an engine-owned system the frame loop runs each phase when the component is present — the package-declarable form of the built-in SpriteAnim/Motion/Light2D systems, reading components by name through the reflection ABI so an unused registration is byte-identical. Both annotations are keyword-free. The core stdlib keeps its optimized codegen (byte-identical output; the C-free bootstrap fixpoint is untouched) and packages ride the generic registry alongside it. This completes the packaging prerequisite for shipping gameplay-controller libraries (#58–#61) as real packages. See docs/PACKAGES.md. -- Prebuilt binary packages (#64) — a package can now ship a compiled artifact whose exported functions, systems and components a consumer uses without the source, over the stable reflection C-ABI. `x build-lib ` compiles a package's module to a per-target native dylib (`lib//`); a `kind prebuilt` dependency is fetched and linked like any other, and `x link-flags` prints the clang flags to link the module dylibs into a game (or `x app` does it in-repo). A module registers its dynamic components (`world_register_prop`) and its `@System(Phase)` functions with the host at load through a constructor, and the host dispatches every registered system each frame — the systems analogue of dynamic components. Binary packages are native-only and second-class ECS by design (source packages remain the portable, first-class, deterministic path); missing a build target is a hard error. See docs/PACKAGES.md. -- The engine runtime ships with the toolchain, not the project (#75). The compiler auto-splices `runtime/native/*` for any ECS game; a `runtime/...` import that is not found relative to the build is now resolved from the install root **`$LUDIC_HOME`** (default: the compiler binary's directory — where the platform `.ll` files already come from) *before* the package module root. So an external game that consumes the `ludic.*` packages no longer has to copy or symlink the engine runtime into its `ludic_modules/`; that directory holds only third-party packages, and the runtime is part of the toolchain install. In-repo builds are byte-identical (the runtime still resolves locally there, so the `$LUDIC_HOME` fallback never fires and the C-free bootstrap fixpoint is untouched). - -### Fixes - -- **Correct the element type of a slice indexed by a member-access expression.** `emit_index_addr` set the global `g_addr_ty` to the slice's element type *before* evaluating the index expression, so an index that was itself a struct-field access (`slice[obj.field]`) overwrote it — the load then came back typed as the field, and a following field access failed with "member access on non-aggregate". The slice branch now sets `g_addr_ty` last, matching the raw-pointer branches. Also de-duplicate the `@strcmp` declaration (centralised in the head prelude) so a program that pulls in both the world table and the filesystem prelude links. -- Input.key_pressed / key_released edges now fire (#87). In a frame-loop game the edges never triggered: the loop (since #83) commits the device layer once per frame via `input_drive`, but a game that *also* called `Input.poll` by hand committed a second time in the same frame, and `input_device_commit` copies `in_held` into `in_prev` at the top of every commit — so the second commit left `in_prev == in_held` and `key_pressed` (`held && !prev`) / `key_released` could never see a transition. Fixed with an `in_have_frame_driver` flag: the loop's `input_drive` sets it, and a manual `Input.poll` under the loop becomes a no-op that returns the frame's key instead of re-committing. An entry-driven harness has no loop, so the flag stays false and each `Input.poll` still commits a frame of input as before (record/replay and the #50 device tests are unchanged). Example: `examples/library/input_edge.ludic` (press edge on the down frame, release edge on the up frame). -- Windowed games no longer force-quit on Esc or 'q' (#88). The macOS platform layer (`runtime/native/cocoa.ll` `win_poll`) used to hard-code Escape (keycode 53) and the character 'q' as *quit* — storing `W_running = 0` so a shipped windowed game died the instant a player pressed Esc (a universal pause key) or typed 'q'. Those dev-loop conveniences are removed for windowed builds: **Escape is delivered to the game as key 27** and **'q' is an ordinary key**, consistently across both the single per-frame key (`@W_key`) and the `#50` held-key set (`ev_keyval` now maps Escape→27, not 'q'). A windowed game now owns Esc/pause and shuts down via `quit()` or the window close button (which still ends the run). The headless test driver (`rt_poll` in `core.ludic`) keeps its own `'q'` = quit for scripted golden runs, so nothing headless changes. Also stops forwarding consumed key events to `-sendEvent:`, which was triggering AppKit's system "funk" beep on every keystroke. - -## v0.2.0 — 2026-09-01 - -The types-and-systems release. Ludic grows a real type system — sum types, -`option`/`result`, exact and arbitrarily-big numbers, string-keyed containers and -packed 2D value types — alongside an engine that auto-runs animation, motion and -lighting over components a game merely declares, a full input stack from -rebindable action maps to native gamepads, out-of-band Audio / HTTP / Jobs -standard libraries, and a built-in test framework with line coverage. Every -addition is gated and additive: a program that never touches a feature compiles -byte-for-byte identically, and the C-free bootstrap fixpoint is untouched. - -### Language & types - -- **feat**: Tagged-union enums (#56) — an `enum` variant may now carry a payload (`enum Tile { Empty, Wall, Door(int), Portal(int, int) }`), making it a sum type. Variants construct by name and `match` destructures them, binding each payload, with exhaustiveness and arity checked so adding a variant surfaces every site to update. Plain enums keep their zero-cost ordinal representation, byte-for-byte unchanged. -- **feat**: `result` + `try`/`else` (#46) — a fallible function returns `ok(payload)` or `err(message)`, and `try EXPR else { … }` recovers a value with the failure message bound to `error`. A plain branch on the tag: no exceptions, no stack unwinding. `is_ok`/`is_err` classify without unwrapping. -- **feat**: `option` (#53) — `some(v)` / `none()`, a maybe-a-value with no magic `-1` sentinel, read with `is_some`/`is_none`/`unwrap_or`. -- **feat**: `panic(msg)` + `assert(cond, msg)` (#8) — a clear, located `file:line: panic:` / `assertion failed:` message and a clean exit 1 instead of a raw segfault; the source location is baked in at compile time. -- **feat**: `IVec2` + `Rect` 2D value types (#1) — by-value spatial types that lower to packed integers, so they copy like scalars and never allocate: an integer 2D vector for grid coordinates and a Q16.16 axis-aligned rectangle for HUD boxes and hitboxes, both exact and platform-identical. -- **feat**: `BigInt` + `Decimal` exact numbers (#52) — arbitrary-precision integers and exact base-10 fixed-point for game economies, so an idle counter never overflows and `0.10 + 0.20` is exactly `0.30`. No `f32`/`f64`; deterministic. -- **feat**: `Huge` + `Angle` + `Percent` (#55) — a display-scale idle/incremental big number (`1.23e45`), an auto-wrapping radian angle over deterministic `Math.*` trig, and a `[0,1]`-clamped fraction for health, volume and interpolation `t`. -- **feat**: `Dict` + `Set` containers (#54) — string-keyed lookups over one open-addressing hash table (FNV-1a, linear probing, tombstones), for resource counts, registries, tags and visited tiles; O(1) average instead of a linear scan. -- **feat**: Value tree + reflection + JSON (#44) — a self-describing `Value` node, `Reflect.serialize`/`apply` to walk an entity's whole component set to and from it bit-exactly, and `Json.encode`/`parse` for compact, stable, diffable text. One-call save/load for entities and the backbone of data-driven tooling. - -### Engine, animation & lighting - -- **feat**: Engine-owned systems (#43) — the ECS hook that auto-runs a system each frame over a component a game merely declares, no `handler` wired: `SpriteAnim` advances sprite-sheet frames and `Motion` advances value tweens for free. Built on the reflection ABI, so it costs nothing in a game that declares neither. -- **feat**: Animation ergonomics (#48) — an ergonomic layer over those systems: `Anim.clip`/`Anim.play` for named spritesheet clips, `Anim.on_frame`/`fired` frame events, `Motion.to` one-call tweens, and fluent engine-advanced `Tween` handles (`to`/`chain`/`delay`/`parallel`/`stop`). All integer and deterministic under replay. -- **feat**: `Light.*` 2D lighting (#4) — a deterministic software light-accumulation pass over the framebuffer: `ambient` tinting, additive radial `point` lights with linear falloff, and hard shadows cast against rectangular occluders. Integer + Q16.16, identical every run and headless. -- **feat**: ECS-native lighting (#47) — a torch is now just an entity carrying `Light2D`, a wall an `Occluder`, and one `Ambient` sets the night tint; the engine runs the whole light pass at the end of the Render phase with no `Light.*` calls wired by hand. -- **feat**: Lighting render-quality tiers (#49) — cone/flashlight `spot` lights, a `falloff` exponent, `soft` penumbra shadows, colour `gel` cookies, normal-mapped surfaces (N·L) and a `time_of_day` day/night ramp, all on the same deterministic accumulation core and consumable via optional `Light2D` fields. - -### Input - -- **feat**: Action maps + record/replay (#7) — gameplay reads named, rebindable actions instead of physical keys, and the single per-frame `Input.poll` makes deterministic replay fall out for free: `Input.record` captures the tape and `Input.replay` feeds it back exactly — the seed of lockstep netcode. -- **feat**: Device layer (#50) — multiple simultaneous held keys, analog axes and a normalized vector, the mouse (position/delta/buttons/wheel), gamepads and touch, all injectable on every target (`Input.press`/`set_mouse`/`set_pad`/`set_touch`) and snapshotted whole into the replay tape. -- **feat**: Native hardware bindings (#51) — the device layer's macOS side wired into cocoa.ll: live cursor position, per-frame GameController polling into gamepad buttons/axes (SDL button order), and NSTouch routing, all DCE'd out of a headless build. No API changes. - -### Standard library - -- **feat**: `Audio.*` (#22) — sound effects and music over a new AVAudioPlayer backend: `load`/`play`/`play_music`/`stop`/`volume`/`pitch`/`is_playing`. Out-of-band (real-time, not part of the simulation) but frame-driven, so a replay fires the same sounds at the same frames; headless builds carry it as dead-stripped no-ops. -- **feat**: `Http.*` (#6) — a poll-based HTTP/HTTPS client for out-of-band data (leaderboards, cloud saves, remote config) that never blocks the frame, over an NSURLConnection transport with system TLS on by default. Pairs with `Json.parse`; the response parser is pure Ludic and tested offline. -- **feat**: Jobs, Promises & opt-in Sync (#14) — a layered concurrency library. `Job.*`/`Promise.*` are the safe default: cooperative futures pumped a little each frame so heavy work spreads out, combined with `Promise.all`/`race`. The advanced `Sync.*` tier adds mutexes, atomics and bounded channels. A deterministic cooperative scheduler — results are collected on the main thread and a Job never touches the world directly, so lockstep and replays stay bit-exact. - -### Testing & tooling - -- **feat**: Built-in test framework (#12) — a `test "name" { … }` block auto-discovered and run by a synthetic runner (no `entry` to write), with `expect`/`expect_eq`/`expect_near` assertions that report every failure and exit non-zero. `expect_near` carries the tolerance fixed-point game math needs. -- **feat**: Line coverage (#45) — compile with `--coverage` and the compiler instruments each statement with a per-line hit counter dumped at exit; `bin/x test --coverage` aggregates the dumps into a per-file report naming the unreached lines. Flag-gated and additive — an ordinary build stays byte-identical. - -### Fixes - -- **fix**: `const` of a non-int type is no longer miscompiled. A `const` reference lowered to its initializer's raw integer bits typed as `int`, so `const X: fixed = 10.0` computed as the raw Q16.16 value `655360` instead of `10.0` — silently corrupting fixed-point math (and, in one case, spinning an infinite loop). Const references now emit their initializer with its real type. Every existing const is an `int` literal, so the lowering there is byte-identical and the bootstrap fixpoint and golden renders are unchanged. +All notable changes to the Ludic toolchain, newest first. Generated from the +changesets under changes/ by x release; do not edit released sections by hand. ## v0.1.0 — 2026-08-30 -### Features +- **ci**: Continuous integration — Forgejo Actions workflows build the toolchain from the seed, run the regression + editor suites, assert the C-free bootstrap fixpoint, and lint commit messages on every push and pull request. +- **feat**: Editor tooling — `ludic-fmt` (formatter) and `ludic-lsp` (language server), plus VS Code and JetBrains integrations, all built by the toolchain. +- **feat**: Namespaced standard library — Math, Text, List, Random, Time, Screen, Color, Ease, Collide, Memory, Vector, DateTime/Date/Duration/Clock, Unicode, Os, Fs/Path/Mime, Log, Noise, Hash, Crypto and Uuid, each deterministic where a game needs it. +- **feat**: Native 2D backend — an ECS core with a deterministic fixed-point (Q16.16) runtime, a windowed Cocoa target on macOS and a headless PPM renderer that runs anywhere. +- **feat**: Self-hosted, C-free toolchain — the compiler, runtime, task runner and editor tools are all written in Ludic and built from a checked-in LLVM-IR seed with clang alone; `x bootstrap-cfree` proves the compiler rebuilds itself byte-for-byte. +- **feat**: Versioning and releases — SemVer with `ludicc --version`, a changeset-driven `CHANGELOG.md`, and `x release` to bump, tag, and publish a Forgejo release with source and toolchain artifacts. -- Editor tooling — `ludic-fmt` (formatter) and `ludic-lsp` (language server), plus VS Code and JetBrains integrations, all built by the toolchain. -- Namespaced standard library — Math, Text, List, Random, Time, Screen, Color, Ease, Collide, Memory, Vector, DateTime/Date/Duration/Clock, Unicode, Os, Fs/Path/Mime, Log, Noise, Hash, Crypto and Uuid, each deterministic where a game needs it. -- Native 2D backend — an ECS core with a deterministic fixed-point (Q16.16) runtime, a windowed Cocoa target on macOS and a headless PPM renderer that runs anywhere. -- Self-hosted, C-free toolchain — the compiler, runtime, task runner and editor tools are all written in Ludic and built from a checked-in LLVM-IR seed with clang alone; `x bootstrap-cfree` proves the compiler rebuilds itself byte-for-byte. -- Versioning and releases — SemVer with `ludicc --version`, a changeset-driven `CHANGELOG.md`, and `x release` to bump, tag, and publish a Forgejo release with source and toolchain artifacts. - -### CI - -- Continuous integration — Forgejo Actions workflows build the toolchain from the seed, run the regression + editor suites, assert the C-free bootstrap fixpoint, and lint commit messages on every push and pull request. diff --git a/COMPILING.md b/COMPILING.md index 8f03d7d3..be767471 100644 --- a/COMPILING.md +++ b/COMPILING.md @@ -1,46 +1,37 @@ # Compiling Ludic -> **Note:** `ludicc` is **written in Ludic** (`selfhost/*.ludic`) and built from a -> checked-in IR seed — the C compiler this document once described has been -> deleted. The native pipeline below (Ludic → LLVM IR → object → binary) is -> unchanged. `ludicc` drives clang itself (via an `os_system` intrinsic), so -> `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly. `--fmt` is -> reimplemented as a lex+parse gate (the doc-check hook). The `--target`/ -> cross-compile and `--shared` paths are still features of the old C driver not -> yet re-implemented on the self-hosted toolchain. See the -> [Bootstrap deep-dive](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Bootstrap) §5.7 on the wiki. +> **Note (2026-08-27):** `ludicc` is now **written in Ludic** (`selfhost/*.ludic`) +> and built from a checked-in IR seed — the C compiler this document describes has +> been deleted. The native pipeline below (Ludic → LLVM IR → object → binary) is +> unchanged. `ludicc` now drives clang itself (via an `os_system` intrinsic), so +> `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly, and a sibling +> command `ludic app.ludic` compiles to a temporary binary and runs it in one +> step. The whole toolchain is built by `bin/x build`; `bin/x app` remains as a +> convenience wrapper over the compiler. `--fmt` is reimplemented as a lex+parse +> gate (the doc-check hook). The `--target`/cross-compile and `--shared` paths are +> still features of the old C driver not yet re-implemented on the self-hosted +> toolchain. See BOOTSTRAP.md §5.7. > -> Most people never invoke `ludicc` directly: the `ludic` CLI drives it. +> From a clean checkout, build the compiler and the task-runner in one line, then +> let `bin/x` do the rest (run it from the repository root): > > ```bash -> curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh # the toolchain, into ~/.ludic -> ludic new mygame && cd mygame -> ludic run # compile + run -> ludic build --headless # compile, deterministic render +> # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/x +> clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x +> bin/x build # rebuild the whole toolchain into bin/ +> # (ludicc, ludic, x, ludic-fmt, ludic-lsp) +> bin/ludicc examples/games/snake.ludic -o bin/snake # compile +> bin/ludic examples/games/snake.ludic # compile + run +> bin/x help # list every command > ``` > -> From a clean checkout, the compiler and the CLI come up in two lines and the -> CLI does the rest (run it from the repository root): -> -> ```bash -> # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/ludic -> mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc -> bin/ludicc --unsafe --globals tools/ludic-cli/dev.ludic -o bin/ludic-dev -> bin/ludic-dev build # the whole toolchain into bin/ -> # (ludicc, ludic, ludic-fmt, ludic-lsp) -> bin/ludicc examples/games/snake.ludic -o bin/snake # the compiler, directly -> bin/ludic build examples/games/snake.ludic # or through the CLI -> bin/ludic help # every command -> ``` -> -> A `.ludic` file with handlers is a game and links windowed by default; -> `--headless` and `--windowed` force the mode. The engine runtime -> (`runtime/native/cocoa.ll`, the spliced `runtime/native/*.ludic`) and the -> bundled `ludic.*` packages are found under the **install root**: `$LUDIC_HOME` -> if set, otherwise derived from the binary's own location — the parent of its -> `bin/` directory, which is both `~/.ludic` for an install and the repository -> root for a checkout. `$LUDIC_CC` overrides the assembler/linker (default -> `clang`). +> The binaries are multi-call (one native binary under two names): invoked as +> `ludicc` it compiles, as `ludic` it compiles-and-runs. A `.ludic` file with +> systems is a game and links windowed by default; `--headless` and `--windowed` +> force the mode. The runtime (`runtime/native/cocoa.ll`) is found via +> `$LUDIC_HOME`, defaulting to the directory the binary sits in — keep them in +> `bin/`, or set `LUDIC_HOME` and put them on `PATH`. `$LUDIC_CC` overrides the +> assembler/linker (default `clang`). `ludicc` is a compiler, not a translator. It lexes, parses, checks and lowers @@ -51,10 +42,10 @@ and find your program rewritten in another language. ``` app.ludic - │ ludicc — lex, parse, lower (selfhost/frontend/*.ludic, - ▼ selfhost/backend/*.ludic) - app.ll LLVM IR: your handlers, your properties, your runtime - │ IR assembler (selfhost/main.ludic drives $LUDIC_CC) + │ ludicc — lex, parse, check, lower (compiler/ludicc.c, + ▼ compiler/native.c) + app.ll LLVM IR: your systems, your properties, your runtime + │ IR assembler (compiler/driver.c) ▼ app.o Mach-O / ELF / COFF object code │ system linker @@ -73,9 +64,6 @@ point at a different LLVM toolchain if you have one. | a windowed native executable | `ludicc game.ludic -o build/game` | | a headless executable | `ludicc game.ludic --headless -o build/game` | | the IR, to read | `ludicc src.ludic --emit-llvm -o src.ll` | -| the schema an editor reads (records, registries and their entries, consts) | `ludicc src.ludic --emit-schema schema.json` | -| every error, as a JSON array on stdout | `ludicc src.ludic --check --diagnostics=json` | -| the same, with an unsaved buffer on stdin standing for one of its files | `ludicc src.ludic --check --diagnostics=json --stdin-file lib/a.ludic < buf` | | a shared library † | `ludicc lib.ludic --shared -o build/liblib.dylib` | | a game that runs in a browser † | `ludicc game.ludic --target wasm32-unknown-unknown -o build/web/game.wasm` | | an object file † | `ludicc src.ludic -c -o src.o` | @@ -85,33 +73,32 @@ the old C driver and are **not yet re-implemented** on the self-hosted toolchain (see the note at the top). The rows above the line work today via the self-hosted `ludicc`. -`bin/ludic build` wraps the common cases: +`bin/x app` wraps the common cases: ```bash -bin/ludic build examples/games/snake.ludic # -> build/snake (native) -bin/ludic build examples/library/combat.ludic --lib # -> build/libcombat.* (library) -bin/ludic build examples/games/snake.ludic --headless # -> build/snake_headless (out.ppm) -bin/ludic build examples/games/snake.ludic --web # -> build/web/ (browser) +bin/x app examples/games/snake.ludic # -> build/snake (native) +bin/x app examples/library/combat.ludic --lib # -> build/libcombat.* (library) +bin/x app examples/games/snake.ludic --headless # -> build/snake_headless (out.ppm) +bin/x app examples/games/snake.ludic --web # -> build/web/ (browser) ``` The `--lib` and `--web` targets were part of the old C driver and are **not yet -re-implemented** on the self-hosted toolchain — `bin/ludic build` supports the native +re-implemented** on the self-hosted toolchain — `bin/x app` supports the native windowed and `--headless` builds today. ## Programs and libraries > **Not yet on the self-hosted toolchain.** `--shared` and the `nm`/library > workflow below describe the old C driver's behavior; the self-hosted `ludicc` -> builds executables only for now. The `@export function` semantics are +> builds executables only for now. The `module`/`@export fn` semantics are > unchanged — only the packaging step is pending. -A source file opens with `program Name { … }`. +A source file opens with `game Name { … }` or `module Name { … }`. -* A program with **handlers** is a game: it gets the phase-ordered frame loop +* A **game** gets an entry point and the phase-ordered frame loop (`Start`, then `Input → FixedUpdate → Update → LateUpdate → Render` each tick). -* A program with only an **`entry`** block is a tool: it runs `entry` and exits. -* Either kind can be a library: only its `@export function`s become public - symbols; everything else stays private. +* A **module** gets neither. It is a library, and only its `@export fn`s become + public symbols; everything else stays private to the library. ```ludic # doc-check: skip — illustrative: elided body @@ -209,11 +196,8 @@ intrinsics compile to nothing there, so a headless binary never references a symbol the window would have provided. Other platforms build headless today. A Win32 or X11 port is another `.ll` file -with the same entry points — the window (`win_open`, `win_poll`, `win_present`, -`win_running`, `win_close`), keys (`win_held`, `win_held_bit`), the mouse and -cursor (`win_mouse`, `win_cursor_mode`, `win_cursor_confine`, -`win_cursor_maintain`), gamepad (`win_pad`) and touch (`win_touch`) — and no -compiler change. +with the same five entry points — `win_open`, `win_poll`, `win_present`, +`win_running`, `win_close` — and no compiler change. ## The web @@ -235,7 +219,7 @@ only the triple changes. ``` ```bash -bin/ludic build examples/games/chronorift.ludic --web +bin/x app examples/games/chronorift.ludic --web python3 -m http.server -d build/web 8000 # then open http://localhost:8000/ ``` @@ -323,7 +307,7 @@ node tools/ludic-web/run.mjs build/web/snake_headless.wasm --stdin=ddss ``` Because Ludic is fixed-point and its RNG is seeded, the native headless binary -and the wasm one must render byte-identical frames from the same input. `bin/ludic-dev test` +and the wasm one must render byte-identical frames from the same input. `bin/x test` asserts exactly that, which is a much stronger check on the backend than "it started". @@ -337,13 +321,13 @@ entity allocator, save/load snapshots, the frame loop, the window, and the whole graphics stack — framebuffer, PNG decoding, sprites, 9-slice, TrueType text and the retained UI. -None of it goes through C. `bin/ludic-dev test` asserts that directly: no C source +None of it goes through C. `bin/x test` asserts that directly: no C source survives in `runtime/`, no C emitter survives in `ludicc`, and the examples all build, run and render from IR alone. ## Every flag -The self-hosted `ludicc`/`ludic` (built with `bin/ludic-dev build-cli`) accept: +The self-hosted `ludicc`/`ludic` (built with `bin/x build-cli`) accept: ``` the program to compile (first non-flag argument) @@ -353,18 +337,15 @@ The self-hosted `ludicc`/`ludic` (built with `bin/ludic-dev build-cli`) accept: --windowed force a windowed (Cocoa) build --headless force a headless build (stdin input, out.ppm output) --emit-llvm stop at LLVM IR — write it and exit, no clang - --check every check a build makes (types, modules, uses, layers, ports, binds); write nothing --fmt lex + parse only; exit 0 if it parses, 1 on a parse error (the check-docs gate; canonical formatting not yet restored) --save-temps keep the intermediate .ll - --run compile then run (what `ludic run` uses) + --run compile then run (implicit when invoked as `ludic`) (unknown -flags are ignored with a warning, never taken as the input file) environment: LUDIC_CC the LLVM that assembles IR and drives the linker (clang) - LUDIC_HOME the install root — runtime/, packages/, VERSION - (default: the parent of the binary's bin/ directory) - LUDIC_MODULES the project's fetched packages (default: ./ludic_modules) + LUDIC_HOME where runtime/native/ lives (default: the binary's dir) ``` Mode is automatic when neither `--windowed` nor `--headless` is given: a program diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6d0c971..0167418b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,25 +18,18 @@ runtime, and the tooling are all written in Ludic and built by Ludic. From a clean checkout, one line lifts the toolchain off the seed: ```bash -mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc -bin/ludicc --unsafe --globals tools/ludic-cli/dev.ludic -o bin/ludic-dev +clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x ``` -That gives you `bin/ludic-dev`, the contributor tool: it replaces every -build/test shell script in the repo and builds everything, including itself and -`bin/ludic`. It is deliberately a separate binary from the `ludic` users install -— that one carries none of these tasks and is never asked to. +That gives you `bin/x`, the Ludic task runner that replaces every build/test +shell script in the repo. From then on it builds everything — including itself: ```bash -bin/ludic-dev build # the whole toolchain into bin/ (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp) -bin/ludic-dev help # every contributor task -bin/ludic help # what a user of the language sees +bin/x build # rebuild the whole toolchain into bin/ (ludicc, ludic, x, ludic-fmt, ludic-lsp) +bin/x help # list every command ``` -Always run `ludic-dev` from the repository root, so `assets/` and `selfhost/` -resolve. (A checkout is also an install root: `bin/` beside `runtime/` and -`packages/`, exactly the shape `install.sh` lays down under `~/.ludic`, which is -why `bin/ludic` behaves there exactly as an installed one does.) +Always run `x` from the repository root, so `assets/` and `selfhost/` resolve. ## The development loop @@ -44,18 +37,18 @@ When you change the compiler or runtime, prove the self-hosting fixpoint still holds before you push: ```bash -bin/ludic-dev reseed # regenerate selfhost/ludicc.seed.ll after a compiler change -bin/ludic-dev bootstrap-cfree # rebuild the compiler from the seed with NO C compiler in the loop -bin/ludic-dev test # the full regression suite +bin/x reseed # regenerate selfhost/ludicc.seed.ll after a compiler change +bin/x bootstrap-cfree # rebuild the compiler from the seed with NO C compiler in the loop +bin/x test # the full regression suite ``` Other useful targets: ```bash -bin/ludic build [--headless] # compile a program to a native app in build/ -bin/ludic-dev selfhost-test # correctness + bootstrap fixpoints -bin/ludic-dev test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp) -bin/ludic clean # remove build/, out.ppm and stray artifacts +bin/x app [--headless] # compile a program to a native app in build/ +bin/x selfhost-test # correctness + bootstrap fixpoints +bin/x test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp) +bin/x clean # remove build/, out.ppm and stray artifacts ``` ## Adding to the standard library @@ -68,7 +61,7 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces and register its id in `tools/docgen/inventory.json`. Each documented namespace gets exactly **one** directory (the docs check enforces this). 3. Add or extend an example under `examples/` and a case in the test suite. -4. Run `bin/ludic-dev docs-gen --out build/pages && bin/ludic-dev docs-check build/pages` — the +4. Run `python3 tools/docgen/gen.py && python3 tools/docgen/check.py` — the check fails if any inventory symbol lacks a page or is still seed text. 5. Add a **changeset** for the user-facing change: a small file under [`changes/`](changes/README.md) with a `bump:` level and a one-line summary. @@ -77,105 +70,20 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces ## Versioning & releases The toolchain is versioned with [SemVer](https://semver.org); `VERSION` is the -single source of truth and `ludicc --version` (or `ludic version`) reports it. +single source of truth and `ludicc --version` (or `x version`) reports it. Releases are changeset-driven. Every user-facing change ships with a changeset -(step 5 above). Read the next release before cutting it: +(step 5 above). To cut a release: ```bash -ludic-dev release --dry-run # render the CHANGELOG section, write nothing +x release [major|minor|patch] # omit the level to derive it from the changesets ``` -Then cut it: - -```bash -ludic-dev release [major|minor|patch] # omit the level to derive it from the changesets -git push origin main --follow-tags -``` - -`ludic-dev release` aggregates the pending changesets into a new `CHANGELOG.md` section -— grouped by change type, with each changeset's markdown kept intact — bumps -`VERSION`, commits `chore(release): vX.Y.Z`, and tags it. - -**Pushing the tag is what publishes.** The `release` workflow builds the -toolchain from the IR seed, runs `ludic-dev test`, `ludic-dev test-tools` and `ludic-dev bootstrap-cfree` -against the tagged tree, and only then creates the Forgejo release — with the -source tarball, a Linux toolchain build, a `.sha256` beside each, and that version's -`CHANGELOG.md` section as the notes. It refuses to publish if the tag and -`VERSION` disagree or the changelog has no section for it. - -Each toolchain artifact is a complete install root — `bin/` beside `runtime/`, -`packages/` and `VERSION` — which is exactly what `install.sh` unpacks into -`~/.ludic`. A release with no artifact for a platform is not a broken install -there: the installer falls back to bootstrapping from the source tarball's IR -seed. But the macOS artifacts are the ones most people get, so attach them. - -macOS artifacts cannot be produced on the Linux runner — a `darwin-arm64` build -needs a macOS host, and there is no cross-compile path (it would need the Xcode -SDK and a Mach-O linker). Attaching one therefore means either registering a -macOS runner and giving it a job, or running the same command CI runs from a -Mac. Either way it is `ludic-dev publish`, which only adds assets the release is missing: - -```bash -FORGEJO_TOKEN=… ludic-dev publish v0.4.0 -``` - -Checksums are one `.sha256` file per artifact rather than a single `SHA256SUMS`, -precisely because a release can be assembled from more than one host and an -asset that already exists is never overwritten. Verify one with: - -```bash -shasum -a 256 -c ludic-0.4.0-src.tar.gz.sha256 -``` - -The tag doubles as the reproducible bootstrap point: the source archive plus its -checked-in seed rebuild that exact toolchain. - -## Where the name and the URLs live - -The language may yet be renamed and the project may yet move hosts, so the -things that carry a name are kept few and listed here rather than discovered one -broken link at a time. Everything host-shaped has an environment override, so a -move can be rehearsed before it is committed. - -**Hosts and URLs.** The install one-liner is served from the documentation site, -which publishes `install.sh` beside the pages that quote it (`ludic-dev docs-gen` -copies it in; `docs-check` fails without it). Change the host in: - -| Where | What | -|---|---| -| `install.sh` | `REPO_API`, `REPO_URL`, `INSTALL_URL` — each `${LUDIC_…:-default}`, so `LUDIC_REPO_URL=… sh install.sh` tests a move without editing anything | -| `tools/ludic-cli/project.ludic` | `install_url()` (`$LUDIC_INSTALL_URL`), used by `ludic upgrade` and `ludic doctor` | -| `tools/ludic-cli/forgejo.ludic` | `FORGEJO_API_DEFAULT` (`$LUDIC_FORGEJO_API`), used by `ludic-dev publish` | -| `docs/site/site.json` | `repo_url`, the `start.terminal` one-liner, and the doc links in `nav_links` | -| Prose | `README.md`, `COMPILING.md`, `tools/editors/README.md`, and the two editor plugins' "server not found" messages | - -**The name itself.** A rename touches, in rough order of blast radius: - -- **The file extension** `.ludic` — the compiler (`strip_ludic`, `do_import`, - `is_ludic_file`), every editor asset (`tools/editors/shared/*.json`, - `vscode/package.json`, the JetBrains `LudicFileType`), and every source file - in the tree. -- **The binaries** `ludic`, `ludicc`, `ludic-dev`, `ludic-fmt`, `ludic-lsp` — - `cmd_dev_build` in `toolchain.ludic`, the release staging in `release.ludic`, - `install.sh`, the editors' executable-name lists. Only the first, third and - fourth of those ship: `ludic-dev` is built from a checkout and stays there. -- **The install root** `~/.ludic` and the source directories `tools/ludic-cli/`, - `tools/ludic-tools/`, `packages/ludic.*`. -- **The environment variables** `LUDIC_HOME`, `LUDIC_CC`, `LUDIC_MODULES`, - `LUDIC_STORE`, `LUDIC_PKG_PROXY`, `LUDIC_INSTALL_URL`, `LUDIC_KEEP_TMP`, - `LUDIC_COVERAGE` — keep the old names working for a release if anyone has them - in a script. -- **Identifiers that are contracts with other software**: the TextMate scope - `source.ludic`, the VS Code language id `ludic`, the JetBrains plugin id - `io.ludic.ide`, and the `ludic` code-fence tag understood by the Markdown - injection and by `ludic-dev check-docs`. -- **The prose**: `README.md`, `LANGUAGE.md`, `COMPILING.md`, `docs/**`, and - `docs/site/site.json`'s `brand`/`meta`. - -`ludic-dev test` is the safety net for the mechanical part — it builds the -toolchain, stages an install, and runs `new` → `build` → `test` through it, so a -half-finished rename fails there rather than in someone's terminal. +That aggregates the pending changesets into a new `CHANGELOG.md` section, bumps +`VERSION`, commits `chore(release): vX.Y.Z`, and tags it. Add `--publish` (with +`FORGEJO_TOKEN` set) to also push and create the Forgejo release with source and +toolchain tarballs. The tag doubles as the reproducible bootstrap point: the +source archive plus its checked-in seed rebuild that exact toolchain. ## Conventions @@ -191,7 +99,7 @@ half-finished rename fails there rather than in someone's terminal. | `perf` | a performance improvement | | `docs` | documentation only (`docs/`, README, comments) | | `test` | tests only | - | `build` | the build/bootstrap machinery (seed, `bin/ludic`, linking) | + | `build` | the build/bootstrap machinery (seed, `bin/x`, linking) | | `ci` | CI workflows under `.forgejo/` | | `style` | formatting/whitespace, no behaviour change | | `chore` | routine housekeeping with no other bucket | @@ -213,7 +121,7 @@ half-finished rename fails there rather than in someone's terminal. so a green local commit is a green CI run. - **Formatting:** `ludic-fmt` is the source of truth (2-space indent, LF, UTF-8); - the repo `.editorconfig` mirrors it. Run `bin/ludic fmt` on files you touch. + the repo `.editorconfig` mirrors it. Run `bin/ludic-fmt -w` on files you touch. The contract CI enforces is *idempotence* — `ludic-fmt` re-run on its own output is a no-op — which leaves deliberate hand alignment in place; it is not a blanket `fmt(x) == x`. @@ -241,44 +149,11 @@ non-destructive version of "tidy the history" without touching a single commit. ## Pull requests - Base your branch on `main`. -- Ensure `bin/ludic-dev test` (and `bin/ludic-dev bootstrap-cfree` for compiler/runtime changes) +- Ensure `bin/x test` (and `bin/x bootstrap-cfree` for compiler/runtime changes) pass, and that `ludic-fmt` leaves your files unchanged. - Fill in the PR template checklist. Reference the issue you close with `Closes #NN` in the description or a commit message. -## CI (self-hosted runners) - -Every workflow starts by cloning `${{ github.server_url }}/${{ github.repository }}`. -On a self-hosted Forgejo runner that URL is usually the instance's *internal* -address (e.g. `http://forgejo:3000`), so **the job container must be able to -resolve it**. The runner puts each job on a fresh per-job network by default, -which the Forgejo container is not attached to — so the clone fails with: - -``` -fatal: unable to access 'http://forgejo:3000/…': Could not resolve host: forgejo -``` - -Give the runner a config that pins job containers to a network Forgejo is also -on. A dedicated network is better than the general application network, so a CI -job cannot reach unrelated services: - -```yaml -# the runner's config.yml, passed with: forgejo-runner daemon --config … -container: - network: forgejo-ci -``` - -with `forgejo-ci` attached to the Forgejo container as well. Verify it without -running a workflow: - -```bash -docker run --rm --network forgejo-ci alpine:3 getent hosts forgejo -``` - -This failure mode is intermittent if left unfixed: Docker forwards names it -cannot resolve to the host's resolver, which may answer for the container name -often enough that CI looks healthy for a while. - ## Reporting issues Use the templates under [`.forgejo/issue_template/`](.forgejo/issue_template): diff --git a/EVENTS-DESIGN.md b/EVENTS-DESIGN.md new file mode 100644 index 00000000..4ff4d4c2 --- /dev/null +++ b/EVENTS-DESIGN.md @@ -0,0 +1,670 @@ +# Events & modding, expanded — a design doc + +> **Status: EV0 fully shipped; EV1 (spawn/despawn), EV2 (first cut) and EV3 +> shipped; EV4–EV7 are design.** Implemented, self-hosted to the C-free fixpoint, +> and each a `bin/x test` check: +> - **EV0** — `event`/`@On`/`emit` lowered to `@ev_` dispatch (compile-time +> listeners), **plus the foreign C ABI** (`ludic_on_`, the `%Ev_` payload +> struct, a fixed-capacity listener array), proven by a C mod in +> [`tests/mod_c/mod.c`](tests/mod_c/mod.c) binding +> [`examples/mod_host.ludic`](examples/mod_host.ludic). Byte-identical when no +> event is declared. ([`examples/events/events.ludic`](examples/events/events.ludic)) +> - **EV1** — public events across the **whole architecture**, every scope shipped: +> **program** (`@Public @OnStart`/`@OnQuit` → `program_start`/`program_quit`, +> [`examples/events/program_events.ludic`](examples/events/program_events.ludic)); **models** +> (`@Public @OnSpawn`/`@OnDespawn` → `model__spawn`/`_despawn`, +> [`examples/events/promote.ludic`](examples/events/promote.ludic)); **properties** (`@Public +> @OnAttach`/`@OnDetach`/`@OnEnable`/`@OnDisable` → `prop_

_attach` etc., +> [`examples/events/prop_events.ludic`](examples/events/prop_events.ludic)); **scenes** (a +> `public` scene → `scene__enter`/`_exit`, +> [`examples/events/scene_events.ludic`](examples/events/scene_events.ludic)); and **layers** (a +> `public` layer + `enable/disable layer L` → `layer__show`/`_hide`, +> [`examples/events/layer_events.ludic`](examples/events/layer_events.ludic)) — which also +> landed **SCENES E2 layer toggle** (`@LE_` flag gating a layer's handlers). +> - **EV2 / EV2b** — the world table: the reflection ABI, generated from the +> compile-time schema, so a mod reads, writes, scans, identifies, **and creates** +> entity state **by name** without compiling against the game. `ludic_prop_id` / +> `ludic_field_id` / `ludic_get` / `ludic_set` / `ludic_has` (read/write — +> [`world_mod.c`](tests/mod_c/world_mod.c)); `ludic_entity_count` / `ludic_kind` / +> `ludic_model_id` (scan and identify — [`world_scan.c`](tests/mod_c/world_scan.c)); +> `ludic_spawn(model_id)` (create, reusing the compiler's own spawn lowering — +> [`world_spawn.c`](tests/mod_c/world_spawn.c)); `get`/`set` address each field by +> its real struct offset, correct for `int`/`fixed`/`byte`/`ptr` and mixed layouts +> ([`world_mixed.c`](tests/mod_c/world_mixed.c)); and iterate +> (`ludic_query_next`, [`world_query.c`](tests/mod_c/world_query.c)). Emitted only +> for an ECS program that declares events, so event-free games stay byte-exact. +> The world table is complete: read, write, scan, identify, create, iterate. +> - **EV3** — `cancellable` events, the `cancel` verb, and `emit E(…)` as an +> expression returning the veto flag. ([`examples/events/cancel.ludic`](examples/events/cancel.ludic)) +> - **EV5** — leak-proof scoped listeners: `ludic_off_(token)` (explicit +> unregister; dispatch skips tombstoned slots), `ludic_on_entity_(entity, cb)` +> (entity-scoped), and a generated `ludic_sweep_entity` called from `despawn` that +> nulls every listener the dying entity owned — a listener can't leak past its +> entity. Proven by [`tests/mod_c/scoped_mod.c`](tests/mod_c/scoped_mod.c). +> - **EV6** — re-entrant `emit` is depth-bounded (`@ev_depth` vs `EV_DEPTH_CAP`): a +> listener may emit another event, but an event cycle traps as an early return +> instead of hanging the frame. Dispatch order was already deterministic (array, +> registration order). Proven by [`examples/events/recurse.ludic`](examples/events/recurse.ludic). +> +> - **EV7 (schema opening)** — a mod defines a brand-new component at runtime: +> `ludic_register_prop(name, nfields)` mallocs flat `[MAX_ENT × nfields × i32]` +> storage + a has-flag array and returns a prop id past the compile-time range; +> `ludic_attach_dyn`/`ludic_detach_dyn` toggle it on an entity; `get`/`set`/`has`/ +> `prop_id` fall through to the dynamic registry for ids ≥ the compile-time count. +> A mod adds entirely new data to entities by name, with per-entity isolation. +> Proven by [`tests/mod_c/world_dyn.c`](tests/mod_c/world_dyn.c). (EV7's other +> half — networking's local/remote event split — has no substrate in Ludic yet.) +> +> Still design: EV4 (the scripting-shim bridge — deferred to keep the suite +> interpreter-free) and EV7 networking. This is a companion to +> [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md) and [SCENES-DESIGN.md](SCENES-DESIGN.md). +> Where those docs extend Ludic's *internal, compile-time* lifecycle, this one +> proposes the *external, runtime* layer that turns those same lifecycle moments +> into a public event surface — the foundation a game can hand to mods written in +> Ludic, JS/TS, Lua, or anything with a C ABI. It distills a survey of modding and +> event systems (§3) into a phased roadmap (EV0–EV7, §12–§13). §14 lists the open +> decisions. + +--- + +## 1. Thesis + +Ludic already has a lifecycle. `@OnSpawn(Enemy)`, `@OnDetach(Sprite)`, scene +`on enter`, `@OnDespawn(M, reason: r)` — every one is a **compile-time, +closed-world, zero-cost** hook that desugars to a direct call at a fixed site. +That is the right design for the *game author*, who is compiled together with the +game. It is exactly the wrong design for a *mod author*, who is not. + +A modding event system is the mirror image of the lifecycle layer along three axes: + +| | Lifecycle hooks (today) | Modding events (this doc) | +|---|---|---| +| World | **closed** — all handlers known at compile time | **open** — mods add listeners after compilation | +| Binding | **static** — a checked symbol, a direct call | **dynamic** — registered at load, dispatched at runtime | +| Language | **in-language** — Ludic, compiled together | **cross-language** — JS/TS/Lua/native over an ABI | + +The instinct would be to build a second, parallel system. **The design that keeps +Ludic's discipline builds one system seen from two sides.** A lifecycle hook is a +*private* view of a moment; a public event is the *same moment* exposed across the +ABI. The author promotes a hook to an event; the compiler keeps its zero-cost +direct calls **and** emits one guarded `bus_emit` at the very same site. Nothing +exposed → nothing emitted → goldens stay byte-identical, exactly like `has_ecs` +and the `g_ondespawn` shutdown walk. + +**The Luanti dividend.** The gap analysis (`LUANTI-ROADMAP.md`) found that ~57k of +Luanti's lines exist only to bridge C++ and Lua, and that its mod predicates are +*runtime strings* it must re-interpret every call. Ludic pays neither tax. The +reflection surface a mod needs — "what properties exist, what fields, at what +offsets" — is a **compile-time fact**; the compiler can *generate* the bridge +instead of a human hand-writing 57k lines, and it is always in sync with the game +it describes. A mod itself written in Ludic and compiled to a shared library binds +that surface with **zero marshalling**; a Lua mod binds the same surface through +its FFI. One ABI, every language. + +--- + +## 2. What Ludic has today, and why it can't reach a mod + +The lifecycle table from [LIFECYCLE-DESIGN.md §2](LIFECYCLE-DESIGN.md), every cell +filled, every cell a zero-cost desugar: + +| Scope | Setup hook | Teardown hook | Fire site the compiler already owns | +|---|---|---|---| +| program | `@OnStart` | `@OnQuit` | boot / shutdown | +| entity | `@OnSpawn(M)` | `@OnDespawn(M, reason)` | `spawn` / `despawn` / shutdown-walk | +| property (structural) | `@OnAttach(P)` | `@OnDetach(P)` | `attach` / `detach` | +| property (toggle) | `@OnEnable(P)` | `@OnDisable(P)` | `enable` / `disable` | +| scene | `on enter` | `on exit` | `become` (and `push`/`pop`, SCENES E3) | + +Two more fire sites are proposed but unbuilt, and both are natural events: +`@OnChange(P)` (LC2 — a value-change hook the compiler can emit right after every +write site) and `@OnStartMatch`/`@OnStopMatch` (LC3 — query-membership edges). + +Every one of these is a place the compiler **already writes a call**. The problem +is purely that the call is *closed*: its targets are fixed at compile time, so a +mod loaded at runtime has no way to be one of them. The entire job of this doc is +to add, at each of these sites, an **opt-in second exit** to an open runtime list — +without touching the closed path's cost when no one opts in. + +What a mod additionally needs, that no hook provides: + +- a **stable name** for each event that survives recompilation (a mod compiled + against v1 must still bind in v1.1); +- a way to **read and write game state** it did not compile against (the world + table, §9); +- a way to **veto or rewrite** an action before it commits, not just observe it + after (cancellable events, §8); +- a **loader** — mods enable, disable, and unload, and their listeners must vanish + cleanly when they do (§10). + +--- + +## 3. Research digest — the one idea to steal from each + +The lifecycle doc surveyed engines for *internal* lifecycle. This surveys systems +for their *modding and event* surface — how untrusted, separately-authored code +plugs into a running game. + +| System | The transferable idea | +|---|---| +| **Bukkit / Spigot** (Minecraft) | The canonical **cancellable event**: `Cancellable.setCancelled(true)` vetoes the action; `EventPriority` orders listeners; `@EventHandler(ignoreCancelled=true)` opts out of already-vetoed events. Events are *classes*, checked at bind time — not strings. | +| **Fabric** (Minecraft) | `Event` backed by an **invoker over a plain array** of callbacks — deterministic registration order, no reflection at dispatch, phases for ordering. The closest existing design to what Ludic wants: fast, ordered, array-backed. | +| **Factorio** | **Deterministic** modded events for multiplayer lockstep: `script.on_event(defines.events.X)`, numeric event ids, `raise_event` for custom events, **filtered** subscriptions. Proof that a heavily-modded game can still replay bit-for-bit. | +| **Minetest / Luanti** | `register_on_*` + a string-keyed global (`minetest.*`) world API. The thing to beat: its predicates are runtime strings, and its C++↔Lua bridge is 57k hand-written lines. | +| **Godot** | **Signals as a first-class language construct**: `signal hurt(amount)`, `emit_signal`, `connect`. Decoupled, per-object, declared where the data lives. | +| **DOM events** | The **two-phase dispatch** vocabulary: capture → target → bubble, `preventDefault` (veto the default action) vs `stopPropagation` (halt the chain), and *passive* listeners that promise not to cancel (so dispatch can skip the veto check). | +| **Node `EventEmitter`** | The dead-simple baseline `on`/`emit` — and its footguns: untyped string names (a typo silently never fires) and **listener leaks** (a listener on a dead object keeps it alive). Design both out. | +| **flecs / Bevy observers** | **ECS-native reactive events**: an event *targeted at an entity*, observers that fire on component add/set/remove, deferred so mutation-during-iteration is safe. The correct shape for an ECS. | +| **Blender `bpy.app.handlers`** | Named application-level handler lists a script appends to, with a `persistent` flag controlling survival across file loads — the "engine lifecycle exposed to scripts" model, and the lesson that *survival scope* must be explicit. | +| **Roblox** | `BindableEvent` (local) vs `RemoteEvent` (across the network boundary) — the same event abstraction, one flag deciding whether it crosses a trust/latency boundary. Relevant the day Ludic has networking. | + +Five **footguns** the survey warns against, to design *out* of Ludic from the start: + +1. **Untyped string events.** Node/DOM let any string be an event; a typo never + fires and never errors. Ludic's core events are compiler-checked symbols; only + genuinely-dynamic *mod-defined* events use interned strings, and those must be + *registered* before use (§6), so an unknown name is a load-time error, not a + silent no-op. +2. **Listener leaks.** A listener bound to an entity that despawns must die with + it. Ludic ties listener lifetime to the scope it names (§10) — entity-scoped + listeners are swept by the same despawn walk that already runs. +3. **Nondeterministic dispatch order.** Hash-map iteration over listeners breaks + replay and save-load. Ludic dispatches in a **defined order** (priority, then + registration order) so a modded game stays deterministic — a hard constraint, + not a nicety, given Ludic's deterministic-by-design rng and byte-identical + goldens. +4. **Re-entrancy / mutate-during-dispatch.** A listener that emits another event, + or despawns the entity mid-dispatch, is the flecs "command during iteration" + hazard. Ludic defers structural changes made inside dispatch to the next sync + point (ties to LIFECYCLE LC5), and bounds re-entrant emit depth. +5. **Cancellation ambiguity.** If two listeners disagree, who wins? Ludic's rule + (§8): **one veto wins and is sticky**; later listeners see the cancelled state + and, unless they opted into `ignoreCancelled`, are skipped. + +--- + +## 4. The two layers, named + +To talk about this precisely the doc fixes two words: + +- A **hook** is the existing compile-time construct: an `@`-annotation or scene + clause that desugars to a direct call. Closed, zero-cost, author-only. Unchanged. +- An **event** is the new runtime construct: a named, ABI-visible moment that any + registered listener — in any language — may observe or (if cancellable) veto. + +An event is *fed by* a hook site. Promoting is additive: the hook keeps firing its +compile-time listeners as direct calls; the event is an extra, guarded emission at +the same site. **Author code never pays for the bus it doesn't expose, and mod +code never sees a hook it wasn't given.** + +--- + +## 5. EV0 — the event bus core + +The minimum viable layer: declare an event, emit it, and have both in-language and +foreign listeners receive it — with zero cost when a program declares no events. + +**Declaring a custom event.** A first-class declaration, mirroring `property`: + +```ludic +# doc-check: skip — sketch +event PlayerHurt { entity: int, amount: int } # a payload is a flat POD record +event WaveCleared { } # payloads may be empty +``` + +**Emitting.** A statement, mirroring `spawn`/`emit_signal`: + +```ludic +# doc-check: skip — sketch +emit PlayerHurt(entity: e, amount: dmg) +``` + +**Listening in-language** (author code, or a *native* Ludic mod) reuses the +annotation channel, mirroring `@OnSpawn`: + +```ludic +# doc-check: skip — sketch +@On(PlayerHurt) handler FlashRed { hud_flash(0xFF0000) } +``` + +**Listening across the ABI** (a JS/TS/Lua mod) goes through the stable C ABI: + +```c +/* the entire foreign-facing event ABI — four functions */ +uint32_t ludic_event_id(const char *name); /* intern → stable id */ +uint32_t ludic_on(uint32_t event, int32_t prio, ludic_cb cb, void *ctx); +void ludic_off(uint32_t token); +void ludic_emit(uint32_t event, void *payload); /* mod-raised events */ +/* cb: void (*)(void *ctx, void *payload) — payload is the flat POD record */ +``` + +**Lowering — the discipline holds.** An exposed event's emit site becomes: + +``` +; emit PlayerHurt(entity: e, amount: dmg) lowers to: + 1. build the payload record on the stack (POD, no heap) + 2. call each compile-time @On(PlayerHurt) handler directly ; zero-cost path + 3. if g_listeners[EV_PlayerHurt].count != 0: ; one branch + loop the runtime listener list, calling each cb(ctx, &payload) +``` + +- **A program that declares no `event` emits none of this.** A `has_events` flag + (exactly like `has_ecs`, `g_ondespawn`) gates the whole subsystem; a game with no + public events is byte-for-byte identical to today. This is the non-negotiable + invariant every phase preserves. +- The compile-time `@On` handlers are direct calls appended to the site — a native + listener costs the same as a lifecycle hook. Only *foreign* listeners walk the + runtime list, and an event with zero foreign listeners is a single count check. +- The runtime list is a **compiler-owned, fixed-capacity buffer** per event + (like the scene stack in SCENES E3) — not heap, not a hash map. `ludic_on` is an + index bump; `ludic_off` tombstones a slot. Deterministic order falls out of the + array (§7 of SCENES' "no dispatch tables" spirit, honestly bent — see §11). + +--- + +## 6. EV1 — promoting hooks to events (the taxonomy) + +Custom `event`s (EV0) cover author-raised signals. The **lifecycle** events — +spawn, despawn, attach, scene enter — should not require the author to hand-write +an `emit` in every `@OnSpawn`. Instead, a hook is promoted with one annotation: + +```ludic +# doc-check: skip — sketch +@Public @OnSpawn(Enemy) handler Init { Health.hp = Health.max } +# now firing this hook ALSO emits the public event model.Enemy.spawn +``` + +`@Public` on a lifecycle hook tells the compiler to add the guarded `bus_emit` at +that hook's existing site, with a **generated payload** built from what the hook +already binds (the entity id, the model/property fields, the `EndReason`). The +result is a uniform event namespace across the whole architecture — precisely the +"events for properties, models, scenes, layers, game" the request asks for: + +| Scope | Public event name | Payload | Fed by | +|---|---|---|---| +| program | `program.start` / `program.quit` | `{}` | `@OnStart` / `@OnQuit` | +| phase | `phase..pre` / `.post` | `{ frame }` | the phase scheduler | +| model | `model..spawn` / `.despawn` | `{ entity, reason? }` | `@OnSpawn` / `@OnDespawn` | +| property (structural) | `prop.

.attach` / `.detach` | `{ entity, }` | `@OnAttach` / `@OnDetach` | +| property (toggle) | `prop.

.enable` / `.disable` | `{ entity }` | `@OnEnable` / `@OnDisable` | +| property (value) | `prop.

.change` | `{ entity, field, old, new }` | `@OnChange` (LC2) | +| query (membership) | `query..enter` / `.exit` | `{ entity }` | `@OnStartMatch`/`@OnStopMatch` (LC3) | +| scene | `scene..enter` / `.exit` / `.push` / `.pop` | `{}` | `on enter`/`on exit`, `push`/`pop` | +| layer | `layer..show` / `.hide` | `{}` | layer toggle (SCENES E2) | + +- **Names are stable strings, ids are fast integers.** `model.Enemy.spawn` is the + public contract; the compiler assigns it a numeric id and registers the mapping + in a generated init. A mod compiled against the string binds by id at load — so + reordering declarations doesn't break a shipped mod (unlike raw + decl-order numbering, which is fine for the *closed* scene machine but wrong for + an *open* ABI). +- **Opt-in per hook, not global.** Only `@Public` hooks emit. A game exposes the + slice of its lifecycle it wants moddable and pays for nothing else. +- **`@Public` composes with everything.** A `@Public @OnDespawn(Enemy, reason: r)` + emits `model.Enemy.despawn` with the `EndReason` in the payload — mods can tell a + scene-exit death from a real one, the LC1 dividend extended to the mod boundary. + +--- + +## 7. EV2 — the world table (reflection for mods) + +The user's "game table": the stable, versioned surface a mod uses to **read and +write game state it never compiled against**. Minetest's `minetest.*`, Factorio's +`game.*`, but *generated* rather than hand-written. + +Because Ludic's data is packed POD in `@S_` arrays whose layout the compiler knows +exactly, the compiler can emit a **schema** (property id → field ids → offset + +type) plus a small accessor ABI over it: + +```c +/* the world table — reflection + mutation over the live ECS */ +uint32_t ludic_prop_id(const char *name); /* "Health" → id */ +uint32_t ludic_field_id(uint32_t prop, const char *name); /* ("Health","hp")→id */ +int64_t ludic_get(int32_t entity, uint32_t prop, uint32_t field); +void ludic_set(int32_t entity, uint32_t prop, uint32_t field, int64_t v); +bool ludic_has(int32_t entity, uint32_t prop); +int32_t ludic_spawn(uint32_t model); /* → entity */ +void ludic_despawn(int32_t entity); +uint32_t ludic_query(uint32_t *props, int n); /* → iterator handle */ +int32_t ludic_query_next(uint32_t iter); /* → entity or -1 */ +``` + +- **Generated from the compile-time schema, so it never drifts.** Add a field to + `Health`, recompile, and the schema updates; a mod that asked for + `("Health","hp")` still resolves. This is the entire Luanti bridge, minus the + hand-written 57k lines and minus the runtime-string re-interpretation. +- **`ludic_set` respects the semantic layer.** Writing a field routes through the + same path a native write does, so `@OnChange`/`prop.change` (LC2) fires for a + mod's write exactly as for the author's — mods can't silently corrupt invariants + that hooks are meant to maintain. +- **Mods can register content, within limits.** A mod may `ludic_on` existing + events and `ludic_emit` custom ones; **defining a new `property`/`model` is a + harder call** (it needs storage the closed `@S_` arrays didn't reserve). The + pragmatic first cut: models and properties are closed (author-defined), and mods + extend *behavior* (listeners, custom events, world reads/writes) but not the + *schema*. Opening the schema to mods is EV-late (§13, open decision 4). + +--- + +## 8. EV3 — cancellable and mutable events + +Observation alone (Node, Blender) can't stop a mod from turning damage off — the +modding headline is that a listener runs **before** the action and can veto or +rewrite it. Events split into two kinds, distinguished at declaration: + +- **notifications** — fired *after* the fact, observe-only, can't change anything. + Cheap, un-ordered-safe, the default. `model.Enemy.spawn` after the spawn. +- **decisions** — fired *before* the action, listeners may **cancel** it or + **mutate** the payload; the caller reads the verdict and branches. Marked + `cancellable` (Bukkit `Cancellable`, DOM `preventDefault`). + +```ludic +# doc-check: skip — sketch +event cancellable BeforeHurt { entity: int, amount: int } # a decision event + +# an author (or native mod) listener that halves fire damage and vetoes lethal hits: +@On(BeforeHurt, prio: 100) handler Armor { + BeforeHurt.amount = BeforeHurt.amount / 2 # mutate the payload… + if BeforeHurt.amount >= Health.hp { cancel } # …or veto the whole action +} + +# the fire site consults the verdict: +let dmg = emit? BeforeHurt(entity: e, amount: raw) # emit? returns the (maybe-mutated) payload +if !cancelled(dmg) { Health.hp -= dmg.amount } +``` + +Rules, chosen from the survey to remove the ambiguity footgun: + +- **Priority, then registration order.** `prio:` (default 0) orders listeners + high-to-low; ties break by registration order. Deterministic, replay-safe. +- **One veto wins and is sticky.** Once a listener calls `cancel`, the event is + cancelled for the rest of the chain; later listeners still run (so they can react + to the cancellation) unless declared `ignoreCancelled`, which skips them. +- **`stopPropagation` is separate from `cancel`.** DOM's distinction: `cancel` + vetoes the *action*, `halt` stops the *chain*. Keep both; they answer different + questions. +- **Passive listeners.** A listener declared `@On(E, passive)` promises not to + cancel or mutate — the dispatcher can call it after the decision is settled, and + a foreign listener that lies is a load-time capability error (§10), not a + mid-frame surprise. +- **Mutation is bounded to the payload.** A decision listener rewrites *the payload + record*, never arbitrary world state, so the caller's branch is the only place + the change takes effect — no spooky action at a distance. + +--- + +## 9. EV4 — the mod ABI & the language-agnostic bridge + +"Agnostic JS/TS/Lua or their own" resolves cleanly once EV0–EV3 exist, because the +contract is **the C ABI, not any one language.** Two mod tiers bind the *same* four +event functions (§5) and the same world table (§7): + +**Tier 1 — native mods (Ludic → shared library).** A mod is a `.ludic` file +compiled to a `.dylib`/`.so`/`.wasm` with `extern fn` bindings +([LANGUAGE.md §Functions & FFI](LANGUAGE.md)). It binds the ABI with **zero +marshalling** — payloads are the same POD records the host builds — and its `@On` +handlers can even be *inlined by the same compiler* if the mod is compiled with the +game. This is the tier Luanti can't offer and the one that makes Ludic's modding +fast: a compiled predicate where Luanti has a re-interpreted string. + +**Tier 2 — scripted mods (JS/TS/Lua/…).** The game embeds a scripting runtime +(QuickJS, Lua, Wasm) and registers a thin per-language shim that: + +1. calls `ludic_event_id("model.Enemy.spawn")` once at load to resolve the id; +2. calls `ludic_on(id, prio, trampoline, script_fn)` where `trampoline` is a + single C function that marshals the POD payload into the script runtime's values + and invokes `script_fn`; +3. exposes the world table (§7) as idiomatic bindings (`world.get(e, "Health", + "hp")` in Lua, `world.get(e, "Health", "hp")` in TS). + +The host writes **one trampoline per language**, not one per event — the schema +(§7) drives the marshalling generically. A Lua mod and a TS mod differ only in +their shim; the game core is identical. This is the structural win the Luanti gap +analysis pointed at: the bridge cost is *O(languages)*, not *O(events × languages)* +hand-written, because the schema is generated. + +``` + ┌─────────────── the stable C ABI ───────────────� + Ludic game core ──────┤ ludic_on / ludic_emit / ludic_get / ludic_set ├────── generated schema + (emits at hook sites) └────────────────────┬───────────────────────────┘ (prop→field→offset) + │ + ┌────────────────────────────────┼────────────────────────────────� + │ │ │ + Tier 1: native mod Tier 2: Lua shim Tier 2: JS/TS shim + (.dylib, zero marshalling) (one trampoline) (one trampoline) +``` + +--- + +## 10. EV5 — mod lifecycle, scoping & leak-proofing + +A mod is not eternal; it loads, enables, disables, and unloads, and its listeners +must vanish with it — the Node listener-leak footgun, solved structurally. + +- **Every registration returns a token** (`ludic_on → token`), and a mod's tokens + are tracked under its **mod handle**. Unloading a mod calls `ludic_off` on all of + them at once — a mod can't leak a listener past its own life. +- **Listeners may be scoped to a game object.** `ludic_on_entity(entity, …)` binds + a listener that the **existing despawn walk** sweeps when that entity dies — the + same `@L_despawn_all` loop LC1 already emits, extended to drop entity-scoped + listeners. An entity-scoped listener on a dead entity is impossible by + construction, not by discipline. +- **Scene-scoped listeners** ride SCENES E1: a listener registered while a scene is + active is dropped by that scene's synthesized `on exit`, alongside its owned + entities. Overlay push/pop (SCENES E3) scopes listeners to the overlay's life. +- **Survival is explicit** (Blender's `persistent` lesson): a listener is + program-, mod-, scene-, or entity-scoped, chosen at registration. There is no + implicit "lives forever" — the default is the narrowest scope that makes sense + (mod), and wider survival is opt-in and visible. +- **Capabilities gate what a scripted mod may touch** (§14, open decision 6). A mod + manifest declares the events and world-table properties it needs; the loader + grants ids only for those. A mod that never asked for `Health` cannot `ludic_set` + it — an untrusted-code boundary the closed lifecycle layer never needed but an + open mod ABI must have. + +--- + +## 11. EV6 — determinism, re-entrancy & the one honest compromise + +Ludic is deterministic by design — deterministic rng, byte-identical PPM goldens, +save-load of the whole World. A modding layer is the classic place that determinism +goes to die (hash-ordered listeners, mods reading wall-clock, emit storms). Holding +the line is a **feature**, and the same one that makes Factorio's modded multiplayer +lockstep-correct. + +- **Dispatch order is total and defined** — priority, then registration order, over + an *array*, never a hash map. Two mods loaded in the same order dispatch in the + same order on every machine. +- **Emit is synchronous by default, deferred on demand.** `emit E` runs listeners + now (push-at-the-site, Ludic's natural style — the LIFECYCLE footgun-1 fix). + Structural changes a listener requests (spawn/despawn/attach) **defer to the next + sync point** (LIFECYCLE LC5's `defer`), so mutate-during-dispatch is safe and + batched. Re-entrant `emit` inside a listener is allowed but **depth-bounded** (a + compile-time cap, trap on overflow) so an event cycle can't hang a frame. +- **Foreign listeners are the determinism boundary.** A native (Tier 1) listener is + as deterministic as any handler. A scripted (Tier 2) listener is only as + deterministic as the script — so the sandbox (§10) can **deny nondeterministic + capabilities** (wall-clock, unseeded rng, filesystem) to a mod that must stay in + a deterministic session (multiplayer, replays). Single-player mods can opt out. + +**The one honest compromise.** SCENES-DESIGN's principle is "no dispatch tables — +the active-scene path is a register read and a static branch." The runtime +listener list *is* a dispatch table, walked at runtime. This doc owns that: it is +the **deliberate, opt-in exception**, justified because open-world extension is the +entire point of a mod ABI and cannot be resolved at compile time by definition. +The mitigations keep it honest — it is (a) gated behind `has_events` so unused it +costs nothing, (b) an array not a hash map so it stays deterministic, (c) fed by +compile-time-checked names so the *closed* side stays typed, and (d) reached only +after the zero-cost direct calls to compile-time `@On` handlers. Ludic pays for a +dispatch table exactly when, and only when, a game chooses to be moddable. + +--- + +## 12. Lowering summary + +Everything above reduces to constructs Ludic already has or honestly-scoped +additions to them: + +| Construct | Lowers to | +|---|---| +| `event E { … }` | a generated payload record type + a reserved event id + a `has_events` bump | +| `emit E(…)` | build POD payload · direct-call each `@On(E)` handler · `if count: walk runtime list` | +| `@On(E)` handler | a compile-time listener: a direct call appended to `E`'s emit site (zero-cost) | +| `@Public @OnX(…)` | the existing hook's site, plus a guarded `bus_emit` of a payload built from the hook's bindings | +| public event name | a stable string interned to an integer id in a generated registry init | +| the runtime listener list | a compiler-owned fixed-capacity array per event; `ludic_on` = index bump, `ludic_off` = tombstone | +| the world table | a generated schema (prop→field→offset/type) + accessor ABI over the live `@S_` arrays | +| `cancellable` / `cancel` | a verdict field on the payload; the emit site branches on it | +| entity/scene-scoped listener | dropped by the existing despawn walk / synthesized `on exit` (LC1 / SCENES E1) | +| deferred structural change in a listener | LIFECYCLE LC5's `defer` queue, flushed at the sync point | + +No heap for native payloads, no hash map, no per-event hand-written bridge. The +active game path is unchanged unless it opts in; the opt-in cost is one branch per +exposed event plus the listeners a mod actually registers. + +--- + +## 13. Design principles distilled + +1. **One system, two sides.** A public event is a lifecycle hook seen from across + the ABI. Don't build a parallel event runtime; promote the sites you already + have. +2. **Opt-in or invisible.** No `event`, no `@Public` → byte-identical goldens. + `has_events` gates the world the way `has_ecs` gates the ECS. +3. **Closed stays typed; only the open edge is dynamic.** Core events are + compiler-checked symbols; string names exist only at the genuinely-runtime mod + boundary, and even there must be registered (no silent typos). +4. **Generated bridge, never hand-written.** The world table and payload marshalling + come from the compile-time schema, so they never drift and cost O(languages), + not O(events × languages). This is the Luanti dividend — spend it. +5. **Deterministic dispatch is a feature.** Array order, not hash order; deny + nondeterministic capabilities to mods in deterministic sessions. Modded replay + and modded multiplayer depend on it. +6. **Lifetime follows scope, explicitly.** Every listener names its scope + (program/mod/scene/entity); the existing teardown walks sweep it. No implicit + immortality, no leaks. +7. **One ABI, every language.** The C ABI is the contract. Native mods bind it with + zero marshalling; scripted mods bind it through one trampoline per language. + Ludic never blesses a single scripting language. +8. **Only the semantic layer, still.** Mods observe and decide; they do not get + ctor/dtor/move hooks Ludic doesn't have. POD in, POD out. + +--- + +## 14. Suggested implementation order + +Each phase is independently shippable and testable, matching how the repo phases +work (and how LIFECYCLE/SCENES sequence). + +- **EV0 — the bus core.** ✅ *Compile-time half shipped.* `event` / `emit` / `@On` + with the `g_events`-gated zero-cost lowering: an event compiles to a `@ev_` + function whose body is its listeners in declaration order (payload bound by + name as params), and `emit E(…)` is a direct call. Verified byte-identical for + event-free programs, self-hosted to the C-free fixpoint. Still open in EV0: the + foreign C ABI (`ludic_on`/`ludic_emit`) and its runtime listener array, so a + mod in another language can join the same dispatch. Implementation notes: AST + `N_EVENT`/`S_EMIT`; `parse_event` + `@On` annotation + `emit` statement (guarded + by an identifier-lookahead so a bare `emit(...)` call still parses); registries + `g_events`/`g_onlisten` (emit_core); `emit_event_fns` (emit_game); `emit_emit` + (emit_stmt). [`examples/events/events.ludic`](examples/events/events.ludic) is a `bin/x test` check. +- **EV1 — `@Public` hook promotion.** ✅ *All scopes shipped.* `@Public` on a + lifecycle hook fires a public event at that hook's site (payload: entity, plus + `EndReason` for despawn); `find_event(name)` doubles as the "is this hook + public?" gate. Covered: program (`@OnStart`/`@OnQuit` → `program_start`/`_quit`), + models (`@OnSpawn`/`@OnDespawn`), properties + (`@OnAttach`/`@OnDetach`/`@OnEnable`/`@OnDisable` → `prop_

_…`). Scenes and + layers use a `public` block modifier instead of an annotation: + `scene__enter`/`_exit` at the synthesized scene functions, and + `layer__show`/`_hide` at the layer-toggle site. Building layer events also + delivered **SCENES E2 layer toggle**: `enable/disable layer L` flips an `@LE_` + flag that gates that layer's handlers, emitted only for toggled layers so + untouched scene programs stay byte-identical. +- **EV2 / EV2b — the world table.** ✅ *Read/write/scan/identify/create shipped.* + The generated reflection ABI (§7), dispatching a runtime prop/model id to the + right `@S_`/`@H_`/`@L_kind` storage: read/write (`prop_id`/`field_id`/`get`/`set`/ + `has`), scan/identify (`entity_count`/`kind`/`model_id`), and create + (`spawn(model_id)`, which reuses the compiler's own spawn lowering — defaults, + `@OnSpawn`, and the spawn event). `get`/`set` address each field by its real + struct offset (constant struct GEP), correct for `int`/`fixed`/`byte`/`ptr` + fields and mixed layouts alike. Emitted only for an ECS program that declares + events (gated on `has_ecs() && g_events`), so event-free games are byte-identical. + A `ludic_query_next(prop, from)` cursor iterates live entities that have a + property. The world table is complete: read, write, scan, identify, create, + iterate. +- **EV3 — cancellable events.** ✅ *Shipped.* `event cancellable E`, the `cancel` + verb, and `emit E(…)` as an expression yielding the veto flag; the flag is a + trailing field of `%Ev_`, so a foreign listener vetoes by setting it. Priority + ordering and `ignoreCancelled`/`halt` (§8) remain open. The modding headline — + observation becomes control. +- **EV4 — the scripting bridge.** One reference shim (Lua *or* QuickJS) over the + ABI, proving the O(languages) claim end to end. +- **EV5 — mod lifecycle & scoping.** ✅ *Shipped.* A parallel owner array `@evO_` + (-1 = program-scoped, ≥0 = owning entity); `ludic_on_` and + `ludic_on_entity_` register with the right owner; `ludic_off_(token)` + tombstones a slot to null and dispatch skips null slots; `ludic_sweep_entity`, + called from `emit_despawn` when the program has events, nulls every listener a + despawning entity owned. Scene-scoped listeners (drop on `on exit`) remain the + same shape applied at the scene teardown — a follow-on. +- **EV6 — determinism & re-entrancy.** ✅ *Depth bound shipped.* `@ev_depth` + increments on each `@ev_` entry and decrements on exit; past `EV_DEPTH_CAP` + (32) a dispatch returns immediately (a cancellable event returns "not + cancelled"), so an event cycle can't hang. Dispatch order was already + deterministic (array, registration order). Still design: deferred structural + changes at a sync point (LC5) and capability gating for deterministic sessions. +- **EV7 — schema-opening & networking.** ✅ *Schema-opening shipped.* A mod defines + a new component at runtime: `ludic_register_prop(name, nfields)` allocates flat + `[MAX_ENT × nfields × i32]` storage + a has-flag array (capacity 32 dynamic + components) and returns a prop id past the compile-time range; + `ludic_attach_dyn`/`ludic_detach_dyn` toggle presence; `get`/`set`/`has`/`prop_id` + fall through to the dynamic registry for a prop id ≥ the compile-time component + count. Per-entity storage is isolated (`world_dyn.c`). This is the first genuinely + *dynamic* `@S_` storage — a deliberate departure from the closed dense arrays, so + it lives entirely behind the ABI (the game's own components stay static and + byte-identical). Dynamic components use integer fields addressed by index (no + field-name schema). *Still design:* the local/remote event split (Roblox's + lesson) waits on Ludic having a networking substrate. + +EV0–EV1 deliver "the whole architecture emits public events." EV2–EV3 are where a +mod becomes able to *change the game*. EV4 proves the language-agnostic claim. +EV5–EV7 are hardening and reach. + +--- + +## 15. Open decisions + +1. **`emit` verb & payload identity.** Is `emit E(…)` the only spelling, or does a + `signal`-style per-property declaration (Godot) read better for the common case? + Are payloads always fresh POD records, or can an emit borrow an existing property + in place (cheaper, but aliases live storage)? +2. **`@Public` granularity.** Per-hook (proposed), per-model (`@Public model + Enemy`), or a program-level "expose all lifecycle" switch for prototyping? Does + `@Public` belong on the hook or on the `model`/`property`/`scene` it concerns? +3. **Name scheme stability.** Dotted strings (`model.Enemy.spawn`) interned to ids — + confirmed. Open: are ids stable across recompiles of the *same* source (needed + for save-compatibility of a listener table), and how does a renamed model + migrate a shipped mod? +4. **Schema opening (EV2/EV7).** Do mods stay behavior-only (listeners + custom + events + world reads/writes over author-defined schema), or can a mod define new + `property`/`model`? The latter needs dynamic `@S_` storage — a real departure + from the closed dense arrays (`LUDIC_MAX_ENT 1024`). Probably EV7. +5. **Cancellation surface.** Keep `cancel` (veto action) and `halt` (stop chain) + distinct (DOM), or collapse to one? Is `ignoreCancelled` per-listener or a + priority-band convention? +6. **Sandbox model.** Capability manifest per mod (proposed) — at what granularity + (per event? per property? per world-table verb)? What is denied by default in a + deterministic session, and who declares a session deterministic? +7. **Re-entrancy bound.** Compile-time constant emit-depth cap (trap on overflow), + or a runtime budget? What is the default depth, and is an event cycle a warning + or an error? +8. **Scripting runtime, in or out of scope.** Does Ludic *ship* an embedded runtime + (QuickJS/Lua) as a blessed default, or only the ABI and reference shims, leaving + the runtime to the game? (Bias: ship the ABI + one reference shim; bless no + language.) + +--- + +*Companion to [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md) (the hook sites this layer +promotes) and [SCENES-DESIGN.md](SCENES-DESIGN.md) (scene/layer/overlay events and +scoped-listener teardown). Grounded in the Luanti gap analysis (`LUANTI-ROADMAP.md`): +the generated bridge is how Ludic avoids the 57k-line C++↔Lua tax. Supersedes +nothing until the compiler work in §12 lands.* diff --git a/LANGUAGE.md b/LANGUAGE.md index eff6abe7..56838960 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -26,10 +26,8 @@ program Name { # plain `new`-allocated record; its use decides which model ... # a named entity KIND (bundle of properties) const ... # compile-time constants - function ... # functions - extern function … # bind a C library symbol (FFI) - enum ... # a named set of integer values - namespace ... # a block of functions with export / internal visibility + fn ... # functions + extern fn ... # bind a C library symbol (FFI) handler ... # behavior, grouped into phases } ``` @@ -44,1411 +42,15 @@ program ChronoRift { } ``` -`import "emberdepths/*.ludic"` imports every `.ludic` file of a directory, in name -order — a game lists its modules once. `import "camp"` names a directory through its -**barrel**, `camp/index.ludic`: a fragment that lists the directory's own imports -(relative to itself), in the order it wants them. A directory with no `index.ludic` -is an error that says so. Packages resolve the same way: `import "ludic.render3d"` -reads `ludic.render3d/index.ludic` from `ludic_modules/` or the toolchain. An imported file is a **fragment**: bare -declarations, no `program` wrapper. Its +An imported file is a **fragment**: bare declarations, no `program` wrapper. Its declarations are spliced into the importing program. Imports may appear inside the `program` block or before it, they may nest (a fragment may import fragments), and each resolved path is **include-guarded**, so importing the same file twice -(even via different chains) pulls it in once. Diagnostics name the file the -line really lives in, in the `file:line: error: message` shape editors already -parse: +(even via different chains) pulls it in once. Diagnostics report the true file: ``` -chronorift/world.ludic:1: error: expected expression -``` - -All of a program's files share one namespace, so **a name is defined once**: two functions, two -`var`s or `const`s (or an `enum` and a `const`), or two `property` / `event` records with one name -are an error that names both places. Declarations the runtime splices in are its own and are not -checked against each other. -The same holds inside a function: a `let` or `var` declares its name once per block (another -block, a loop variable or a parameter may reuse it), and a function with a result type must -`return` one on every path - running off the end of its body is an error, not a zero. - -### Modules (`module`, `export`, `friend module`) - -One namespace is not the same as one room. A barrel that says `module bank` makes its -directory a **module**: the barrel, every file it imports, and every file those import - until -one says `module` of its own - belong to `bank`. Inside a module every name is visible as -before. From anywhere else, a module's `function`, `var`, `const`, `property` or `event` is -reachable only if its declaration says `export`: - -```ludic -# doc-check: skip — a module spans files -# bank/index.ludic -module bank -import "ledger.ludic" - -# bank/ledger.ludic -export state Bank { balance: int = 0 } # its fields are bank's to change: only bank's functions do -export event Deposited { amount: int } -function add(b: mut Bank, n: int) -> void { b.balance += n } # private: only module bank sees it -export function deposit(b: mut Bank, n: int) -> void { - add(b, n) - emit Deposited(amount: n) -} -``` - -A program that imports `bank` may call `deposit` and listen to `Deposited`; calling `add` is - -``` -visible.ludic:5: error: add is private to module bank; mark it 'export' where it is declared (bank/ledger.ludic) -``` - -A file in no module - the program's own file, the runtime - is public, and a package found -through `ludic_modules` or the toolchain keeps its own module rather than its importer's. A -program that says `friend module lab` sees every module's private names: that is for a test -harness, which has to reach inside what it tests. `friend module lab of fishing, data` narrows -that to the modules named: `lab` sees their private names, and every other module's exports only. `export` is a keyword before a declaration -and is not the `@export` annotation, which names a C symbol. - -**A module's private names are its own.** A function, `var` or `const` a module does not export -may share its spelling with a name in another module, in a file in no module, or in the engine's -runtime: `shop` and `weather` may each have a private `seed`, and each module's code reaches its -own (a local of the same name still shadows it). There is no need to prefix a package's privates. -Exported names are one namespace across the program, so two modules that both `export function -seed` are still refused (`function 'seed' is defined twice`). Where two names meet, the private -one is compiled under its module's name (`seed$shop`), which is the spelling a message about it may -show. - -The same holds for a `property` and an `event`: `fishing` and `hunting` may each have a private -`Catch` record and a private `Landed` event with different fields, and each module's types, `new`, -`emit` and `@On` reach its own (compiled as `Catch__fishing`). Two exported ones of one spelling are -refused (`'Catch' is defined twice`, `event 'Landed' is defined twice`). When one of the two is a package's export the message -says so (`'UiNode' is defined twice (...): ludic_ui exports it, and exported names are one namespace - -rename this one, or declare it without export inside a module of your own`); a package exports only -what a program uses, so ludic.ui's `UiAct` is its own and a game's may take the name. Three kinds of record stay -one namespace, because other code names them by spelling: a generic record, a property that is an -entity's component (a `model` names it), and the record a component or a view generates. - -To move an existing codebase onto modules, build it once with `LUDIC_VIS_REPORT=1`: every -reference that would be refused is printed as `vis: :: . used from -` and the build goes on, so a script can add the `export`s the program already relies on. - -### What a module may reach (`uses`) - -`export` says what a module offers; `uses` says what a module takes. A module line may name the -only other modules its files reach: - -```ludic -# doc-check: skip — a module spans files -# fishing/index.ludic -module fishing uses base, data -``` - -From then on a reference from `fishing` into any other module is refused, even to a name that -module exports: - -``` -fishing/land.ludic:4: error: fishing uses items.inv_add (items/index.ludic:3): add 'uses items' to fishing's module line, or take it through a port -``` - -- A module with no `uses` clause keeps the rule before it - anything exported - so the rule can be - switched on one module at a time. Two module lines for one module add their lists together. -- A package's module counts like any other: a mechanic that says `uses ludic_base` and calls - into `ludic_inventory`, `ludic_ui` or the renderer is refused. A package with no `module` line - of its own (`ludic.render3d`) is named for its directory - `ludic_render3d` - for this rule; its - names stay public to the export rule. Only the engine's own runtime - `Value`, `Json`, - `Random` and the rest, which is in no module and no package - needs no naming. -- A file in no module (the program's root) is unaffected either way. -- `module ludic_base uses` with nothing after it is a module that reaches no other module at all. -- A friend of a module (`friend module lab`, or `friend module lab of fishing`) is not held to its - `uses` for that module. -- The declared graph may not go round: `module a uses b` beside `module b uses a` is refused - (`the modules' uses go round in a circle: a -> b -> a`) - one of them takes the other through a - port instead. -- **Layers.** `module flow in layer app uses base, items` puts `flow` in layer `app`. The modules of - one layer use each other freely, without naming each other, and may go round - a game's app - modules (the flow, the menus, the HUD) reach each other by design. Everything outside the layer is - still held to the module's `uses`, and a layered module with no `uses` may reach nothing outside - its layer. A cycle is allowed only inside a layer: `items uses hud` with `hud` in layer `app` - using `items` back is `items -> layer app -> items`, refused. A module is in one layer. -- `LUDIC_VIS_REPORT=1` lists these too, as `uses: :: . used from - (module )`, and builds. - -### Ports (`port`, `bind`) - -A module that needs something from outside itself - the time, a save, a sound - declares a -**port** instead of naming (and `uses`-ing) the module that answers. A port is a record of -function values; a member with no default is required: - -```ludic -# doc-check: skip — a module spans files -# clock/index.ludic -module clock uses units -export port Clock { - now: fn() -> int - day: fn() -> int = fn first_day -} -export function hour_of_day() -> int { return Clock.now() - (Clock.day() - 1) * HOURS } - -# app/index.ludic - where the program is put together -module app uses clock -bind Clock { now: fn game_hours } -function game_hours() -> int { return g_hours } -``` - -A member that takes nothing can be bound to a variable instead: `bind Purse { money: g_money }` for a -`money: fn() -> int` writes the getter (`bind_Purse_money`, returning `g_money` as it is at each call) -in the bind's own file, so the one-line wrapper function is not needed. A member that takes something -is refused a variable (`bind Purse: price is a fn(int)->int, and only a member that takes nothing can -be bound to a variable`). - -Calls go through the port by name, `Clock.now()`. `clock` never names `app`, so it needs no -`uses app`; the binder must be able to see the port - it is `export`ed, and a binder that says -`uses` names the port's module. What the bind names (`fn game_hours`) is checked from the bind's -own file. The compiler refuses: - -``` -app.ludic:5: error: port Clock is used here but never bound: the program has to say 'bind Clock { ... }' once, where it is put together -app.ludic:9: error: bind Clock leaves out now, which has no default -app.ludic:11: error: port Clock is bound twice (first at app.ludic:10) -app.ludic:9: error: port Clock has no member later -``` - -and a member of the wrong type is a type error like any other (`field now of Clock_port wants a -fn()->int and this is a fn(int)->float`). A port nobody uses may stay unbound, and so may a port -whose every member has a default: unbound, it answers with its defaults - which is how a -package's fallbacks ("unbound: this machine runs the world") are written. - -### State: no function writes a global (`state`, `mut`) - -A function that changes a module variable it was not given is a hidden coupling: nothing in its -signature says what it touches, and it cannot run without the whole program around it. So a -module-level `var` is refused, and a module's changing data is a **state** record instead: - -```ludic -# doc-check: skip — a fragment -state Hiker { - hips: int = -1 - spine: int = -1 -} - -function hiker_bind(h: mut Hiker, sk: Skin) -> void { - h.hips = skin_joint(sk, "hips") - h.spine = skin_joint(sk, "spine") -} -function hips_of(h: Hiker) -> int { return h.hips } -``` - -- **One instance, which no code names.** The program holds exactly one of each `state`, made - before any code runs. It reaches code only as a parameter: `h: mut Hiker` may change it, `h: Hiker` - may only read it. So a function's signature is everything it reads and writes, and a test hands - it a plain value. -- **Read-only is checked where it is written.** Through a read-only state the compiler refuses an - assignment whose target starts at it (`h.hips = 1`, `h.list[i] = x`), a `push` onto something in - it, and passing it where a `mut` one is wanted (`bump changes Tally (c: mut Tally), and c is - read-only here`). A reference read out of it into a local (`let l = h.list`, `let r = h.rows[0]`) - is read-only too, so a write through that is refused the same way; a value read out (`let n = - h.count`) is a copy and the local's own. `machine h.mode` writes its store on every `become`. - `mut` is for a state parameter only. -- **The runtime supplies it at the entry points** - the only code nothing in the program calls: - - a body that declares it: `entry (h: mut Hiker) { ... }`, `handler Draw(h: Hiker) phase Render - { ... }`, `@On(Ping) handler Heard(h: mut Hiker) { ... }`, `@OnSpawn(M) handler Made(h: mut - Hiker) { ... }`, a scene's `on enter (h: mut Hiker) { ... }`, `test "name" (h: mut Hiker) { ... }`; - - a retained `ui` block, which names a state's instance by the state's name: `font: Menu.title_font`; - - a component, whose header names its states: `component Tally (score: mut Score) { ... }`; - - a function value: `fn tick` of `function tick(h: mut Hiker, t: Tick)` is `tick` with its - leading states supplied, a `fn(Tick) -> void` - so a system's functions, a port's bind and any - callback a package calls are entry points without saying so; - - a port member bound to a state's field, `bind Purse { money: Wallet.cash }`; - - a call the compiler writes: a namespace method's target (`Weapon.def(...)` of `function - weapon_def(w: mut Weapons, ...)`), an engine system, a runtime built-in. - Every other call passes its states explicitly. -- **Everything else module-level is immutable all the way down.** `let LIMITS: []int = [1, 2]`, - a `const`, a registry: an assignment or a `push` that starts at one is refused, and so is one - through a local that holds part of it (`let r = LIMITS; push(r, 3)`). -- **Tests get fresh states.** Each test block starts from states made new, in its own process under - `ludic test` and in the runner run directly. -- The toolchain's own programs (the compiler, the CLI) are not part of this yet: they build with - `ludicc --globals`, which lets a module-level `var` through. - -The errors: - -``` -counter.ludic:5: error: this assignment: c is read-only here (c: Tally); take it as c: mut Tally to change it -counter.ludic:3: error: a module-level var is refused: a module's changing data is its state (state Name { ... }), passed to the functions that use it - or, if it never changes, a let -counter.ludic:5: error: this assignment: LIMITS is module-level and immutable all the way down; changing data belongs in a state, passed as a mut parameter -counter.ludic:3: error: x: mut int - mut is for a state parameter, and int is not a state -``` - -**`ludic migrate state [file|dir...] [--prune] [--runtime] [--dry-run]` moves programs there.** It compiles -each program and, from the compiler's own view of every name: - -1. a var nothing writes, holding a value (an int, a string, an enum...), becomes a module-level - `let` where it stands; -2. each module's other vars become one state, `state State { ... }`, where the first of - them was - a module's by its name (`module fishing`: `FishingState`), a directory with no module - line by its path (`ludic.render3d`: `Render3dState`), the program's own file by the program's - name (`program SceneDemo`: `SceneDemoState`); the fields keep their comments; -3. every reference to one is rewritten to `_st.` (`scene_demo_st.counter`), and in a - `ui` block to `.`; -4. each function's states - those it touches, and those of everything it calls, to a fixed point - - become its leading parameters, `mut` where it or something it calls writes; a state a run before - declared read-only becomes `mut` where it is now changed; -5. each call passes them on, each entry point declares them (after any it declares already), and - each component's header declares what all its members need. - -Give it every program at once - a directory stands for the test programs under it - and it merges -their plans before it edits anything: programs that share a package agree about it, a module two of -them see different files of is one state, and a path reached as `../../packages/x` is the same file -as `packages/x`. A program's own file keeps a state of its own (named for the program) whatever module it says, and -a `friend module`'s other files go by their directory, since each program declares that module for -itself. A package's var nothing in the given programs writes stays changing data (a game may set -`r3d_dem_path`); only a private one of a package's module becomes a `let`. A name the program uses -that a package migrated earlier moved into its state (`cam_pos`, now `Render3dState`'s) is rewritten -through that state, and a read of the runtime's own var from outside it through the runtime function -that answers it (`gl_w` is `gl_width()`). A program's own module named like a package (a game's `module fishing` beside -`ludic.fishing`) gets a state of its own, `FishingAppState` / `fishing_app_st`, never the package's. -It writes only under the programs and directories it is given (and `runtime/` with `--runtime`): -when the programs need a change in a file anywhere else - an installed package, a module imported -from a neighbouring directory - it says which and changes nothing. It prints what it cannot decide -(a var read in another global's initializer, a reference in generated code), for a person to finish. -A later run finds the states an earlier one made and adds to them. `--runtime` moves the runtime's -own vars too. gpp's packages and examples were moved with one command: - -``` -ludic migrate state packages packages/ludic.lab/example/plate.ludic -migrate: 1804 vars into 126 states, 64 into lets; 23498 edits in 460 files -``` - -**`--prune` takes out what a function no longer uses:** each state parameter that neither the -function nor anything it calls uses, and the argument that fills it at every call. An argument for a -parameter the callee no longer has goes too - a package's verb that dropped a state leaves its callers -passing one too many, and `ludic migrate state --prune ` puts them right. A reducer keeps its -state, and a state declared after a plain parameter (`home_keep(r: Records, save_st: mut Save)`) is -kept where it is. When ludic.base's queues began keeping their own counts, every package verb lost its -`base_st` this way (`wallet_earn(wallet_st, n)`, not `wallet_earn(base_st, wallet_st, n)`): - -``` -ludic migrate state --prune packages packages/ludic.lab/example/plate.ludic -migrate: 0 vars into 0 states, 0 into lets; 1889 edits in 132 files -``` - -### Actions and reducers (`action`, `reducer`, `dispatch`) - -Threading states makes a function's signature say what it touches, and it shows where one function -does everything: an input handler that reads the keys and then changes the world itself takes every -state the world has. An action separates the two. The input says WHAT happened; each module decides -what that means for its own state, and nothing else: - -```ludic -program Pack { - state Bag { - items: []int = new []int - weight: int = 0 - } - state Log { lines: []string = new []string } - action PickUp { item: int, kg: int = 1 } # what happened: a typed record - - reducer Bag on PickUp(b: mut Bag, a: PickUp) { # in the module that owns Bag - push(b.items, a.item) - b.weight += a.kg - } - reducer Log on PickUp(l: mut Log, a: PickUp) { push(l.lines, `picked {a.item}`) } - - handler Keys phase Input { - if Input.key() == 'e' { dispatch PickUp { item: 7 } } # a translator: keys to actions - } -} -``` - -- **`action Name { fields }`** is a record, with defaults like any. `export action` for other - modules to dispatch it. -- **`reducer State on Action(s: mut State, a: Action) { ... }`** WRITES exactly its state, and takes - the action last. Between the two it may declare states it only READS - `reducer Wallet on Buy(w: - mut Wallet, s: Shop, a: Buy)` - supplied like its own, for what is only known inside the drain (a - price another reducer just set, the map in play, where the save lives). A second state to write - is refused (`reducer Pack on Buy: a reducer writes one state, and w is a mut Wallet - read it (w: - Wallet), or dispatch an action Wallet's own reducer takes`), and so is a call inside it to a - function that writes another. What the dispatcher knows rides in the action. Several reducers may - handle one action, one per state; a reducer is not called by name. -- **`dispatch Action { fields }`** queues the action, from anywhere: a handler, a function, a - listener, a reducer. The queue is the runtime's, supplied like an entry point's state, so - dispatching needs no state parameter. -- **When the queue is drained:** at the end of every phase of the frame loop (so what the `Input` - phase dispatches is reduced before `Update`); after every phase of the ludic.base system runner - (`core_tick_all`); when ludic.ui has run a frame's presses (`ui_show`, `ui_press`), so a button's - action is reduced before the host presents the frame, not a frame later; and wherever the program - calls `drain_actions()` (an `entry` program, a test, a loop of its own - and a host that runs UI - presses some other way calls it before it presents). Draining runs the actions in the order they were dispatched, and each action's - reducers in the order of their states' names - never the order of imports - so the same actions - make the same changes on every machine and in a replay. -- **An action a reducer dispatches** is queued behind the rest and reduced in the same drain, never - re-entrantly. A queue still growing after 64 rounds of that stops the program, naming the action: - `actions: Ping is still being dispatched after 64 rounds of reducers - a reducer dispatches what - dispatches it`. -- **Events stay** for what changes no state - a sound, a notice, telemetry - and `@On` listeners run - as the event is emitted. Actions are for changes. - -**A reducer on a row** (27.3). Many of a kind are rows of a ludic.base `Table` (Things, animals, -vehicles), and an action may name ONE of them: its field marked `@Target` holds the row's handle, -and a reducer written `in` the table runs for that row alone. - -```ludic -import "ludic.base" -program Herds { - numbers float - property Deer { - @Column x: float = 0.0 # a table column mirrors it - fear: int = 0 - } - state Herd { deer: Table = null } - state Noise { loud: int = 2 } - action Spook { @Target who: int = -1, by: int = 1 } - action Bolt { @Target who: int = -1, dx: float = 0.0 } - - reducer Deer in Herd.deer on Spook(r: mut Row, n: Noise, a: Spook) { - r.rec.fear += a.by * n.loud - if r.rec.fear > 3 { dispatch Bolt { who: r.h, dx: 5.0 } } - } - reducer Deer in Herd.deer on Bolt(r: mut Row, a: Bolt) { deer_move(r, r.rec.x + a.dx) } - - @RowVerb function deer_move(r: mut Row, x: float) -> void { - r.rec.x = x - tb_set_f(r.tb, 0, r.row, x) # the column and its indexes, with the field - } - entry (h: mut Herd) { - let tb: Table = table_new(1, 0) - h.deer = tb - dispatch Spook { who: tb_add(tb, new Deer), by: 2 } - drain_actions() - } -} -``` - -- **`reducer Record in State.table on Action(r: mut Row, reads..., a: Action)`** - the - table is a field of the state (a path through its records is fine: `WildState.w.tab`), of type - `Table`, and only the module that owns the state declares a row reducer on it. The row - comes first and `mut`, the action last, and between them states it only READS, as any reducer's. -- **`@Target`** marks the action's one field holding the handle (an `int`, what `tb_add` returned). - One to an action: several rows are the dispatcher's loop, one `dispatch` per handle. -- **The drain resolves the handle** (`tb_row`) and hands the reducer a `Row` (ludic.base: - `tb`, `row`, `h`, `rec`) it keeps, one per row reducer and filled in place, so a targeted action - allocates nothing. A handle whose row is gone - the animal removed since the dispatch - runs - nothing, which is not an error; `LUDIC_ACTIONS_LOG=1` prints a line for it. Row reducers take - their place among the action's reducers by their state's name, then the table's. -- **The row goes no further.** Inside a row reducer `r.rec` and `r.h` are all it reaches: `r.tb` - and `r.row` are refused, the view is not assigned, stored, copied or handed to anything but a - **`@RowVerb`** - a function of the record's own module that takes the row first - and a field - marked **`@Column`** (one a table column mirrors: a position, a kind, whether it is alive) is not - written through `r.rec`: `reducer Deer in Herd.deer on Push: Deer.x is mirrored by a column of the - table (@Column) - write it through a @RowVerb, which keeps the column and its indexes with it`. - What `things_verify` catches at run time is a compile error inside a row reducer. -- **Dice** a row reducer rolls come from the row's own `Rng` or its owner's, never `Random.*`: it - runs in the drain, outside its owner's tick, and the world's stream is co-op's shared order. - -**A machine as data** (27.1). A row's state - an animal idling, fleeing, drinking - is an enum field -of its record, and the machine that moves it is a registry marked `@Machine(Record.field)`: one row -a transition, which the compiler turns into the reducers and the tick. The table is the whole -machine, and a studio edits it as a graph. - -```ludic -import "ludic.base" -program Moods { - enum Mood { Calm, Wary, Fled } - property Deer { - mood: Mood = Mood.Calm # the start: the field's default - fear: int = 0 - } - state Herd { deer: Table = null } - action Spook { @Target who: int = -1 } - property DeerStep { - from: Mood = Mood.Calm - to: Mood = Mood.Calm - on: string = "" # an action's name, or "" for a transition the tick asks - guard: fn(Row, Herd) -> bool = null - enter: fn(Row, Herd) -> void = null - } - @Machine(Deer.mood) registry DeerSteps of DeerStep - def DeerSteps startled { from: Mood.Calm, to: Mood.Wary, on: "Spook", enter: fn deer_startle } - def DeerSteps bolts { from: Mood.Wary, to: Mood.Fled, on: "Spook", guard: fn deer_afraid } - def DeerSteps settles { from: Mood.Wary, to: Mood.Calm, guard: fn deer_settled } - def DeerSteps home { from: Mood.Fled, to: Mood.Calm } - - function deer_afraid(r: Row, h: Herd) -> bool { return r.rec.fear > 1 } - function deer_settled(r: Row, h: Herd) -> bool { return r.rec.fear == 0 } - function deer_startle(r: mut Row, h: Herd) -> void { r.rec.fear += 1 } - entry (h: mut Herd, m: mut DeerStepsMachine) { - h.deer = table_new(0, 0) - dispatch Spook { who: tb_add(h.deer, new Deer) } - drain_actions() - deer_steps_tick(m, h) - } -} -``` - -- **The registry's record** has `from` and `to` (variants of the field's enum), `on: string` (an - action's name, `""` for a transition the tick asks), and may have `guard: fn(Row, reads...) - -> bool` and `enter: fn(Row, reads...) -> void`, each taking the row first and then the - states it reads. Its rows may live in an `.lres` (`from "deer_steps.lres"`), where a value is - written as in code: `bolts { from: Mood.Wary, to: Mood.Fled, on: "Spook", guard: fn deer_afraid }`. - The states are the enum's variants; the record is held in one state's `Table`, and the - registry is that state's module's. -- **What the compiler writes.** For each action an `on` names (it must have a `@Target`), a row - reducer: the row's current state, the first transition from it on that action - in the table's - order - whose guard passes (no guard passes always), the field set, then `enter`. When a row - leaves a state on a guard alone, `state DeerStepsMachine` (the tick's kept row view) and - `deer_steps_tick(m: mut DeerStepsMachine, s: mut Herd, reads...)`, which takes every row of the - table through the same first match, one transition a row a tick; the program calls it from its - system. A program's own row reducer on the same action runs before the machine's, so an enter - that needs the action's payload finds it on the row. Nothing is allocated: the row views are kept. -- **The table is the whole machine.** Its field is written by nothing else - an assignment or a - `machine` block's `become` anywhere else is refused: `this assignment: Deer.mood is the machine - DeerSteps's (@Machine(Deer.mood)) - it changes only by a transition in its table`. A new row takes - its state in its `new` (a save's load does too). A guard and an enter are functions of the - record's module and keep a row reducer's rules - the row reaches `r.rec` and `r.h`, goes only to a - `@RowVerb` or another of the machine's functions, and a `@Column` field is not written; a guard - asks and writes nothing through its row. -- **The graph is checked**, each an error at its row: a state never reached from the start; a state - with no way out; an `on` naming no action, or an action naming no row; a transition from a state - to itself with no guard; and two ways out of one state on one trigger behind an unguarded first - (`settles and stays both leave Wary on Spook, and settles has no guard - stays could never be - taken`). -- `ludic schema`'s code section lists each machine (`machines`: `registry`, `record`, `field`, - `enum`, `table`, `start`, `states`, `actions`, `tick`, `module`, `at`), and `ludic deps` names its - reducers `reducer Deer in Herd.deer on Spook (machine DeerSteps)`. The `machine` block stays for - a machine that is only code. - -`ludic deps` reports the widest function - the most states any function or entry point of the -program's own takes - and `--check` holds it as a ratchet like its other numbers -(`widest_function 12` in the baseline file). A function value's states are supplied where it is called, so a -step list - `let STEPS = [fn a, fn b]` walked by a function that takes nothing - hides what it -touches; `widest_reach` is the most states any function can come to, through its calls, the `fn f` -it writes and the globals holding fn values it reads (a registry of systems), and the report says how -many of them it does not take itself. `ludic deps --widest N` lists the N functions that take the -most states, each with what it reaches; `--reach N` lists them by reach. - -### Types are checked before anything is emitted - -Between the parse and the emitter a checker walks every function, the entry, the tests, the -globals' initializers and every `@On` listener, and refuses a program whose types do not agree - -all of its mix-ups at once, each at its own line: - -``` -trip.ludic:12: error: metres wants an int and this is a float -trip.ludic:14: error: area takes 2 argument(s) and this call gives 1 -trip.ludic:20: error: + of a string and an int: text joins text only - write string(x) for a number -3 type error(s) -``` - -What it holds apart: `int`, `float`, `fixed` and `bool` (a float or a fixed into an int is -`int(x)`; a float and a fixed never meet but by a literal, which takes whichever kind its slot is); -text and numbers (`string(n)` or a template); one record type and another; slices of different -elements; functions of different types. A call gives exactly as many arguments as there are -parameters, a `return` gives the declared result, and `push` gives the slice's own element. A -function names each parameter once (`add names two parameters n`). A type written in a parameter, a -result or a field names a declared type (or one of the declaration's type parameters): a misspelling -is refused there (`kind_of's parameter f: there is no type CharFact`), not later as a member access -that makes no sense. - -`pointer` is untyped, as `void *` is in C: it goes wherever a reference is wanted and takes any -reference, and a `[]pointer` any slice of references. Restricting what a raw pointer may reach is -`unsafe`'s job, not the checker's. Bits cross between kinds by name - `as_int(x)` / `as_fixed(n)` -reinterpret a word, `float_bits(f)` / `float_from_bits(i)` a float's - never by a slot's type. - -A name the checker cannot type (an engine namespace's arguments, a query's bindings) agrees with -everything, so it only ever reports what it can prove; the emitter keeps its own checks behind -it. `LUDIC_CHECK_REPORT=1` lists every mix-up by category and fails nothing, which is how an -existing program is measured before it has to pass. - -### Generic records and functions - -A record or a function can take type parameters, written after its name: - -```ludic -program Pools { - property Pool { - items: []T = null - n: int = 0 - } - function pool_new() -> Pool { - let p = new Pool - p.items = new []T - return p - } - function pool_add(p: Pool, x: T) -> void { - push(p.items, x) - p.n += 1 - } - function map(xs: []T, f: fn(T) -> U) -> []U { - let out = new []U - var i = 0 - while i < len(xs) { - push(out, f(xs[i])) - i += 1 - } - return out - } - entry { - let names: Pool = pool_new() - pool_add(names, "Crater Lake") - print(names.n) - } -} -``` - -A type names an instance with its arguments - `Pool`, `Pair`, -`Pool>`, `[]Pool` - and two instances of one generic are two types. A call's -type arguments are worked out from its arguments (`pool_add(names, "x")` is `pool_add` at -`string`), a literal deciding only what nothing else did; a call with nothing to say it, like -`pool_new()`, takes them from the slot its result is written into - a `let` with a declared type, -an assignment, an argument, a `return`. Where neither decides, the call is refused and says which -parameter it could not tell. - -Generics are compiled by instantiation: each instance the program uses is an ordinary record or -function, made and checked once, so it costs exactly what writing it out by hand would. A generic -nothing instantiates is not compiled at all. - -### Namespaces declared in Ludic (`alias`) - -A namespace method can be a name for a function. Inside a `namespace` block, - -```ludic -program Trails { - namespace Trail { - export alias length(from, to) = trail_distance - alias km = trail_km - } - function trail_distance(a: int, b: int) -> int { return b - a } - function trail_km(metres: int) -> int { return metres / 1000 } - entry { print(Trail.length(to: 12, from: 2) + Trail.km(metres: 5000)) } -} -``` - -makes `Trail.length(...)` a call to `trail_distance`: the list after the method is the labels a call -may name its arguments by, in the target's parameter order, and without one the target's own -parameter names are the labels (`alias show() = present` takes none). The call is the target's - -same arguments, same checks, same code - so a namespace costs nothing over calling the function. - -This is how the engine declares its own namespaces: `Http`, `Udp`, `Process`, `Json`, `Value`, -`Screen`, `Input`, `Audio`, `World`, `Tiled` and the rest are `alias` blocks in -`runtime/native/namespaces.ludic`, not branches in the compiler, and a package owns an API the same -way in its own files. What is still built into the compiler is the namespaces that compute inline - -`Math`, `Text`, `List`, `Vector`, `Color`, `Time`, `Date` - and the few methods that choose their -target by an argument's type (`Audio.play` of a handle or a name). - -A function of the program's own that is named like the target of one of the engine's namespace -methods would take that method's calls - `Random.range` is `rng_range`, so a package's -`rng_range(a, b, c)` would receive every `Random.range(1, 6)`. Where the program calls that method, -the function is refused (`rng_range is the engine's Random.range, which this program calls (...), -and every such call would reach this function instead; choose another name`), as a function named -like a compiler built-in (`run`, `exit`) or like one of the engine runtime's own functions is. So -is a type - a property, record, enum, event or state - named like one the runtime declares in a file -the program uses: `property PadButton` beside the runtime's `enum PadButton` used to compile and -then fail in clang, where the two layouts met (`PadButton is the runtime's enum -(runtime/native/input.ludic); choose another name for this property`). - -### Registries (`registry`, `def`) - -A table of records that code used to fill with calls in an init function is declared instead: - -```ludic -program Camp { - property Furnishing { - key: string = "" - name: string = "" - cost: int = 0 - } - registry Furnishings of Furnishing as HF - def Furnishings chair { name: "Camp chair", cost: 60 } - def Furnishings crate { name: "Crate", cost: 30 } - entry { print(Furnishings[HF_CRATE].cost + HF_COUNT) } -} -``` - -`registry NAME of RECORD [as PREFIX]` is a global `[]RECORD`, and every `def NAME key { ... }` is one -entry of it - in any file, collected in source order, and in the table before any code runs. Each -entry gets an index constant, `PREFIX_KEY` (the prefix defaults to the registry's name in capitals), -in declaration order, and the registry a count, `PREFIX_COUNT`. When the record has a `key: string` -field it is filled with the entry's key, and `name_find(key)` (the registry's name in lower case) -returns its index or -1. A def's fields are checked against the record like any record literal, a -key is declared once, and `export registry` exports the table, its constants and its lookup. - -Because the index is the order of the defs, a table stored by position - a save, a setting - keeps -the rule it always had: add an entry at the end, never between two. The order is the order the -compiler reads them in, and an `import` is read where it stands: a file's imported defs come before -the defs written after the import line. - -A registry belongs to its module, and a `def` written in another module is refused - unless the -registry says `open`: - -```ludic -# doc-check: skip — a module spans files -# core/index.ludic -module core -export property System { key: string = "", run: fn() -> int = null } -export open registry Systems of System as SY -def Systems clock { run: fn clock_run } - -# weather/index.ludic -module weather uses core -def Systems weather { run: fn weather_run } # weather_run may stay private to weather -``` - -A def into an open registry goes through visibility like any other reference: the registry must be -exported, and a module that says `uses` names the registry's module. What the entry itself names -(`fn weather_run`) is seen from the def's own module. The errors: - -``` -game.ludic:4: error: def Tools saw: registry Tools is not open to other modules; declare it 'open registry Tools' in module kit, or write the def there -game.ludic:4: error: Tools is private to module kit; mark it 'export' where it is declared (kit/index.ludic) -``` - -**The index order of an open registry** is the declaring module's own entries first, in the order -they are read, then every other module's: the modules in the order of their names, each one's -entries in the order they are read. `SY_CLOCK` is 0 however the program imports things, and an -entry from `alpha` comes before one from `zeta` even when `zeta` is imported first - so a table -saved by position keeps its meaning when a barrel's imports are reordered. The rule for a saved -table is still to append: a new entry goes at the end of its own module's list, and a new module -whose name sorts before an existing one moves that one's entries along. A registry whose defs are -all in its own module (or in no module) keeps exactly the order it always had. - -### Resource files (`registry ... from`) - -A registry's entries can live in a data file instead of the source: - -```ludic -# doc-check: skip — the file it names is beside the example -registry Tools of Tool as TL from "data/tools.lres" -``` - -``` -# tools.lres -axe { - name: "Axe" - weight: 1.5 - uses: [{ verb: "Chop", minutes: 20 }, { verb: "Split", minutes: 10 }] -} -lantern { name: "Lantern", weight: 0.75, uses: [] } -``` - -The file is a list of entries, each a key and a record, with `#` comments. It is read when the -program is compiled: the registry's record is its schema, so every entry is checked as a `def` is -- a field the record does not have, a string where it wants a number, a key twice - and the error -names the line in the resource file. A field whose type is a record takes a bare `{ ... }`, and one -typed as a list of records takes `[{ ... }, ...]`; the schema supplies the type. Values are Ludic -expressions in the registry's module, so an entry refers to another table's entry by its constant. -The entries are compiled in: nothing is parsed at start-up, and a build that succeeds has checked -every resource it uses. The path is the project's (where the build runs), else beside the file that -declares the registry. - - -A module that extends an open registry can bring its entries from a resource file of its own: - -```ludic -# doc-check: skip — the file it names is beside the example -import "crafting" # module crafting: export open registry Recipes of Recipe as RC -def Recipes from "recipes.lres" # the game's recipes, after crafting's own -``` - -`def REGISTRY from "file.lres"` reads the file as `registry ... from` does - the project's path, else -beside the file that says it - and checks every entry against the registry's record, an error -naming the resource file's own line (`bad_recipes.lres:3: error: field minutes of Recipe wants an -int and this is a string`). Its entries are defs of the module that wrote the line, so the registry -must be open (and exported) to it, and they take that module's place in the order: the declaring -module's entries, then each other module's by module name, and within a file, file order. - -### Map-scoped tables (`@PerMap`, `@Chunked`) - -A registry can hold what is on a map rather than what is in the game: its rows are not compiled in, -they are read when a map loads, from that map's own directory. - -```ludic -# doc-check: skip — its rows are files under each map's directory -property PropRow { - key: string = "" # the entry's key, as every registry record - @Ref(Models) model: int = 0 # a literal, or any constant by name: MDL_TENT2 - x: float = 0.0 - @Unit("deg") yaw: float = 0.0 - @Ref(Spots) home: string = "" # a row of another map table, by its key -} -@PerMap @ByKey registry Props of PropRow from "props.lres" -@PerMap @Chunked(64) registry Instances of InstRow from "instances/{cx}_{cz}.lres" -``` - -The maps are the directories under the maps root, which `package.ludic` names (`maps "assets/maps"`, -the default; taken relative to the package's directory): `assets/maps/maroon/props.lres`, -`assets/maps/maroon/instances/12_-3.lres`. `from` is relative to the map's directory; in a -`@Chunked(n)` registry, `{cx}` and `{cz}` are the chunk's integer coordinates, `floor(x / n)` and -`floor(z / n)`, written as decimals (`-3`), and they go in the file's own name, not a directory above -it. The files are ordinary `.lres` entries (`key { field: value, ... }`, see Resource files), read -through the same open every asset takes, so a mounted pack serves them and a dev run reads the -directory. A map's row may hold an `int`, a `float`, a `bool`, a `string`, a nested record, a list of -any of those (not a list of lists or of `fn` values), or a `fn` value written `fn name` (resolved -among the functions of the field's type the registry's module can name). An `int` field takes a -literal or any `int` constant by name, a `float` field any number or number constant; the program -carries its constants by name for that, when a `@PerMap` registry exists. The record's first field -is `key: string`, filled from the entry's key. Rows are in file order. - -A `@PerMap` registry has no `as PREFIX` and no `_KEY` constants - its keys are not known when the -game compiles - takes no `def`, and is never `open`. The compiler writes, in the declaring module and -exported with the registry, a `state` named after it and its verbs, prefixed with the registry's name -in snake case (`GroundLayers` -> `ground_layers_`): - -```ludic -# doc-check: skip — what the compiler writes for the two registries above -state Props { rows: []PropRow, map: string, err: string, ... } -props_load(st: mut Props, map: string) -> bool # //props.lres; false + st.err if missing or wrong -props_clear(st: mut Props) -> void -props_find(st: Props, key: string) -> int # the row's index, or -1 -props_path(st: Props, rel: string) -> string # "//", for an asset a row names - -property InstancesChunk { cx: int, cz: int, on: bool, rows: []InstRow, ... } -state Instances { chunks: []InstancesChunk, map: string, err: string, ... } -instances_in(st: mut Instances, map: string, cx: int, cz: int) -> int # its slot; no file is an empty chunk, a wrong one -1 + st.err -instances_out(st: mut Instances, cx: int, cz: int) -> void -instances_slot(st: Instances, cx: int, cz: int) -> int # -1 when that chunk is not in -instances_find(st: Instances, slot: int, key: string) -> int # a row of that slot, or -1 -instances_clear(st: mut Instances) -> void -instances_path(st: Instances, rel: string) -> string -``` - -Read `props.rows[i]` and `len(props.rows)`, `st.chunks[s].rows`. An error is `"file:line:col: what"` -(`maps/alpha/props.lres:2:18: PropRow has no field colour`, `unknown constant MDL_TENT3`), and a -failed load leaves the table empty, never half filled. `_find` is a hash over the row keys, rebuilt -in place on every load: O(1) and allocation-free. `_in` for a chunk that is already in hands back its -slot; `_in` with another map than the one the chunks came from puts every chunk out first. `_path` -makes one string: it is for load time, not a frame. A row's id for co-op is `(map, key)` for a -whole-map table and `(cx, cz, key)` for a chunked one - both stable, because they are data. - -**The table owns its rows, and everything in them.** Every row record, every list inside a row and -every record in such a list is pooled: a reload of the map, or a chunk slot refilled, resets and -refills them in place - the rows list and each row's lists are emptied and refilled, each record set -back to its record's defaults (a template made once) - and a record is made only when a load needs -more of its type than any load before it. Nothing is allocated past that high water, so a frame may -call `instances_in`. Never keep a row, a row's list or a chunk's rows across a load or an `_out`: keep -the key or the index, or copy the numbers. A string field, and a whole-map table's key, is interned -and safe to keep. A CHUNKED table's keys are unique across its map (`t0` .. `t91842`: a row's id is -`(map, key)`, and a tree moved into another chunk keeps it; `ludicc --check` refuses a key written in -two chunk files, naming both), so they are NOT interned - interning every key a player walks past would -fill the bounded intern table and keep them all: a slot's keys live in the slot's own buffers, -rewritten when the slot is refilled. Keep such a key past `_out` with `intern(row.key)`. A record may hold a record of its -own type only through a list. The reader -(the runtime's `lres.ludic`) keeps its own buffers the same way: the file's bytes in one that grows -only for a bigger file, its tree as parallel lists reused from file to file. - -`@Ref(T)` where `T` is a `@PerMap` registry goes on a `string` field holding the row's key (an `int` -field is refused: "use a string key"); its schema attribute says `"scope": "map"`. - -**Every map is checked with the program.** `ludicc --check` (and `ludic build --check`) reads every -directory under the maps root as a map and checks, with the compiler's own resource parser, each -registry's file in it (each file a chunked pattern matches) against the record: the field exists, -its value has the field's type, a constant it names exists, `fn name` names a function of the -field's type, `@OneOf` and `@Range` hold, an `@Ref` into a game registry is in range, and an `@Ref` -into a `@PerMap` registry names a row of that table in the same map (in any of its chunks; `""` is -none). A key is written once in a file. Errors carry the map file's line and column, through the -diagnostics as any other (`--diagnostics=json`); `--no-maps` leaves the maps alone, and no maps -root is nothing to check. `ludic build --check` reads them only for the package's `entry` (the game): -a partial program - a unit test, a molecule, a bake's runner - lacks the game's constants and cannot -judge them, so they are left alone there unless `--maps` asks. `LUDIC_PERMAP_SRC=` appends what the compiler wrote for each table. - -### Editor attributes and the schema (`@Ref`, `@Range`, ..., `ludic schema`) - -A field can say what an editor of the data should offer for it, and a registry how its entries may -change, on the same `@` a field's `@max(64)` is written with - one or several, on the field's line or -the lines above it: - -```ludic -# doc-check: skip — the registries it names are declared elsewhere -property Tool { - @Ref(Vendors) seller: int = 0 # an index into that registry: its entries are offered - @OneOf(GR_) grade: int = 0 # one of the constants whose names start GR_ - @OneOf(GR_GOLD, GR_SILVER) medal: int = 0 # or one of these constants - @Range(0, 20.5) @Unit("kg") weight: float = 1.0 - @Asset("gltf") model: string = "" # a file of that kind (any string) - @Asset("png", map) density: string = "" # a path under EACH map's directory (assets/maps//...) - @Color tint: int = 0 - @Node(model) grip: string = "" # a node inside the glTF that `model` names - @Clip(model) swing: string = "" # a clip inside it - @Material(model) finish: string = "" # a material inside it - @OneOf("box", "hull") shape: string = "" # a string field: one of these words - @Color @Tint(TSLOT_SHELL) shell: int = 0 # a colour for that tint slot - @Derived reach: float = 0.0 # worked out at boot: anything written is overwritten - @Text @Multiline blurb: string = "" # read by the player (so translated), and prose - @Key bind: int = 0 # a key code -} -@AppendOnly @ByKey -registry Tools of Tool as TL from "data/tools.lres" -``` - -`@Unit` takes one of the canonical ASCII spellings - `m`, `m/s`, `m/s2`, `s`, `min`, `h`, `d`, `deg`, -`rad`, `rad/s`, `kg`, `N`, `N.m`, `%`, `px` - so one word means one unit to an editor and a converter: -the angle is `"deg"`, never `"°"`. Any other spelling is a warning naming the canonical one where -there is an obvious one (`"°"`, `"degrees"` -> `deg`, `"sec"` -> `s`, `"m/s^2"` -> `m/s2`, `"Nm"` -> -`N.m`), else listing them; the schema carries the list as `"units"`. - -They change nothing the program does. A target that no part of the program declares - the registry -an `@Ref` names, the constant of an `@Tint` or a listed `@OneOf` - is a warning, and the schema marks -the attribute `"unresolved": true`: a package can name the game's registry without importing it. -Everything else is an error, every one reported: a target that exists but is another kind -(`@Ref(Vendor)` on a record); `@OneOf` of the wrong kind for its field - on a string field the -arguments are words (`@OneOf("box", "hull")`) and every registry row's value must be one of them, on -any other field a prefix ending in `_` or constants; and `@Node(f)`, `@Clip(f)` or `@Material(f)` -naming anything but a field `f` of the same record that is `@Asset("gltf")`, or an `@Ref` to a -registry whose record has exactly one `@Asset("gltf")` field (the model is then the row's). `@Asset(kind, map)` names a file under each map's -directory, not the game's root: `ludicc --check` looks for it in every map - a @PerMap row's in its own -map, a game-wide row's in all of them - and refuses a map that lacks it, unless the field says -`@Asset(kind, map, optional)`; the schema marks it `"scope": "map"`. A plain `@Asset(kind)` is not -looked for. - -`ludicc app.ludic --emit-schema out.json` (or `ludic schema [file] [-o out.json]`) writes what the -compiler resolved, once the types are checked and every open registry has its entries, as one JSON -object with `"schema_version": 1`: - -- `records` - every `property`, `state` and `event`: its module, file, line and column, its doc - comment (the comment lines above it, else the one ending its line), and its fields, each with its - type as text, its default as written (or null), its doc, its place and its attributes - (`[{"name": "Range", "args": [0, 20.5]}]`); -- `registries` - every registry: its record, prefix (null for a `@PerMap` one), resource file, - whether it is open, its `"scope"` (`"map"` for `@PerMap`, else `"game"`), its `"chunk"` size (or - null) and, for a map-scoped one, the `"maps"` root; its own attributes, and its entries in their final index order (`{"key": "axe", "constant": "TL_AXE", - "index": 0, "file": ..., "line": ..., "col": ..., "fields": [{"name", "value", "file", "line", - "col"}]}`), with `contributors`: which resource file or file of `def`s brought which keys in; -- `consts` - every const: its type, its value as written, its module and doc; -- `functions` - every function a `fn` value can name, exported or not: its module, return type, - `params` (the arguments a caller passes), `states` (what the runtime supplies), `signature`, and - `fn_type` - the type a `fn` field sees, the states stripped, spelled as a field's type is - (`fn(NpcPerson,float)->bool`), so matching a function to a field is comparing two strings. -- `components` - every UI component (see Components): its module, place, doc, `xml` and `lss` (the - template's and the stylesheet's paths, `lss` null when it has none); `props` and `state`, the - instance's own fields in the order written, each with its type, its default as written (or null), - its place and doc (a prop's `attributes` are `[]`: a component's members take none); `states_read`, - the program's states its header names, which the runtime supplies and the template never sees; - `derived`, the fields worked out each frame, with their types (an inferred one resolved); - `functions` (`{"name", "params": [["i", "int"]], "result"}`) and `events` (its `on` handlers, - `{"name", "params"}`) as the template calls them, the header's states and the instance stripped; - and `natives`, the registered native tags its template uses as elements; -- `natives` - every `ui_native` / `ui_native_input` call whose tag is a string literal: - `{"tag", "via", "handler", "module", "file", "line", "col"}`, `via` the function called and - `handler` the `fn` value it passes last (else null). A tag worked out at run time cannot be known - and is left out. -- `units` - `@Unit`'s canonical spellings. -- `lang` - the text keys and the languages (see Text keys), or null without a `lang` line. -- `code` - the program's code map, so an editor needs no scan of its own. Every place is - `"file:line:col"` (`"at"`); what the runtime declares and what the compiler writes itself are left - out. It holds: - - `modules` - `{"name", "package", "layer", "uses", "uses_at", "friend", "files"}`: `uses` null - without a `uses` line, `friend` null, or `{"of": null}` for a friend of every module and - `{"of": [...]}` for `friend module lab of a, b`; `files` its source files and the resource files - read into its registries; - - `states` (`{"name", "module", "at"}`), `actions` (`{"name", "module", "at", "target"}`, - `target` its `@Target` field or null), `reducers` (`{"state", "action", "module", "at"}`, a - state's), `row_reducers` (`{"record", "table", "state", "action", "target", "predicted", "net", - "module", "at"}`: `table` the path as written, `State.field`; `predicted` false and `net` null - until `@Predicted` and `@Net` reach reducers), `row_verbs` (`{"name", "record", "module", "at"}`) - and `dispatch` (every `dispatch`: `{"action", "module", "at"}`); - - `events` - `{"name", "module", "at", "cancellable", "net"}` (`net` `"toserver"` for - `@ToServer`, `"toclients"` for `@ToClients`, else null), its `emits` (every `emit`'s place) and - its `listeners` (the `@On` handlers: `{"handler", "module", "at"}`); - - `ports` - `{"name", "module", "at", "bound", "members"}`, each member `{"name", "type", "default", - "required"}` (its `fn` type as a field's is spelled, its default as written); and `binds` - one - row per member a `bind` gives: `{"port", "member", "fn", "value", "module", "at"}`, `fn` the - function a `fn name` names (null for a variable bound to a member that takes nothing) and - `value` as written; - - `handlers` - `{"name", "module", "at", "phase", "hook", "target", "public", "net", "queries", - "scene", "layer"}`: `phase` null for a hook, `hook` null or `"On"`, `"OnSpawn"`, `"OnDespawn"`, - `"OnAttach"`, `"OnDetach"`, `"OnEnable"`, `"OnDisable"`, `"OnStart"`, `"OnQuit"` with `target` - the event, model or property it names; `net` `"server"` (`@Server`), `"predicted"` - (`@Predicted`) or null; `queries` null or `{"these": [{"property", "filter"}], "on"}` (a - filter as written, or null); a scene's handler has its name as written and its `scene` and - `layer`; - - `models` (`{"name", "module", "at", "owned", "properties": [{"name", "sync"}]}`), `prefabs` - (`{"name", "model", "module", "at", "components": [{"property", "fields": [{"name", - "value"}]}]}`) and `scenes` (`{"name", "module", "at", "start", "public", "shows", "lasts", - "then", "loads", "enter", "exit", "layers"}`: `lasts` as written, `then` the scene it goes on to, - `loads` the scene a `loads then` one goes on to, `enter` / `exit` whether it has the block); - - `fn_refs` - every `fn name` written, in code or in a resource file: `{"fn", "module", "at", - "slot_kind", "slot", "entry"}`, the slot it fills: `"registry"` (`"Registry.field"`, `entry` the - row's key), `"port"` (a bind's `"Port.member"`), `"port_default"`, `"default"` (a record - field's default, `"Type.field"`), `"record"` (a `new` or a spawn's `"Type.field"`), `"event"` - (an `emit`'s `"Event.field"`), `"arg"` (`"callee(i)"`, the i-th argument written), `"assign"` - (`s.tick = fn f`: `"s.tick"`), `"let"` (the name), or `"value"` with a null slot. - - Named lists are sorted by name (then file and line), sites by place. - -Each list is sorted by name (then file and line), a registry's entries are in index order, and the -paths are the ones the compiler read, so two runs over the same source write the same file. The -engine's runtime is left out. - -`Build.schema_hash()` is a `long`: FNV-1a 64 of exactly those bytes, for the program being built, -worked out once and only when the program names it - so a tool talking to a running build can tell -whether it was compiled from the data in front of it. It is 0 in a release (`ludicc --release`, -which `ludic bundle` passes). - -### Text keys (`Key`, `k"..."`, `lang`) - -A text the player reads is named by a KEY, and the key's English is a language file like any -other's. `package.ludic` says where the languages are and which is the source: - -``` -lang "assets/lang" en # the directory, and the source language: assets/lang/en.po -``` - -In code a key is a literal of the builtin type `Key` - `k"module.purpose"`, or `kn"..."` for a key -whose text has plural forms - written against its quote (`k "x"` is a name and a string, as ever): - -```ludic -# doc-check: skip — tr / trf / trn are ludic.i18n's -let title = tr(k"pause.resume") # tr(key: Key) -> string -let day = trf(k"hud.day", txt_num(n)) # the English's holes {1}..{4}, in order -let got = trn(kn"catch.count", n, fish) # {1} is the count, the rest follow -``` - -A `Key` is not a `string`, and a `string` is not a `Key`: giving one where the other is wanted is an -error either way, and so is `+` on a key. Keys compare with `==` and `!=`, and are fields, parameters, -results, list elements and registry values like any other type (`null` is none). At run time a key is -its text after a marker byte - byte 1, or 2 for `kn"..."` - so `k"pause.resume"` is the string -`"\x01pause.resume"`: the runtime's `tr` is a cast, and a translator knows a key from English. -`string(key)` gives that marked text, for a runtime that needs it; a program itself makes text with -`tr`, and a key in a template literal's hole (`` `at {k}` ``) is an error - write `trf(k"...", ...)`. -`trn` takes a plural key and `tr` / `trf` refuse one, with or without a `lang` line. (A program that -declares its own type named `Key` keeps it; the builtin is then out of reach.) - -**A package's own words** are keys too. ludic.ui's key field says "Right click" as `ui_tk(ui_st, -k"ui.right_click", "Right click")`: translated through the program's translator when one is bound, the -plain text otherwise (a program with no languages). A program with a `lang` line that imports ludic.ui -therefore carries `ui.right_click`, `ui.middle_click`, `ui.left_click` and `ui.press_key` in its -source `.po`, and the check says so if it does not. - -**The data.** A registry's `@Text` field is text by derivation: its key is -`..` - the registry's name in snake case (`Items` -> `items`, `GearKinds` -> -`gear_kinds`), a list field adding `.` and a nested record `.` - or, with -`@TextKey("steps")` on the registry, `steps..`. A `@PerMap` table's is -`maps....`. When the field's type is `Key` and a row of a compiled registry -gives it no value, the compiler fills in the derived key (marked, as a literal is), so code writes -`tr(Items[i].name)` and the `.lres` never spells a key. The same holds below the row: a nested -record's field adds `.` and a list item `.` (`disruptions..arrive.steps.0.text`), and a -`@Text []Key` a row leaves out takes `<...>.0`, `.1`, ... for as many as the source `.po` has; `field: -null` is no text, and is not checked; a row may still name one, `name: -k"items.lamp.name"`, in a resource file or a map's file alike. - -```ludic -# doc-check: skip — the data file is the game's -property Item { - key: string = "" - @Text name: Key = null # items..name, filled in when the row gives none -} -registry Items of Item as IT from "data/items.lres" -@TextKey("steps") registry StepKinds of StepKind from "steps.lres" # steps.. -``` - -**The templates.** A component's text is a key too: `{t('pause.resume')}`, `title="{t('pause.close')}"`, -`{t('hud.day', day)}`; `t(expr)` takes a key worked out at run time (a `Key` field reaches a template -as its marked text). Words outside an element with `translate="no"` are English still waiting for a -key. - -**The checks.** With a `lang` line and its source `.po` there, the compiler holds every key to it, -each diagnostic at its `file:line:col`: - -- a `k"..."` literal, a template's `t('...')` literal, or a key a data row names or derives that the - source `.po` does not have is an **error**; so is a `kn"..."` whose entry has no `msgid_plural`; -- a call of `trf` or `trn` with a key literal, and a template's `t('key', ...)`, gives as many values - after the key as the English's highest hole `{n}` (`trn`'s count is `{1}`; trailing `""` literals - are padding and not counted), or it is a **warning**; -- **English left** - a template's words outside `translate="no"`, a text attribute's words (`title`, - `label`, `hint`, `text`, `caption`, and any other whose value is not a keyword), a quoted choice - inside a hole that reads as words, and a `@Text` row whose value is still English - is an - **error** (a warning until the migration was done; `ludic deps` still counts it as `english_left`); -- one English under several keys (a split) wants a `#.` description on each, for a translator to - tell them apart: a **warning** at the `.po`'s line. - -With no `lang` line nothing is checked and nothing is said (a package test uses key literals freely); a -`lang` line whose source `.po` is missing checks nothing, and the first key literal says so once. -The source `.po` is gettext's: `msgid` (the key), `msgid_plural` and `msgstr[n]`, `#,` flags -(`fuzzy`), `#.` descriptions, `msgctxt` (read and ignored: the key is the context), strings over several lines; `#~` -entries are obsolete. `ludic schema` carries a `"lang"` object (null without a `lang` line): every -key used with its kind (`code`, `template`, `data`), its sites, its English, whether it is a plural, -and its description; `unused` (the source's keys used nowhere), `undescribed`, `split`, and per other -language in the directory its `missing`, `fuzzy` and `extra` keys. - -### Default parameters, and calls that name what they change - -A parameter can have a default, and a call leaves out what it does not change - the last ones when -it passes arguments by position, or any it does not name. A call can pass its first arguments by -position and the rest by name: - -```ludic -program Boxes { - numbers float - function box(label: string, w: float = 300.0, pad: float = 8.0, bold: bool = false) -> string { - return `{label} {w} {pad} {bold}` - } - entry { - print(box("a")) # every default - print(box("b", 120.0)) # the first two by position - print(box("c", bold: true)) # the subject by position, a prop by name - } -} -``` - -A default is an expression written with the function and evaluated for each call that leaves it -out. A call that leaves out a parameter with no default, names one the function does not have, or -puts a positional argument after a named one is refused with that said. - -### Components and templates (`ludic.ui`) - -A UI is components, and a component is three files side by side: -- `Name.ludic` declares what it takes, keeps and does; -- `Name.xml` is its HTML-shaped template; -- `Name.lss` holds its CSS-shaped styles, which apply to its own elements only. - -```ludic -# doc-check: skip — a fragment of a program that imports ludic.ui -component Counter { - prop label: string # its parent passes it: - prop step: int = 1 # with a default - state count: int = 0 # the instance's own, kept while it is on screen - doubled: int = count * 2 # worked out every frame - function big() -> bool { return count > 3 } # callable from the template - on bump() { count += step } # an event: on-click="bump()" -} -``` - -- **Props and state** are plain names in the component's code; each mounted instance is a record - of them. A prop or state is an `int`, a `float`, a `bool`, a `string` or a `Val`. A field is - anything a template can read, and records and lists become objects and lists. -- **The template** has one root (use `` for several). A component is used by its name as - a tag. `set count = 0` in an action sets its state, and `emit close` runs what its parent passed - as `on-close`. `class`, `style` and `id` on a component's tag land on its root element, styled by - the parent's sheet as well as its own; when that root is itself a component, every user in turn - passes theirs down (the outermost's `id` wins). The rules of all those sheets that match the root - are weighed together by specificity, as one sheet's are, and a user's rule wins a tie. -- **A component's names.** An event may be called anything, `set` included (`on set(v: int)`, - pressed as `set(4)`; `set x = ...` is still the action). A `string` prop given a number reads it - as text (`label="{3}"` is `"3"`). Two components of one name are an error that names both files. -- **Compiled in.** The compiler reads the template and the styles from beside the declaration and - inlines each `@import` (a path starting with `/` is from the project's root), so a missing - template fails the build and nothing has to ship beside the program. `ui_reload()` reads the - files again when they change on disk and keeps every instance's state. -- **A screen is a component** with no props: `ui_show("Counter", null, x, y, w, h)`, or - `ui_nodes("Counter", null)` for a test. -- **States in the header.** A component that reads the program's data names the states it reads - and changes after its name, as an entry point does: - - ```ludic - # doc-check: skip — a fragment of a program that imports ludic.ui - component Tally (score: mut Score, look: Look) { - total: int = score.points - function big() -> bool { return total > look.big } - on add(n: int) { score.points += n } - } - ``` - - Every getter, default, function and event takes them before the instance; a member's call to - another passes them on; ludic.ui's calls into the component are given the instances; and the - template never sees them - `on-click="add(5)"` passes only `5`. A header state without `mut` is - read-only in every member. - -An older, lighter bridge remains: `view Name { ... }` gives a whole template file of ``s -and ``s (loaded with `ui_load`) one model and one call, and the rest of this section -applies to both: - -```xml - - - - - The purse: ${purse} - - - - - - Nothing bought yet - {owned} bought - - -``` - -- **Elements.** A template is HTML-shaped: - - `div`, `section`, `header`, `footer`, `nav`, `main`, `article`, `aside`, `ul`, `ol`, `li` and - `form` are boxes laid out in a column; `row` is one laid out in a row. - - `p`, `span`, `label`, `h1`-`h6`, `strong`, `em`, `small`, `b`, `i` and `a` are text; words - inside a box become a text of their own. - - `button`, `img src`, `hr` and `spacer`; `scroll` is a column that scrolls. - - A default stylesheet, like a browser's, sizes the headings and pads the buttons. -- **Attributes.** `id`, `class` (which may be bound: `class="{picked ? 'on' : ''} row"`), - `style="padding: 4px; color: gold"`, `hidden`, `disabled`, `onclick` or `on-click` (also - `on-press`), and any attribute a property is named after (`width="300"`). Any other attribute is - kept for `[attr=value]` selectors, as HTML's are. -- **The box model and flex.** Sizes are border boxes. - - `padding` and `margin` take one to four lengths, or one side by name (`padding-left`). - - `border` is `2px solid #ffcc00`, or `border-width` and `border-color`. - - A length is `12`, `12px`, `50%`, `fit`/`auto`, `fill` or `calc()`. `width: 0` and `height: 0` - are 0, not unset. - - `calc()` takes `+`, `-`, `*` and `/` and brackets over `px`, `em`, `rem`, `vw`, `vh`, plain - numbers and `var()`; in a `width`, `height`, `top`, `right`, `bottom` or `left` it may also hold - a percentage of the room or of the containing block (`calc(50% - 10px)`). `min()`, `max()` and - `clamp(least, want, most)` pick among lengths, inside `calc()` or on their own - (`width: min(300px, 50vw)`); a percentage cannot be compared there. - - `top`, `right`, `bottom` and `left` take a percentage of the containing block: its width for - left and right, its height for top and bottom. - - `flex-grow` (or `flex`) shares out the spare room along `flex-direction`, and `fill` is a share - of 1. `justify-content` takes `flex-start`/`start`, `center`, `end`, `space-between`, - `space-around` or `space-evenly`. - - `align-items` and `align-self` take `start`, `center`, `end` or `stretch`. `flex-wrap: wrap` - breaks a row into lines, and `min-`/`max-width`/`-height` bound it. - - `flex-shrink` gives up room when a line's children want more than it has, each in proportion - to its shrink times its size, and none below its min size, a fixed size or its content. A scroll - box shrinks (and scrolls) and a box in a column shrinks as far as the scroll boxes in it let it, - so a list in a column takes the room its siblings leave with no height of its own. `flex: 1 0` - is the grow and the shrink. `order` rearranges a box's children without touching the tree. - - `gap`, `text-align`, `display: none`, `background(-color)`, `color`, `opacity` and `font-size` - are CSS's. - - Every property also has a short name (`w`, `h`, `pad`, `bg`, `size`, `grow`, `align`, - `justify`, `self`, `dir`, `wrap`, `alpha`). Rounded corners, font weight and a few more are - things the renderer does not draw, and the runtime says so rather than ignoring them. A colour - is the renderer's name for one, `#rrggbb` or `#rgb`. -- **Stylesheets.** Rules go in a `

a

b

c

" - function at(ui_st: mut UiState) -> string { - ui_show(ui_st, "a", view_anim(), 0.0, 0.0, 200.0, 200.0) - let r: UiNode = ui_nodes(ui_st, "a", view_anim()) - return `{int(r.children[0].alpha * 100.0)} {int(r.children[1].alpha * 100.0)} {int(r.children[2].alpha * 100.0)}` - } - entry (ui_anim_st: mut UiAnimState, ui_st: mut UiState) { - ui_load_text(ui_st, PAGE, "a.xml") - var out = at(ui_st) - for i in 0 .. 29 { at(ui_st) } - out = out + " | " + at(ui_st) - ui_anim_st.dim = true - for i in 0 .. 29 { at(ui_st) } - out = out + " | " + at(ui_st) - print(out) - } -} diff --git a/examples/library/ui_box.ludic b/examples/library/ui_box.ludic deleted file mode 100644 index 2f8f29ff..00000000 --- a/examples/library/ui_box.ludic +++ /dev/null @@ -1,19 +0,0 @@ -# ui_box.ludic — what a host reads from outside the interface: ui_scale(), the scale it is drawn at -# (the renderer's), and ui_box("id"), where an element was laid out when its screen was last shown, -# in screen pixels (null for one that is not there) - to frame a 3D view beside a panel. -import "ludic.ui" -program UiBox { - numbers float - const PAGE: string = "

title

" - function scale() -> float { return 1.5 } - entry (ui_st: mut UiState) { - let b = new UiBackend - b.scale = fn scale - ui_backend(ui_st, b) - ui_load_text(ui_st, PAGE, "b.xml") - ui_show(ui_st, "b", null, 20.0, 30.0, 800.0, 600.0) - let p = ui_box(ui_st, "panel") - let none = ui_box(ui_st, "nothing") - print(`scale {ui_scale(ui_st)} | panel {int(p.x)},{int(p.y)} {int(p.w)}x{int(p.h)} | missing {none == null}`) - } -} diff --git a/examples/library/ui_boxes.ludic b/examples/library/ui_boxes.ludic deleted file mode 100644 index 7242d520..00000000 --- a/examples/library/ui_boxes.ludic +++ /dev/null @@ -1,25 +0,0 @@ -# ui_boxes.ludic — ludic.ui's flex and sizes: a scroll box in a column shrinks to the room its -# siblings leave (flex-shrink) and scrolls what it cannot show; text in a row wraps in the room left -# after the icon beside it; width and height 0 are 0; calc() mixes a percentage with lengths; order -# rearranges a row; and a range's track is a row, its fill as tall as the track. -import "ludic.ui" -import "ui_boxes_parts/Boxes.ludic" -program UiBoxes { - numbers float - entry (ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - ui_show(ui_st, "Boxes", null, 0.0, 0.0, 400.0, 600.0) - let root: UiNode = ui_nodes(ui_st, "Boxes", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 600.0) - let page = root.children[0] - let list = page.children[0].children[1] - let line = page.children[1] - let text = line.children[1] - let zero = page.children[2] - let half = page.children[3] - let ord = page.children[4] - let track = page.children[5].children[0] - let fill = track.children[0] - print(`list {int(list.ch)} of {int(list.content_h)} | text at {int(text.x)} {int(text.cw)} wide, {len(text.lines)} lines, row {int(line.ch)} | zero {int(zero.cw)}x{int(zero.ch)} | half {int(half.cw)}x{int(half.ch)} pad {int(half.pt)} {int(half.pr)} | order {ord.children[0].text}{ord.children[1].text}{ord.children[2].text} at {int(ord.children[2].x)} | fill {int(fill.cw)}x{int(fill.ch)} of {int(track.cw)}x{int(track.ch)}`) - } -} diff --git a/examples/library/ui_boxes_parts/Boxes.lss b/examples/library/ui_boxes_parts/Boxes.lss deleted file mode 100644 index 30bfda09..00000000 --- a/examples/library/ui_boxes_parts/Boxes.lss +++ /dev/null @@ -1,14 +0,0 @@ -.page { width: 300px; gap: 4px } -.panel { height: 200px; gap: 10px } -.bar { height: 30px } -.list { overflow: auto } -.item { height: 20px } -.line { width: 200px; gap: 8px } -.line img { width: 24px; height: 24px } -.zero { width: 0; height: 0 } -.half { --gap: 10px; width: calc(50% - var(--gap)); height: calc(2 * var(--gap) + 1em); padding: calc(1em / 4) 2px } -.ordered { gap: 0 } -.a { order: 1 } -.b { order: 2 } -.c { order: 3 } -.ui-track { height: 12px; padding: 2px } diff --git a/examples/library/ui_boxes_parts/Boxes.ludic b/examples/library/ui_boxes_parts/Boxes.ludic deleted file mode 100644 index e48029a5..00000000 --- a/examples/library/ui_boxes_parts/Boxes.ludic +++ /dev/null @@ -1,5 +0,0 @@ -# Boxes.ludic - a panel with a list that takes the room its header and footer leave, a line of text -# beside an icon, zero sizes, calc(), order, and a range's track -component Boxes { - state rows: int = 10 -} diff --git a/examples/library/ui_boxes_parts/Boxes.xml b/examples/library/ui_boxes_parts/Boxes.xml deleted file mode 100644 index 1f25e02c..00000000 --- a/examples/library/ui_boxes_parts/Boxes.xml +++ /dev/null @@ -1,12 +0,0 @@ -
-
-

Head

-

{r}

-

Foot

-
-

A line long enough that it has to wrap beside the icon

-

hidden

-
- cab - -
diff --git a/examples/library/ui_calc.ludic b/examples/library/ui_calc.ludic deleted file mode 100644 index 7a4eea33..00000000 --- a/examples/library/ui_calc.ludic +++ /dev/null @@ -1,20 +0,0 @@ -# ui_calc.ludic — min(), max() and clamp() (on their own and inside calc()), and top, right, bottom -# and left as percentages and calc() of the containing block, for absolute and relative elements. -import "ludic.ui" -program UiCalc { - numbers float - const STYLE: string = "" - const BODY: string = "
" - entry (ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - ui_load_text(ui_st, "" + STYLE + BODY + "", "c.xml") - ui_show(ui_st, "c", null, 0.0, 0.0, 400.0, 600.0) - let r: UiNode = ui_nodes(ui_st, "c", null) - ui_place(ui_st, r, 0.0, 0.0, 400.0, 600.0) - let box = r.children[4] - let p = box.children[0] - let q = box.children[1] - let rel = box.children[2] - print(`min {int(r.children[0].cw)} max {int(r.children[1].cw)} clamp {int(r.children[2].cw)} {int(r.children[3].ch)} | p {int(p.x - box.x)},{int(p.y - box.y)} q {int(q.x - box.x)},{int(q.y - box.y)} r {int(rel.x - box.x)}`) - } -} diff --git a/examples/library/ui_component.ludic b/examples/library/ui_component.ludic deleted file mode 100644 index cac2a70b..00000000 --- a/examples/library/ui_component.ludic +++ /dev/null @@ -1,37 +0,0 @@ -# ui_component.ludic — components as files: Counter.ludic declares props, state, a field, a function -# and an event; Counter.xml is its template; Counter.lss its scoped styles (which @import a theme). -# Two counters keep their own state, a parent's class reaches the child's root, and the child's -# `p { color }` does not leak into the parent. -import "ludic.ui" -import "ui_component_parts/Counter.ludic" -import "ui_component_parts/App.ludic" -program UiComponent { - numbers float - function texts(n: UiNode, out: []string) -> void { - if n.text != "" { push(out, n.text) } - for i in 0 .. len(n.children) { texts(n.children[i], out) } - } - function frame(ui_st: mut UiState) -> UiNode { - let root: UiNode = ui_nodes(ui_st, "App", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root - } - entry (ui_st: mut UiState) { - var root = frame(ui_st) - let b = root.children[0].children[2] - ui_press(ui_st, b.children[1]) - ui_press(ui_st, b.children[1]) - ui_press(ui_st, root.children[0].children[1].children[1]) - root = frame(ui_st) - let out = new []string - texts(root, out) - var joined = "" - for i in 0 .. len(out) { joined = joined + out[i] + "|" } - print(joined) - let b2 = root.children[0].children[2] - print(`b margin {int(b2.mt)}; b text {b2.children[0].fg}; outside {root.children[0].children[3].fg}; button {int(b2.children[1].ch)}`) - ui_press(ui_st, b2.children[2]) - root = frame(ui_st) - print(root.children[0].children[2].children[0].text) - } -} diff --git a/examples/library/ui_component_parts/App.lss b/examples/library/ui_component_parts/App.lss deleted file mode 100644 index 29c72305..00000000 --- a/examples/library/ui_component_parts/App.lss +++ /dev/null @@ -1 +0,0 @@ -.wide { margin: 9px } diff --git a/examples/library/ui_component_parts/App.ludic b/examples/library/ui_component_parts/App.ludic deleted file mode 100644 index 788d54e7..00000000 --- a/examples/library/ui_component_parts/App.ludic +++ /dev/null @@ -1,4 +0,0 @@ -# App.ludic — a screen is a component too: this one has no props, only what it shows -component App { - title: string = "counters" -} diff --git a/examples/library/ui_component_parts/App.xml b/examples/library/ui_component_parts/App.xml deleted file mode 100644 index afd44365..00000000 --- a/examples/library/ui_component_parts/App.xml +++ /dev/null @@ -1,6 +0,0 @@ -
-

{title}

- - -

outside

-
diff --git a/examples/library/ui_component_parts/Counter.lss b/examples/library/ui_component_parts/Counter.lss deleted file mode 100644 index 15174c36..00000000 --- a/examples/library/ui_component_parts/Counter.lss +++ /dev/null @@ -1,3 +0,0 @@ -@import "theme.lss"; -.counter { padding: 4px } -p { color: #ff0000 } diff --git a/examples/library/ui_component_parts/Counter.ludic b/examples/library/ui_component_parts/Counter.ludic deleted file mode 100644 index 8a677ed8..00000000 --- a/examples/library/ui_component_parts/Counter.ludic +++ /dev/null @@ -1,9 +0,0 @@ -# Counter.ludic — a component: what it takes (props), keeps (state), works out and does -component Counter { - prop label: string - prop step: int = 1 # how much one press adds - state count: int = 0 - doubled: int = count * 2 - function big() -> bool { return count > 3 } - on bump() { count += step } -} diff --git a/examples/library/ui_component_parts/Counter.xml b/examples/library/ui_component_parts/Counter.xml deleted file mode 100644 index 617de5aa..00000000 --- a/examples/library/ui_component_parts/Counter.xml +++ /dev/null @@ -1,5 +0,0 @@ -
-

{label}: {count} ({doubled}){big() ? ' big' : ''}

- - -
diff --git a/examples/library/ui_component_parts/theme.lss b/examples/library/ui_component_parts/theme.lss deleted file mode 100644 index c7297243..00000000 --- a/examples/library/ui_component_parts/theme.lss +++ /dev/null @@ -1,2 +0,0 @@ -/* theme.lss - shared by whoever imports it */ -button { height: 30px } diff --git a/examples/library/ui_controls.ludic b/examples/library/ui_controls.ludic deleted file mode 100644 index 7e4508a7..00000000 --- a/examples/library/ui_controls.ludic +++ /dev/null @@ -1,71 +0,0 @@ -# ui_controls.ludic — ludic.ui's own controls, driven as a player would drive them: Tab and the -# arrows move the focus, Enter presses and flips, the arrows move a range and step a select, typing -# fills a text field and Backspace takes a letter back, a key field takes the next key, the pointer -# drags a range, and the wheel scrolls a box. Every change reaches the component as an event. -import "ludic.ui" -import "ui_controls_parts/Settings.ludic" -program UiControls { - numbers float - function frame(ui_st: mut UiState, i: UiInput) -> UiNode { - ui_input(ui_st, i) - ui_show(ui_st, "Settings", null, 0.0, 0.0, 400.0, 400.0) - let root: UiNode = ui_nodes(ui_st, "Settings", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root - } - function press(tab: bool, down: bool, right: bool, enter: bool) -> UiInput { - let i = new UiInput - i.tab = tab - i.down_key = down - i.right = right - i.enter = enter - return i - } - function state(root: UiNode) -> string { - let page = root.children[0] - let last = page.children[len(page.children) - 1] - return last.text - } - entry (ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - var root = frame(ui_st, new UiInput) - root = frame(ui_st, press(true, false, false, false)) - root = frame(ui_st, press(false, false, false, true)) - root = frame(ui_st, press(false, true, false, false)) - root = frame(ui_st, press(false, false, false, true)) - root = frame(ui_st, press(false, true, false, false)) - root = frame(ui_st, press(false, false, true, false)) - root = frame(ui_st, press(false, false, true, false)) - root = frame(ui_st, press(false, true, false, false)) - root = frame(ui_st, press(false, false, true, false)) - root = frame(ui_st, press(false, true, false, false)) - let typed = new UiInput - typed.typed = "lovelace" - root = frame(ui_st, typed) - let back = new UiInput - back.backspace = true - root = frame(ui_st, back) - root = frame(ui_st, press(false, true, false, false)) - root = frame(ui_st, press(false, false, false, true)) - let k = new UiInput - k.key = 70 - root = frame(ui_st, k) - print(state(root)) - let range = root.children[0].children[2] - let track = range.children[1] - let at = new UiInput - at.x = track.x + track.cw * 0.8 - at.y = track.y + 2.0 - at.down = true - root = frame(ui_st, at) - root = frame(ui_st, new UiInput) - let list = root.children[0].children[6] - let w = new UiInput - w.x = list.x + 5.0 - w.y = list.y + 5.0 - w.wheel = -1.0 - root = frame(ui_st, w) - root = frame(ui_st, new UiInput) - print(`{state(root)}; scrolled {int(root.children[0].children[6].scroll_y)}`) - } -} diff --git a/examples/library/ui_controls_parts/Settings.lss b/examples/library/ui_controls_parts/Settings.lss deleted file mode 100644 index cb956a07..00000000 --- a/examples/library/ui_controls_parts/Settings.lss +++ /dev/null @@ -1,4 +0,0 @@ -.page { width: 400px; gap: 4px } -input, select { width: fill; height: 30px } -.list { overflow: auto; height: 60px } -p { height: 20px } diff --git a/examples/library/ui_controls_parts/Settings.ludic b/examples/library/ui_controls_parts/Settings.ludic deleted file mode 100644 index 63542b85..00000000 --- a/examples/library/ui_controls_parts/Settings.ludic +++ /dev/null @@ -1,10 +0,0 @@ -# Settings.ludic - a screen of controls: every change comes back as an event with its value -component Settings { - state music: bool = true - state volume: int = 5 - state quality: int = 1 - state name: string = "Ada" - state jump: int = 32 - state pressed: int = 0 - on go() { pressed += 1 } -} diff --git a/examples/library/ui_controls_parts/Settings.xml b/examples/library/ui_controls_parts/Settings.xml deleted file mode 100644 index 61cde68f..00000000 --- a/examples/library/ui_controls_parts/Settings.xml +++ /dev/null @@ -1,12 +0,0 @@ -
- - - - - - -

1

2

3

4

5

6

7

8

-

{pressed} {music} {volume} {quality} {name} {jump}

-
diff --git a/examples/library/ui_css.ludic b/examples/library/ui_css.ludic deleted file mode 100644 index 90a3feac..00000000 --- a/examples/library/ui_css.ludic +++ /dev/null @@ -1,26 +0,0 @@ -# ui_css.ludic — ludic.ui's CSS without a renderer: custom properties and var(), absolute, fixed and -# relative positioning, em, rem and vw, @media, text that wraps, sibling combinators, :nth-child -# formulas, :checked, and overflow making a box scroll. -import "ludic.ui" -program UiCss { - numbers float - view Css { - wide = true - } - const LOOK: string = "screen { --gap: 6px; --accent: #ff8800 }\n.panel { position: relative; width: 300px; height: 200px; padding: 10px }\n.badge { position: absolute; right: 5px; bottom: 5px; width: 40px; height: 20px; background: var(--accent) }\n.corner { position: fixed; left: 0; top: 0; width: 10vw; height: 2rem }\n.nudge { position: relative; left: 7px; top: 3px }\n.big { font-size: 2em }\n.missing { color: var(--nope, #00ff00) }\n@media (max-width: 500px) { .wide-only { display: none } }\n@media (min-width: 500px) { .narrow-only { display: none } }\nli + li { margin-top: var(--gap) }\nh3 ~ p { color: var(--accent) }\nli:nth-child(2n+1) { color: #0000ff }\ninput:checked { opacity: 0.25 }\n.list { overflow: auto; height: 50px }\n.para { width: 120px }\n" - const PAGE: string = "\n\n\n
!

moved

big

\n
\n

wide

narrow

\n
  • a
  • b
  • c
\n

h

after

fallback

\n \n

1

2

3

4

\n

a sentence long enough that it has to wrap onto several lines

\n\n\n" - entry (ui_st: mut UiState) { - ui_load_text(ui_st, LOOK, "ui/look.lss") - ui_load_text(ui_st, PAGE, "ui/css.xml") - ui_viewport(ui_st, 400.0, 300.0) - let root: UiNode = ui_nodes(ui_st, "css", view_css()) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 300.0) - let panel = root.children[0] - let badge = panel.children[0] - let ul = root.children[3] - let para = root.children[len(root.children) - 1] - print(`badge at {int(badge.x)},{int(badge.y)} bg {badge.bg}; nudged {int(panel.children[1].x)},{int(panel.children[1].y)}; big {int(panel.children[2].rsize)}; corner {int(root.children[1].x)},{int(root.children[1].y)} {int(root.children[1].cw)}x{int(root.children[1].ch)}`) - print(`media keeps {root.children[2].text}; li gaps {int(ul.children[1].mt)} {int(ul.children[0].mt)}; li colours {ul.children[0].fg} {ul.children[1].fg} {ul.children[2].fg}; after {root.children[5].fg}; fallback {root.children[6].fg}`) - print(`checked opacity {root.children[7].alpha}; list scrolls {root.children[8].kind == UI_SCROLL} {int(root.children[8].ch)} of {int(root.children[8].content_h)}; para lines {len(para.lines)}`) - } -} diff --git a/examples/library/ui_ease.ludic b/examples/library/ui_ease.ludic deleted file mode 100644 index 4da174f1..00000000 --- a/examples/library/ui_ease.ludic +++ /dev/null @@ -1,57 +0,0 @@ -# ui_ease.ludic — easing never depends on the frame rate: a transition and an animation stepped at -# 60, 120, 180 and 240 Hz on the program's clock are at the same value half way (kept in float, never -# cut to whole pixels) and land exactly on their targets when their time is up. -import "ludic.ui" -program UiEase { - numbers float - state UiEaseState { - dim: bool = false - now: float = 0.0 - } - view Ease (ui_ease_st: UiEaseState) { - dim = ui_ease_st.dim - } - const STYLE: string = "" - const BODY: string = "

a

b

c

" - function clock(ui_ease_st: UiEaseState) -> float { return ui_ease_st.now } - function nodes(ui_st: mut UiState, name: string) -> UiNode { - ui_show(ui_st, name, view_ease(), 0.0, 0.0, 200.0, 200.0) - return ui_nodes(ui_st, name, view_ease()) - } - # one rate, on its own screen: the slide from its first frame, the fade from half a second in - function at_rate(ui_ease_st: mut UiEaseState, ui_st: mut UiState, hz: int) -> string { - let name = `r{hz}` - ui_ease_st.dim = false - var mid = "" - var slid = false - var landed = false - var over = false - for k in 0 .. hz + 1 { - ui_ease_st.now = float(k) / float(hz) - if k == hz / 2 { ui_ease_st.dim = true } - let r = nodes(ui_st, name) - let go = r.children[0] - let soft = r.children[1] - let want = r.children[2].alpha - if k == hz / 10 { mid = `{int(Math.round(go.left * 100.0))} ` } - if k == hz / 2 + hz / 10 { mid = mid + `{int(Math.round(soft.alpha * 1000.0))}` } - if k == hz / 4 { slid = go.left == 37.0 } - if k == hz / 2 + hz / 4 { landed = soft.alpha == want } - if k > hz / 2 + hz / 4 and soft.alpha != want { over = true } - } - return `{hz}: {mid} slid {slid} landed {landed} left {over}` - } - entry (ui_ease_st: mut UiEaseState, ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - ui_clock(ui_st, fn clock) - var page = "" + STYLE - for i in 0 .. 4 { - let hz = 60 * (i + 1) - page = page + `` + BODY - } - ui_load_text(ui_st, page + "", "e.xml") - var out = "" - for i in 0 .. 4 { out = out + at_rate(ui_ease_st, ui_st, 60 * (i + 1)) + "; " } - print(out) - } -} diff --git a/examples/library/ui_emit_click.ludic b/examples/library/ui_emit_click.ludic deleted file mode 100644 index 8454d736..00000000 --- a/examples/library/ui_emit_click.ludic +++ /dev/null @@ -1,40 +0,0 @@ -# ui_emit_click.ludic - a component whose button says `emit click` is answered by its user's -# on-click, by the mouse or a press: on-click is kept as on-press, and the emit once looked for -# "click" and found nothing (the pack's items and the wardrobe's picks did not respond) -import "ludic.ui" -import "ui_emit_click_parts/Cell.ludic" -import "ui_emit_click_parts/Pick.ludic" -program UiEmitClick { - numbers float - function frame(ui_st: mut UiState, x: float, y: float, down: bool) -> UiNode { - let i = new UiInput - i.x = x - i.y = y - i.down = down - ui_input(ui_st, i) - ui_show(ui_st, "Pick", null, 0.0, 0.0, 400.0, 400.0) - let root: UiNode = ui_nodes(ui_st, "Pick", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root - } - function first_text(n: UiNode) -> string { - if n.text != "" { return n.text } - for i in 0 .. len(n.children) { let t = first_text(n.children[i]); if t != "" { return t } } - return "" - } - entry (ui_st: mut UiState) { - var root = frame(ui_st, -1.0, -1.0, false) - root = frame(ui_st, -1.0, -1.0, false) - let b = root.children[0].children[2].children[0] - let x = b.x + b.cw / 2.0 - let y = b.y + b.ch / 2.0 - root = frame(ui_st, x, y, false) - root = frame(ui_st, x, y, true) - root = frame(ui_st, x, y, false) - root = frame(ui_st, x, y, false) - let clicked = first_text(root) - ui_press(ui_st, root.children[0].children[1].children[0]) - root = frame(ui_st, x, y, false) - print(`{clicked} | pressed: {first_text(root)}`) - } -} diff --git a/examples/library/ui_emit_click_parts/Cell.lss b/examples/library/ui_emit_click_parts/Cell.lss deleted file mode 100644 index b8c03a5a..00000000 --- a/examples/library/ui_emit_click_parts/Cell.lss +++ /dev/null @@ -1 +0,0 @@ -.cell { width: 100px; height: 40px } diff --git a/examples/library/ui_emit_click_parts/Cell.ludic b/examples/library/ui_emit_click_parts/Cell.ludic deleted file mode 100644 index 0ed005d7..00000000 --- a/examples/library/ui_emit_click_parts/Cell.ludic +++ /dev/null @@ -1,3 +0,0 @@ -component Cell { - prop caption: string = "" -} diff --git a/examples/library/ui_emit_click_parts/Cell.xml b/examples/library/ui_emit_click_parts/Cell.xml deleted file mode 100644 index a88076ab..00000000 --- a/examples/library/ui_emit_click_parts/Cell.xml +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/examples/library/ui_emit_click_parts/Pick.lss b/examples/library/ui_emit_click_parts/Pick.lss deleted file mode 100644 index 1de0ef03..00000000 --- a/examples/library/ui_emit_click_parts/Pick.lss +++ /dev/null @@ -1 +0,0 @@ -div { width: 400px } diff --git a/examples/library/ui_emit_click_parts/Pick.ludic b/examples/library/ui_emit_click_parts/Pick.ludic deleted file mode 100644 index 917bb014..00000000 --- a/examples/library/ui_emit_click_parts/Pick.ludic +++ /dev/null @@ -1,4 +0,0 @@ -component Pick { - state got: int = -1 - on pick(i: int) { got = i } -} diff --git a/examples/library/ui_emit_click_parts/Pick.xml b/examples/library/ui_emit_click_parts/Pick.xml deleted file mode 100644 index aeefa2b6..00000000 --- a/examples/library/ui_emit_click_parts/Pick.xml +++ /dev/null @@ -1,5 +0,0 @@ -
-

got {got}

- - -
diff --git a/examples/library/ui_fill.ludic b/examples/library/ui_fill.ludic deleted file mode 100644 index c7214bb1..00000000 --- a/examples/library/ui_fill.ludic +++ /dev/null @@ -1,41 +0,0 @@ -# ui_fill.ludic — linear-gradient backgrounds (a direction or an angle, stops placed or spread) drawn -# as whole-pixel bands; object-fit contain and cover for a picture of its own size; aspect-ratio -# making a height from a width, and a native's width from its height. -import "ludic.ui" -program UiFill { - numbers float - state UiFillState { - rects: []string = new []string - pics: []string = new []string - clips: int = 0 - } - const STYLE: string = "" - const BODY: string = "
" - function rec_rect(ui_fill_st: mut UiFillState, x: float, y: float, w: float, h: float, c: int, a: float) -> void { push(ui_fill_st.rects, `{int(x)},{int(y)} {int(w)}x{int(h)} {ui_hex(c)}`) } - function rec_image(ui_fill_st: mut UiFillState, src: string, x: float, y: float, w: float, h: float, c: int, a: float) -> void { push(ui_fill_st.pics, `{int(x)},{int(y)} {int(w)}x{int(h)}`) } - function rec_clip(ui_fill_st: mut UiFillState, x: float, y: float, w: float, h: float) -> void { ui_fill_st.clips += 1 } - function no_clip() -> void { } - function no_text(x: float, y: float, s: string, size: float, c: int, a: float) -> void { } - function nat_w(src: string) -> float { return 200.0 } - function nat_h(src: string) -> float { return 100.0 } - function knob_measure(n: UiNode, avail: float) -> void { } - function knob_draw(n: UiNode) -> void { } - entry (ui_fill_st: UiFillState, ui_st: mut UiState) { - let b = new UiBackend - b.rect = fn rec_rect - b.text = fn no_text - b.image = fn rec_image - b.image_w = fn nat_w - b.image_h = fn nat_h - b.clip = fn rec_clip - b.unclip = fn no_clip - ui_backend(ui_st, b) - ui_native(ui_st, "knob", fn knob_measure, fn knob_draw) - ui_load_text(ui_st, "" + STYLE + BODY + "", "f.xml") - ui_show(ui_st, "f", null, 0.0, 0.0, 400.0, 600.0) - let r: UiNode = ui_nodes(ui_st, "f", null) - ui_place(ui_st, r, 0.0, 0.0, 400.0, 600.0) - let n = len(ui_fill_st.rects) - print(`{ui_fill_st.rects[0]} .. {ui_fill_st.rects[49]} | {ui_fill_st.rects[50]} .. {ui_fill_st.rects[59]} .. {ui_fill_st.rects[69]} of {n} | {ui_fill_st.pics[0]} | {ui_fill_st.pics[1]} clipped {ui_fill_st.clips} | ratio {int(r.children[4].ch)} knob {int(r.children[5].children[0].cw)}`) - } -} diff --git a/examples/library/ui_first_show.ludic b/examples/library/ui_first_show.ludic deleted file mode 100644 index c3aeb648..00000000 --- a/examples/library/ui_first_show.ludic +++ /dev/null @@ -1,27 +0,0 @@ -# ui_first_show.ludic - a component shown for the first time once the fence is judging (a prompt's key -# hint mid-walk) makes its instance's props and model: declared, once per instance, and the failing -# fence lets it through; hidden and shown again, the instance is reused and nothing more is made: -# bad 0 shown 3 -import "ludic.ui" -import "ui_first_show_parts/Cell.ludic" -import "ui_first_show_parts/Host.ludic" -program UiFirstShow { - numbers float - function frame(ui_st: mut UiState) -> UiNode { - let root: UiNode = ui_nodes(ui_st, "Host", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root - } - entry (ui_st: mut UiState) { - var shown = 0 - for f in 0 .. 1400 { - let root = frame(ui_st) - if f == 700 or f == 900 or f == 1000 or f == 1100 or f == 1200 { - ui_press(ui_st, root.children[0].children[0]) - if f != 900 and f != 1100 { shown += 1 } - } - Mem.frame() - } - print(`bad {Mem.bad_frames()} shown {shown}`) - } -} diff --git a/examples/library/ui_first_show_parts/Cell.lss b/examples/library/ui_first_show_parts/Cell.lss deleted file mode 100644 index b8c03a5a..00000000 --- a/examples/library/ui_first_show_parts/Cell.lss +++ /dev/null @@ -1 +0,0 @@ -.cell { width: 100px; height: 40px } diff --git a/examples/library/ui_first_show_parts/Cell.ludic b/examples/library/ui_first_show_parts/Cell.ludic deleted file mode 100644 index 0ed005d7..00000000 --- a/examples/library/ui_first_show_parts/Cell.ludic +++ /dev/null @@ -1,3 +0,0 @@ -component Cell { - prop caption: string = "" -} diff --git a/examples/library/ui_first_show_parts/Cell.xml b/examples/library/ui_first_show_parts/Cell.xml deleted file mode 100644 index a88076ab..00000000 --- a/examples/library/ui_first_show_parts/Cell.xml +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/examples/library/ui_first_show_parts/Host.lss b/examples/library/ui_first_show_parts/Host.lss deleted file mode 100644 index 1bac0ccf..00000000 --- a/examples/library/ui_first_show_parts/Host.lss +++ /dev/null @@ -1 +0,0 @@ -.t { width: 60px; height: 30px } diff --git a/examples/library/ui_first_show_parts/Host.ludic b/examples/library/ui_first_show_parts/Host.ludic deleted file mode 100644 index a8e1b5ba..00000000 --- a/examples/library/ui_first_show_parts/Host.ludic +++ /dev/null @@ -1,4 +0,0 @@ -component Host { - state on: bool = false - on flip() { on = not on } -} diff --git a/examples/library/ui_first_show_parts/Host.xml b/examples/library/ui_first_show_parts/Host.xml deleted file mode 100644 index 29140558..00000000 --- a/examples/library/ui_first_show_parts/Host.xml +++ /dev/null @@ -1,4 +0,0 @@ -
- - -
diff --git a/examples/library/ui_flex.ludic b/examples/library/ui_flex.ludic deleted file mode 100644 index 50255f51..00000000 --- a/examples/library/ui_flex.ludic +++ /dev/null @@ -1,25 +0,0 @@ -# ui_flex.ludic — ludic.ui's flex layout and stylesheets without a renderer: grow shares the spare -# room, justify spreads it, a row wraps, align stretches; a stylesheet file (which @imports another) -# styles whoever imports it, a more specific rule wins, a bound class switches one on, and an attribute beats all. -import "ludic.ui" -program UiFlex { - numbers float - let picked: int = 1 - view Tags { - picked = picked - } - const BASE: string = "/* base.lss - every text small */\ntext { size: 10 }\n" - const THEME: string = "/* theme.lss - the base, and a colour for what is on */\n@import \"base.lss\";\n.on { color: #ff0000 }\n" - const PAGE: string = "\n\n\n\n \n ab\n \n lmr\n \n t{i}\n \n
x\n\n\n" - entry (ui_st: mut UiState) { - ui_load_text(ui_st, BASE, "ui/base.lss") - ui_load_text(ui_st, THEME, "ui/theme.lss") - ui_load_text(ui_st, PAGE, "ui/flex.xml") - let root: UiNode = ui_nodes(ui_st, "flex", view_tags()) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 300.0) - let bar = root.children[0] - let wrap = root.children[2] - let tall = root.children[3] - print(`grow {int(bar.children[0].cw)}:{int(bar.children[1].cw)}; between at {int(root.children[1].children[1].x)}; wrap lines at {int(wrap.children[0].y)}, {int(wrap.children[3].y)}; red {wrap.children[1].fg}; stretched {int(tall.children[0].ch)}; own size {int(tall.children[1].rsize)}`) - } -} diff --git a/examples/library/ui_focus_tip.ludic b/examples/library/ui_focus_tip.ludic deleted file mode 100644 index 126839ce..00000000 --- a/examples/library/ui_focus_tip.ludic +++ /dev/null @@ -1,41 +0,0 @@ -# ui_focus_tip.ludic — a title shows for the keyboard's focus as it does for the pointer: after the -# focus has rested half a second on an element with one, under that element; moving the pointer -# hands the tooltip back to the pointer. -import "ludic.ui" -program UiFocusTip { - numbers float - state UiFocusTipState { - said: []string = new []string - now: float = 0.0 - } - const PAGE: string = "" - function rec_text(ui_focus_tip_st: mut UiFocusTipState, x: float, y: float, s: string, size: float, c: int, a: float) -> void { push(ui_focus_tip_st.said, `{s} {int(x)},{int(y)}`) } - function clock(ui_focus_tip_st: UiFocusTipState) -> float { return ui_focus_tip_st.now } - function frame(ui_focus_tip_st: mut UiFocusTipState, ui_st: mut UiState, i: UiInput, secs: float) -> string { - ui_focus_tip_st.now = ui_focus_tip_st.now + secs - ui_focus_tip_st.said = new []string - ui_input(ui_st, i) - ui_show(ui_st, "f", null, 0.0, 0.0, 400.0, 400.0) - return ui_focus_tip_st.said[len(ui_focus_tip_st.said) - 1] - } - entry (ui_focus_tip_st: mut UiFocusTipState, ui_st: mut UiState) { - let b = new UiBackend - b.text = fn rec_text - ui_backend(ui_st, b) - ui_clock(ui_st, fn clock) - ui_load_text(ui_st, PAGE, "f.xml") - frame(ui_focus_tip_st, ui_st, new UiInput, 0.0) - let tab = new UiInput - tab.tab = true - frame(ui_focus_tip_st, ui_st, tab, 0.0) - let early = frame(ui_focus_tip_st, ui_st, new UiInput, 0.2) - let shown = frame(ui_focus_tip_st, ui_st, new UiInput, 0.4) - frame(ui_focus_tip_st, ui_st, tab, 0.0) - let next = frame(ui_focus_tip_st, ui_st, new UiInput, 0.6) - let moved = new UiInput - moved.x = 390.0 - moved.y = 390.0 - let gone = frame(ui_focus_tip_st, ui_st, moved, 0.6) - print(`{early} | {shown} | {next} | {gone}`) - } -} diff --git a/examples/library/ui_gamepad.ludic b/examples/library/ui_gamepad.ludic deleted file mode 100644 index b6c257a4..00000000 --- a/examples/library/ui_gamepad.ludic +++ /dev/null @@ -1,54 +0,0 @@ -# ui_gamepad.ludic — the pad and held keys, on the program's clock: a direction held presses once, -# again after 0.42 s and every 0.11 s after that, at any frame rate; A presses what has the focus, -# left and right step a range and a select, and B closes a popover. -import "ludic.ui" -import "ui_gamepad_parts/Pad.ludic" -program UiGamepad { - numbers float - state UiGamepadState { - now: float = 0.0 - } - function clock(ui_gamepad_st: UiGamepadState) -> float { return ui_gamepad_st.now } - function frame(ui_st: mut UiState, i: UiInput) -> string { - ui_input(ui_st, i) - ui_show(ui_st, "Pad", null, 0.0, 0.0, 400.0, 600.0) - let page = ui_nodes(ui_st, "Pad", null).children[0] - return page.children[len(page.children) - 1].text - } - # `what` held for `secs` at `hz`, then let go - function hold(ui_gamepad_st: mut UiGamepadState, ui_st: mut UiState, what: string, secs: float, hz: int) -> string { - let frames = int(secs * float(hz) + 0.5) - for k in 0 .. frames + 1 { - ui_gamepad_st.now = ui_gamepad_st.now + 1.0 / float(hz) - let i = new UiInput - i.held_up = what == "up" - i.held_down = what == "down" - i.held_left = what == "left" - i.held_right = what == "right" - i.pad_a = what == "a" - i.pad_b = what == "b" - frame(ui_st, i) - } - ui_gamepad_st.now = ui_gamepad_st.now + 1.0 / float(hz) - return frame(ui_st, new UiInput) - } - function tap(ui_gamepad_st: mut UiGamepadState, ui_st: mut UiState, what: string) -> string { return hold(ui_gamepad_st, ui_st, what, 0.0, 60) } - entry (ui_gamepad_st: mut UiGamepadState, ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - ui_clock(ui_st, fn clock) - frame(ui_st, new UiInput) - hold(ui_gamepad_st, ui_st, "down", 1.0, 240) - let fast = tap(ui_gamepad_st, ui_st, "a") - hold(ui_gamepad_st, ui_st, "down", 1.0, 60) - hold(ui_gamepad_st, ui_st, "up", 0.5, 60) - let slow = tap(ui_gamepad_st, ui_st, "a") - for k in 0 .. 8 { tap(ui_gamepad_st, ui_st, "down") } - let vol = hold(ui_gamepad_st, ui_st, "right", 0.5, 60) - tap(ui_gamepad_st, ui_st, "down") - let q = tap(ui_gamepad_st, ui_st, "left") - tap(ui_gamepad_st, ui_st, "down") - let open = tap(ui_gamepad_st, ui_st, "a") - let shut = tap(ui_gamepad_st, ui_st, "b") - print(`{fast} | {slow} | {vol} | {q} | {open} | {shut}`) - } -} diff --git a/examples/library/ui_gamepad_parts/Pad.ludic b/examples/library/ui_gamepad_parts/Pad.ludic deleted file mode 100644 index 6d3776cc..00000000 --- a/examples/library/ui_gamepad_parts/Pad.ludic +++ /dev/null @@ -1,7 +0,0 @@ -# Pad.ludic - eight buttons, a range, a select and a button that opens a popover, for a gamepad -component Pad { - state last: int = -1 - state vol: int = 0 - state q: int = 0 - state open: bool = false -} diff --git a/examples/library/ui_gamepad_parts/Pad.xml b/examples/library/ui_gamepad_parts/Pad.xml deleted file mode 100644 index 0596d59f..00000000 --- a/examples/library/ui_gamepad_parts/Pad.xml +++ /dev/null @@ -1,8 +0,0 @@ -
- - - - -
-

last {last} vol {vol} q {q} open {open}

-
diff --git a/examples/library/ui_hold.ludic b/examples/library/ui_hold.ludic deleted file mode 100644 index 60547577..00000000 --- a/examples/library/ui_hold.ludic +++ /dev/null @@ -1,61 +0,0 @@ -# ui_hold.ludic — on-hold fires every frame a button is held, by the pointer (still when it is dragged -# off, until let go) or by Enter or the pad's A on the focus, with event.dt and event.t on the ui clock, -# the same at 60 and 240 Hz; on-down and on-up still mark the ends. -import "ludic.ui" -program UiHold { - numbers float - state UiHoldState { - now: float = 0.0 - held: float = 0.0 - frames: int = 0 - last_t: float = 0.0 - ends: string = "" - } - view Craft (ui_hold_st: mut UiHoldState) { - on tick(dt: float, t: float) { - ui_hold_st.held = ui_hold_st.held + dt - ui_hold_st.frames = ui_hold_st.frames + 1 - ui_hold_st.last_t = t - } - on down() { ui_hold_st.ends = ui_hold_st.ends + "d" } - on up() { ui_hold_st.ends = ui_hold_st.ends + "u" } - } - const PAGE: string = "" - function clock(ui_hold_st: UiHoldState) -> float { return ui_hold_st.now } - function frame(ui_st: mut UiState, i: UiInput) -> UiNode { - ui_input(ui_st, i) - ui_show(ui_st, "h", view_craft(), 0.0, 0.0, 400.0, 400.0) - let r: UiNode = ui_nodes(ui_st, "h", view_craft()) - ui_place(ui_st, r, 0.0, 0.0, 400.0, 400.0) - return r.children[0] - } - # held for `secs` at `hz`, by the pointer (off the button after the first frame) or by A - function hold(ui_hold_st: mut UiHoldState, ui_st: mut UiState, b: UiNode, secs: float, hz: int, pad: bool) -> string { - ui_hold_st.held = 0.0 - ui_hold_st.frames = 0 - let n = int(secs * float(hz) + 0.5) - for k in 0 .. n + 1 { - let i = new UiInput - i.pad_a = pad - i.down = not pad - i.x = b.x + 5.0 - i.y = b.y + 5.0 - if k > 0 and not pad { i.x = 390.0 } - frame(ui_st, i) - ui_hold_st.now = ui_hold_st.now + 1.0 / float(hz) - } - frame(ui_st, new UiInput) - ui_hold_st.now = ui_hold_st.now + 1.0 / float(hz) - return `{ui_hold_st.frames} frames {int(Math.round(ui_hold_st.held * 1000.0))} ms t {int(Math.round(ui_hold_st.last_t * 1000.0))}` - } - entry (ui_hold_st: mut UiHoldState, ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - ui_clock(ui_st, fn clock) - ui_load_text(ui_st, PAGE, "h.xml") - let b = frame(ui_st, new UiInput) - let slow = hold(ui_hold_st, ui_st, b, 0.5, 60, false) - let fast = hold(ui_hold_st, ui_st, b, 0.5, 240, false) - let pad = hold(ui_hold_st, ui_st, b, 0.25, 60, true) - print(`{slow} | {fast} | pad {pad} | {ui_hold_st.ends}`) - } -} diff --git a/examples/library/ui_html.ludic b/examples/library/ui_html.ludic deleted file mode 100644 index f639d5bc..00000000 --- a/examples/library/ui_html.ludic +++ /dev/null @@ -1,30 +0,0 @@ -# ui_html.ludic — ludic.ui read as HTML and CSS: HTML's elements with a browser's defaults, ids, -# classes, style="...", hidden and disabled, onclick; selectors with ids, descendants, children, -# attributes and pseudo-classes, weighed by specificity; CSS's own property names and the box model. -import "ludic.ui" -program UiHtml { - numbers float - state UiHtmlState { - clicks: int = 0 - } - view Page (ui_html_st: mut UiHtmlState) { - clicks = ui_html_st.clicks - on clicked() { ui_html_st.clicks += 1 } - } - const LOOK: string = "/* look.lss */\n.card { padding: 4 8 12 16; margin: 3; border: 2px solid #0000ff; width: 50% }\n#title { font-size: 40 }\nh1.big { font-size: 20 }\n.card p { color: #00ff00 }\nul > li { height: 30 }\nli:first-child { color: #ff0000 }\nli:nth-child(even) { color: #0000ff }\nli:last-child:not(.keep) { display: none }\n[kind=warn] { background-color: #ffff00 }\nbutton:disabled { opacity: 0.5 }\n" - const PAGE: string = "\n\n\n

Title

\n

inside

careful
\n

outside

\n
  • one
  • two
  • three
  • gone
\n
\n

mid

\n \n \n
\n
\n" - entry (ui_html_st: UiHtmlState, ui_st: mut UiState) { - ui_load_text(ui_st, LOOK, "ui/look.lss") - ui_load_text(ui_st, PAGE, "ui/page.ludic.xml") - var root: UiNode = ui_nodes(ui_st, "page", view_page()) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 600.0) - let card = root.children[1] - let items = root.children[3].children - print(`id beats class: {int(root.children[0].rsize)}; card {int(card.cw)} wide at {int(card.x)},{int(card.y)} pad {int(card.pt)} {int(card.pr)} {int(card.pb)} {int(card.pl)} border {int(card.border)}`) - print(`inside {card.children[0].fg}, outside {root.children[2].fg}; warn bg {card.children[1].bg}; items {len(items)} of 30: {int(items[0].ch)}; colours {items[0].fg} {items[1].fg} {items[2].fg}; hr {int(root.children[4].ch)}; children {len(root.children)}`) - ui_press(ui_st, root.children[6]) - root = ui_nodes(ui_st, "page", view_page()) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 600.0) - print(`clicks {ui_html_st.clicks}; the button now disabled {not root.children[6].enabled} at opacity {root.children[6].alpha}; text-align {root.children[5].talign}`) - } -} diff --git a/examples/library/ui_inputs.ludic b/examples/library/ui_inputs.ludic deleted file mode 100644 index 4a995afc..00000000 --- a/examples/library/ui_inputs.ludic +++ /dev/null @@ -1,83 +0,0 @@ -# ui_inputs.ludic — more of ludic.ui's controls, driven by the keys: a number field takes typed digits -# (Enter or leaving it commits them, held between min and max) and steps with the arrows; a key field -# listens for the next key (Tab included), Esc stops it listening and Backspace unbinds it; a range -# shows its value as a percentage or with decimals and a unit; a label carries a note; an action may -# call an event named set; and a string prop given a number reads it as text. -import "ludic.ui" -import "ui_inputs_parts/Form.ludic" -import "ui_inputs_parts/Tag.ludic" -program UiInputs { - numbers float - function frame(ui_st: mut UiState, i: UiInput) -> UiNode { - ui_input(ui_st, i) - ui_show(ui_st, "Form", null, 0.0, 0.0, 400.0, 400.0) - let root: UiNode = ui_nodes(ui_st, "Form", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root.children[0] - } - function keys(tab: bool, shift: bool, enter: bool, left: bool) -> UiInput { - let i = new UiInput - i.tab = tab - i.shift = shift - i.enter = enter - i.left = left - if tab { i.key = Key.Tab } - if enter { i.key = Key.Enter } - return i - } - function typed(s: string) -> UiInput { - let i = new UiInput - i.typed = s - return i - } - function shown_state(page: UiNode) -> string { return page.children[len(page.children) - 1].text } - function last(n: UiNode) -> string { return n.children[len(n.children) - 1].text } - entry (ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - var page = frame(ui_st, new UiInput) - frame(ui_st, keys(true, false, false, false)) - frame(ui_st, typed("12")) - page = frame(ui_st, keys(false, false, true, false)) - let shown = page.children[0].children[1].children[0].text - let a = shown_state(page) - frame(ui_st, typed("99")) - page = frame(ui_st, keys(true, false, false, false)) - let b = shown_state(page) - frame(ui_st, keys(true, true, false, false)) - page = frame(ui_st, keys(false, false, false, true)) - let c = shown_state(page) - let note = page.children[0].children[0].children[1].text - frame(ui_st, keys(true, false, false, false)) - let space = page.children[1].children[1].children[0].text - frame(ui_st, keys(false, false, true, false)) - let cap = ui_capturing(ui_st) - page = frame(ui_st, keys(true, false, false, false)) - let d = shown_state(page) - frame(ui_st, keys(false, false, true, false)) - let esc = new UiInput - esc.escape = true - esc.key = Key.Escape - page = frame(ui_st, esc) - let e = shown_state(page) - let back = new UiInput - back.backspace = true - page = frame(ui_st, back) - let f = shown_state(page) - frame(ui_st, keys(true, false, false, false)) - let right = new UiInput - right.right = true - page = frame(ui_st, right) - page = frame(ui_st, new UiInput) - let button = page.children[4] - let click = new UiInput - click.x = button.x + 2.0 - click.y = button.y + 2.0 - click.down = true - frame(ui_st, click) - let up = new UiInput - up.x = click.x - up.y = click.y - page = frame(ui_st, up) - print(`count {shown} {a} | {b} | {c} | {note} | {space} listening {cap} {d} | {e} | {f} | {last(page.children[2])} {last(page.children[3])} | {shown_state(page)} | {page.children[5].text}`) - } -} diff --git a/examples/library/ui_inputs_parts/Form.lss b/examples/library/ui_inputs_parts/Form.lss deleted file mode 100644 index 0f774ffd..00000000 --- a/examples/library/ui_inputs_parts/Form.lss +++ /dev/null @@ -1,2 +0,0 @@ -.page { width: 400px; gap: 4px } -input { width: fill } diff --git a/examples/library/ui_inputs_parts/Form.ludic b/examples/library/ui_inputs_parts/Form.ludic deleted file mode 100644 index f81669f3..00000000 --- a/examples/library/ui_inputs_parts/Form.ludic +++ /dev/null @@ -1,9 +0,0 @@ -# Form.ludic - a number, a key binding, two ranges with their values formatted, a note under a label, -# and an event called set -component Form { - state count: int = 5 - state bind: int = 32 - state vol: float = 0.5 - state said: int = 0 - on set(v: int) { said = v } -} diff --git a/examples/library/ui_inputs_parts/Form.xml b/examples/library/ui_inputs_parts/Form.xml deleted file mode 100644 index 1d13d356..00000000 --- a/examples/library/ui_inputs_parts/Form.xml +++ /dev/null @@ -1,9 +0,0 @@ -
- - - - - - -

{count} {bind} {vol} {said}

-
diff --git a/examples/library/ui_inputs_parts/Tag.ludic b/examples/library/ui_inputs_parts/Tag.ludic deleted file mode 100644 index 196cadfa..00000000 --- a/examples/library/ui_inputs_parts/Tag.ludic +++ /dev/null @@ -1,4 +0,0 @@ -# Tag.ludic - a string prop, which a number given to it arrives in as text -component Tag { - prop text: string = "none" -} diff --git a/examples/library/ui_inputs_parts/Tag.xml b/examples/library/ui_inputs_parts/Tag.xml deleted file mode 100644 index 6fd5cee1..00000000 --- a/examples/library/ui_inputs_parts/Tag.xml +++ /dev/null @@ -1 +0,0 @@ -

{text}!

diff --git a/examples/library/ui_keycap.ludic b/examples/library/ui_keycap.ludic deleted file mode 100644 index 9a3ad764..00000000 --- a/examples/library/ui_keycap.ludic +++ /dev/null @@ -1,57 +0,0 @@ -# ui_keycap.ludic — a key field takes a mouse button as well as a key: listening (and :capturing, -# which a stylesheet can colour), the next press of the left, right or middle button anywhere is its -# value (UI_MOUSE_LEFT / RIGHT / MIDDLE), named for what it is, and that press presses nothing else. -import "ludic.ui" -program UiKeycap { - numbers float - state UiKeycapState { - bind: int = 32 - pressed: int = 0 - } - view Keys (ui_keycap_st: mut UiKeycapState) { - bind = ui_keycap_st.bind - pressed = ui_keycap_st.pressed - on set_bind(v: int) { ui_keycap_st.bind = v } - on press() { ui_keycap_st.pressed = ui_keycap_st.pressed + 1 } - } - const PAGE: string = "" - function frame(ui_st: mut UiState, x: float, y: float, b: int) -> UiNode { - let i = new UiInput - i.x = x - i.y = y - i.down = b == 0 - i.down_right = b == 1 - i.down_middle = b == 2 - ui_input(ui_st, i) - ui_show(ui_st, "k", view_keys(), 0.0, 0.0, 400.0, 400.0) - let r: UiNode = ui_nodes(ui_st, "k", view_keys()) - ui_place(ui_st, r, 0.0, 0.0, 400.0, 400.0) - return r - } - function listen(ui_st: mut UiState, k: UiNode) -> int { - frame(ui_st, k.x + 5.0, k.y + 5.0, 0) - frame(ui_st, k.x + 5.0, k.y + 5.0, -1) - return frame(ui_st, -1.0, -1.0, -1).children[0].bg - } - function take(ui_keycap_st: UiKeycapState, ui_st: mut UiState, at: UiNode, b: int) -> string { - frame(ui_st, at.x + 5.0, at.y + 5.0, b) - frame(ui_st, at.x + 5.0, at.y + 5.0, -1) - let r = frame(ui_st, -1.0, -1.0, -1) - let f = r.children[0] - return `{ui_keycap_st.bind} {f.children[1].children[0].text} {f.bg}` - } - entry (ui_keycap_st: UiKeycapState, ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - ui_load_text(ui_st, PAGE, "k.xml") - let r = frame(ui_st, -1.0, -1.0, -1) - let k = r.children[0] - let btn = r.children[1] - let red = listen(ui_st, k) - let right = take(ui_keycap_st, ui_st, btn, 1) - listen(ui_st, k) - let middle = take(ui_keycap_st, ui_st, btn, 2) - listen(ui_st, k) - let left = take(ui_keycap_st, ui_st, btn, 0) - print(`listening {red} | {right} | {middle} | {left} | pressed {ui_keycap_st.pressed}`) - } -} diff --git a/examples/library/ui_look.ludic b/examples/library/ui_look.ludic deleted file mode 100644 index 0e279b45..00000000 --- a/examples/library/ui_look.ludic +++ /dev/null @@ -1,67 +0,0 @@ -# ui_look.ludic — what ludic.ui draws, through a backend that writes each call down: a component -# whose root is another component, styled by every sheet above it by specificity; text-shadow under -# text; a native reading the opacity it is drawn at (ui_opacity); border-image as painted, tinted by -# a background colour, and gone under `transparent`; a picture drawn as it is and an atlas cell in -# the text's colour; a tooltip of two lines; and popovers placed beside their anchors, one flipped. -import "ludic.ui" -import "ui_look_parts/Look.ludic" -import "ui_look_parts/Middle.ludic" -import "ui_look_parts/Inner.ludic" -import "ui_look_parts/Menu.ludic" -program UiLook { - numbers float - state UiLookState { - said: []string = new []string - seen: float = 0.0 - } - function rec_rect(x: float, y: float, w: float, h: float, c: int, a: float) -> void { } - function rec_text(ui_look_st: mut UiLookState, x: float, y: float, s: string, size: float, c: int, a: float) -> void { push(ui_look_st.said, `text {s} {int(x)},{int(y)} {ui_hex(c)}`) } - function rec_image(ui_look_st: mut UiLookState, src: string, x: float, y: float, w: float, h: float, c: int, a: float) -> void { push(ui_look_st.said, `image {src} {ui_hex(c)}`) } - function rec_nine(ui_look_st: mut UiLookState, src: string, slice: float, x: float, y: float, w: float, h: float, dst: float, c: int, a: float) -> void { push(ui_look_st.said, `nine {int(w)} {ui_hex(c)}`) } - function gauge_measure(n: UiNode, avail: float) -> void { } - function gauge_draw(ui_look_st: mut UiLookState, ui_st: UiState, n: UiNode) -> void { ui_look_st.seen = ui_opacity(ui_st) } - function count(ui_look_st: UiLookState, s: string) -> int { - var n = 0 - for i in 0 .. len(ui_look_st.said) { - if Text.starts_with(ui_look_st.said[i], s) { n += 1 } - } - return n - } - function has(ui_look_st: UiLookState, s: string) -> string { - for i in 0 .. len(ui_look_st.said) { - if Text.starts_with(ui_look_st.said[i], s) { return ui_look_st.said[i] } - } - return "-" - } - function show(ui_look_st: mut UiLookState, ui_st: mut UiState, name: string, i: UiInput) -> UiNode { - ui_input(ui_st, i) - ui_look_st.said = new []string - ui_show(ui_st, name, null, 0.0, 0.0, 400.0, 300.0) - let root: UiNode = ui_nodes(ui_st, name, null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 300.0) - return root.children[0] - } - entry (ui_look_st: mut UiLookState, ui_st: mut UiState) { - let b = new UiBackend - b.rect = fn rec_rect - b.text = fn rec_text - b.image = fn rec_image - b.nine = fn rec_nine - ui_backend(ui_st, b) - ui_native(ui_st, "gauge", fn gauge_measure, fn gauge_draw) - var page = show(ui_look_st, ui_st, "Look", new UiInput) - let inner = page.children[0] - let tip = page.children[7] - let rest = new UiInput - rest.x = tip.x + 2.0 - rest.y = tip.y + 2.0 - for f in 0 .. 40 { page = show(ui_look_st, ui_st, "Look", rest) } - print(`root {inner.tag}.{inner.classes[0]}.{inner.classes[1]}.{inner.classes[2]} pad {int(inner.pt)} margin {int(inner.mt)} color {ui_hex(inner.fg)}`) - print(`{has(ui_look_st, "text Hi")} | {has(ui_look_st, "text Hi 0")} | opacity {ui_look_st.seen} | {has(ui_look_st, "nine 40 #ffffff")} {has(ui_look_st, "nine 40 #ff0000")} of {count(ui_look_st, "nine")}`) - print(`{has(ui_look_st, "image photo")} | {has(ui_look_st, "image icon")} | {has(ui_look_st, "text first")} | {has(ui_look_st, "text second")}`) - let menu = show(ui_look_st, ui_st, "Menu", new UiInput) - let a = menu.children[1] - let c = menu.children[3] - print(`by id at {int(a.x)},{int(a.y)} | flipped at {int(c.x)},{int(c.y)} {int(c.cw)}x{int(c.ch)}`) - } -} diff --git a/examples/library/ui_look_parts/Inner.lss b/examples/library/ui_look_parts/Inner.lss deleted file mode 100644 index a2a23a45..00000000 --- a/examples/library/ui_look_parts/Inner.lss +++ /dev/null @@ -1 +0,0 @@ -div.in { padding: 5px } diff --git a/examples/library/ui_look_parts/Inner.ludic b/examples/library/ui_look_parts/Inner.ludic deleted file mode 100644 index ea13f836..00000000 --- a/examples/library/ui_look_parts/Inner.ludic +++ /dev/null @@ -1,4 +0,0 @@ -# Inner.ludic - the innermost root: its own rule is more specific than Middle's, so it holds -component Inner { - state n: int = 0 -} diff --git a/examples/library/ui_look_parts/Inner.xml b/examples/library/ui_look_parts/Inner.xml deleted file mode 100644 index 6ac2251d..00000000 --- a/examples/library/ui_look_parts/Inner.xml +++ /dev/null @@ -1 +0,0 @@ -

in

diff --git a/examples/library/ui_look_parts/Look.lss b/examples/library/ui_look_parts/Look.lss deleted file mode 100644 index 10bfe048..00000000 --- a/examples/library/ui_look_parts/Look.lss +++ /dev/null @@ -1,9 +0,0 @@ -.page { width: 400px; gap: 2px } -.m { margin: 9px } -.shade { text-shadow: 2px 3px 4px #102030 } -.dim { opacity: 0.5 } -gauge { width: 10px; height: 10px } -.chip { width: 40px; height: 20px; border-image: url(chip.png) 8 } -.clear { background: transparent } -.red { background: #ff0000 } -.pics { flex-direction: row; color: #00ff00 } diff --git a/examples/library/ui_look_parts/Look.ludic b/examples/library/ui_look_parts/Look.ludic deleted file mode 100644 index 6dcb508d..00000000 --- a/examples/library/ui_look_parts/Look.ludic +++ /dev/null @@ -1,5 +0,0 @@ -# Look.ludic - what is drawn: a nested component's root styled by every sheet above it, a shadow -# under text, a native at its group's opacity, border-images, pictures, and a tooltip of two lines -component Look { - state n: int = 0 -} diff --git a/examples/library/ui_look_parts/Look.xml b/examples/library/ui_look_parts/Look.xml deleted file mode 100644 index 953c4731..00000000 --- a/examples/library/ui_look_parts/Look.xml +++ /dev/null @@ -1,10 +0,0 @@ -
- -

Hi

-
-
-
-
-
-

rest here

-
diff --git a/examples/library/ui_look_parts/Menu.lss b/examples/library/ui_look_parts/Menu.lss deleted file mode 100644 index c391c723..00000000 --- a/examples/library/ui_look_parts/Menu.lss +++ /dev/null @@ -1,4 +0,0 @@ -.page { width: 400px; height: 300px } -.cell { position: absolute; left: 20px; top: 50px; width: 60px; height: 30px } -.edge { position: absolute; left: 350px; top: 280px; width: 50px; height: 30px } -.verbs { width: 100px; margin-left: 4px; margin-right: 4px } diff --git a/examples/library/ui_look_parts/Menu.ludic b/examples/library/ui_look_parts/Menu.ludic deleted file mode 100644 index 040af0e7..00000000 --- a/examples/library/ui_look_parts/Menu.ludic +++ /dev/null @@ -1,5 +0,0 @@ -# Menu.ludic - popovers beside what they belong to: one by the anchor's id, one beside the element -# before it, which has no room on its right and opens to the left -component Menu { - state n: int = 0 -} diff --git a/examples/library/ui_look_parts/Menu.xml b/examples/library/ui_look_parts/Menu.xml deleted file mode 100644 index f1d58856..00000000 --- a/examples/library/ui_look_parts/Menu.xml +++ /dev/null @@ -1,6 +0,0 @@ -
- -
- -
-
diff --git a/examples/library/ui_look_parts/Middle.lss b/examples/library/ui_look_parts/Middle.lss deleted file mode 100644 index 25c4e0e7..00000000 --- a/examples/library/ui_look_parts/Middle.lss +++ /dev/null @@ -1 +0,0 @@ -.i { padding: 3px; color: #ff0000 } diff --git a/examples/library/ui_look_parts/Middle.ludic b/examples/library/ui_look_parts/Middle.ludic deleted file mode 100644 index f849c893..00000000 --- a/examples/library/ui_look_parts/Middle.ludic +++ /dev/null @@ -1,4 +0,0 @@ -# Middle.ludic - a component whose root is another component -component Middle { - state n: int = 0 -} diff --git a/examples/library/ui_look_parts/Middle.xml b/examples/library/ui_look_parts/Middle.xml deleted file mode 100644 index eef6d672..00000000 --- a/examples/library/ui_look_parts/Middle.xml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/examples/library/ui_mounted.ludic b/examples/library/ui_mounted.ludic deleted file mode 100644 index 083468b4..00000000 --- a/examples/library/ui_mounted.ludic +++ /dev/null @@ -1,18 +0,0 @@ -# ui_mounted.ludic - an editor's questions of a running interface: which components the last frame -# showed (in tree order, each once) and one's model, read in place. App shows two Counters: the list -# is App then Counter, a Counter has a model, and a class not shown has none. Prints `App|Counter| 1 1`. -import "ludic.ui" -import "ui_component_parts/Counter.ludic" -import "ui_component_parts/App.ludic" -program UiMounted { - numbers float - entry (ui_st: mut UiState) { - let root: UiNode = ui_nodes(ui_st, "App", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - let names = new []string - ui_mounted(ui_st, names) - var joined = "" - for i in 0 .. len(names) { joined = joined + names[i] + "|" } - print(`{joined} {ui_model_of(ui_st, "Counter") != null} {ui_model_of(ui_st, "Nothing") == null}`) - } -} diff --git a/examples/library/ui_nine.ludic b/examples/library/ui_nine.ludic deleted file mode 100644 index ca2efdbd..00000000 --- a/examples/library/ui_nine.ludic +++ /dev/null @@ -1,19 +0,0 @@ -# ui_nine.ludic — a nine-slice's cuts: its corners are at most half the box across and half of it -# down, each direction on its own, so a key cap shorter than two corners keeps them across; the cuts -# land on whole pixels so the pieces meet. -import "ludic.ui" -program UiNine { - numbers float - function cuts(x: float, y: float, w: float, h: float, dst: float) -> string { - let c = ui_nine_cuts(x, y, w, h, dst) - var s = "" - for i in 0 .. 8 { - if i == 4 { s = s + "/" } - s = s + ` {int(c[i])}` - } - return s - } - entry { - print(`cap{cuts(0.0, 0.0, 30.0, 16.0, 12.0)} | wide{cuts(0.0, 0.0, 100.0, 40.0, 12.0)} | odd{cuts(10.4, 3.6, 20.3, 9.0, 14.0)}`) - } -} diff --git a/examples/library/ui_override.ludic b/examples/library/ui_override.ludic deleted file mode 100644 index 867341e2..00000000 --- a/examples/library/ui_override.ludic +++ /dev/null @@ -1,36 +0,0 @@ -# ui_override.ludic - a template's text given from outside in place of its file (a studio editing a -# running game's interface): the component is shown again with the new template and its state kept, -# and letting go brings the file back, the state still kept. Prints three lines: the counters as the -# files have them, as the override has them, and as the files have them again. -import "ludic.ui" -import "ui_component_parts/Counter.ludic" -import "ui_component_parts/App.ludic" -program UiOverride { - numbers float - function texts(n: UiNode, out: []string) -> void { - if n.text != "" { push(out, n.text) } - for i in 0 .. len(n.children) { texts(n.children[i], out) } - } - function frame(ui_st: mut UiState) -> UiNode { - let root: UiNode = ui_nodes(ui_st, "App", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root - } - function said(ui_st: mut UiState) -> string { - let out = new []string - texts(frame(ui_st), out) - var joined = "" - for i in 0 .. len(out) { joined = joined + out[i] + "|" } - return joined - } - entry (ui_st: mut UiState) { - let root = frame(ui_st) - ui_press(ui_st, root.children[0].children[1].children[1]) # counter a: + once - print(said(ui_st)) - let xml = "examples/library/ui_component_parts/Counter.xml" - ui_override(ui_st, xml, "

[{label}={count}]

") - print(`{said(ui_st)} {ui_overridden(ui_st, xml)}`) - ui_override_clear(ui_st, "") - print(`{said(ui_st)} {ui_overridden(ui_st, xml)}`) - } -} diff --git a/examples/library/ui_override_up.ludic b/examples/library/ui_override_up.ludic deleted file mode 100644 index 0a5c5824..00000000 --- a/examples/library/ui_override_up.ludic +++ /dev/null @@ -1,26 +0,0 @@ -# ui_override_up.ludic - the override through a component registered under a path with `..` in it, as a -# game's lab build registers every component (lab/../src/ui/...): the studio names the file as the game's -# root has it, and it still matches. Prints `counters|[a=0]|+|[b=0]|+|outside| 1 1` - the override shown, -# and the same file found by its plain path and by a ./ path. -import "ludic.ui" -import "../library/ui_component_parts/Counter.ludic" -import "../library/ui_component_parts/App.ludic" -program UiOverrideUp { - numbers float - function texts(n: UiNode, out: []string) -> void { - if n.text != "" { push(out, n.text) } - for i in 0 .. len(n.children) { texts(n.children[i], out) } - } - entry (ui_st: mut UiState) { - ui_override(ui_st, "examples/library/ui_component_parts/Counter.xml", "

[{label}={count}]

") - let root: UiNode = ui_nodes(ui_st, "App", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - let out = new []string - texts(root, out) - var joined = "" - for i in 0 .. len(out) { joined = joined + out[i] + "|" } - let plain = ui_overridden(ui_st, "examples/library/ui_component_parts/Counter.xml") - let dotted = ui_overridden(ui_st, "./examples/library/../library/ui_component_parts/Counter.xml") - print(`{joined} {plain} {dotted}`) - } -} diff --git a/examples/library/ui_pointer.ludic b/examples/library/ui_pointer.ludic deleted file mode 100644 index 3a6e615d..00000000 --- a/examples/library/ui_pointer.ludic +++ /dev/null @@ -1,62 +0,0 @@ -# ui_pointer.ludic — pointer events: a native map takes presses, drags (still its own when the -# pointer leaves it, until let go), the wheel and the release, but not a press on the button over it; -# a pad takes the same in markup; a hold button says on-down and on-up, and is :active while held. -import "ludic.ui" -import "ui_pointer_parts/Chart.ludic" -program UiPointer { - numbers float - state UiPointerState { - log: string = "" - dragged: float = 0.0 - } - function map_measure(n: UiNode, avail: float) -> void { } - function map_draw(n: UiNode) -> void { } - function map_input(ui_pointer_st: mut UiPointerState, n: UiNode, e: UiPointer) -> bool { - if e.kind == "drag" { ui_pointer_st.dragged = ui_pointer_st.dragged + e.dx } else if e.kind != "pointermove" { ui_pointer_st.log = ui_pointer_st.log + `{e.kind} {int(e.x)},{int(e.y)} {e.button} {int(e.wheel)}; ` } - return true - } - function frame(ui_st: mut UiState, x: float, y: float, down: bool, wheel: float, enter: bool) -> UiNode { - let i = new UiInput - i.x = x - i.y = y - i.down = down - i.wheel = wheel - i.held_enter = enter - ui_input(ui_st, i) - ui_show(ui_st, "Chart", null, 0.0, 0.0, 400.0, 400.0) - let root: UiNode = ui_nodes(ui_st, "Chart", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root.children[0] - } - function said(page: UiNode) -> string { return page.children[len(page.children) - 1].text } - entry (ui_pointer_st: UiPointerState, ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - ui_native(ui_st, "chartmap", fn map_measure, fn map_draw) - ui_native_input(ui_st, "chartmap", fn map_input) - let page = frame(ui_st, -1.0, -1.0, false, 0.0, false) - let box = page.children[0] - let pad = page.children[1] - let hold = page.children[2] - frame(ui_st, box.x + 20.0, box.y + 20.0, true, 0.0, false) - frame(ui_st, box.x + 20.0, box.y + 20.0, false, 0.0, false) - frame(ui_st, box.x + 100.0, box.y + 80.0, true, 0.0, false) - frame(ui_st, box.x + 130.0, box.y + 60.0, true, 0.0, false) - frame(ui_st, box.x + 300.0, box.y + 300.0, true, 0.0, false) - frame(ui_st, box.x + 300.0, box.y + 300.0, false, 0.0, false) - frame(ui_st, box.x + 50.0, box.y + 50.0, false, 1.0, false) - frame(ui_st, pad.x + 7.0, pad.y + 9.0, true, 0.0, false) - frame(ui_st, pad.x + 17.0, pad.y + 9.0, true, 0.0, false) - frame(ui_st, pad.x + 17.0, pad.y + 9.0, false, 0.0, false) - frame(ui_st, pad.x + 17.0, pad.y + 9.0, false, 2.0, false) - frame(ui_st, hold.x + 5.0, hold.y + 5.0, true, 0.0, false) - let down = frame(ui_st, hold.x + 5.0, hold.y + 5.0, true, 0.0, false) - let active = down.children[2].bg - let mid = said(down) - frame(ui_st, hold.x + 5.0, hold.y + 90.0, false, 0.0, false) - let off = said(frame(ui_st, -1.0, -1.0, false, 0.0, false)) - frame(ui_st, -1.0, -1.0, false, 0.0, true) - let keyed = said(frame(ui_st, -1.0, -1.0, false, 0.0, true)) - let up = frame(ui_st, -1.0, -1.0, false, 0.0, false) - print(`{ui_pointer_st.log}dragged {int(ui_pointer_st.dragged)} | {mid} active {active} | {off} | {keyed} | {said(up)} {up.children[2].bg}`) - } -} diff --git a/examples/library/ui_pointer_parts/Chart.lss b/examples/library/ui_pointer_parts/Chart.lss deleted file mode 100644 index 12ead9db..00000000 --- a/examples/library/ui_pointer_parts/Chart.lss +++ /dev/null @@ -1,7 +0,0 @@ -.page { width: 400px; gap: 4px } -.box { width: 200px; height: 100px; position: relative } -.map { width: 200px; height: 100px } -.zoom { position: absolute; top: 10px; left: 10px; width: 30px; height: 30px; padding: 0 } -.pad { width: 100px; height: 50px } -.hold { width: 80px; height: 30px } -.hold:active { background: #ff0000 } diff --git a/examples/library/ui_pointer_parts/Chart.ludic b/examples/library/ui_pointer_parts/Chart.ludic deleted file mode 100644 index e2717401..00000000 --- a/examples/library/ui_pointer_parts/Chart.ludic +++ /dev/null @@ -1,12 +0,0 @@ -# Chart.ludic - a native map with a zoom button over it, a pad that takes pointer events in markup, -# and a hold button that says when it goes down and comes up -component Chart { - state zoomed: int = 0 - state px: float = -1.0 - state dragx: float = 0.0 - state ups: int = 0 - state wh: float = 0.0 - state holding: bool = false - state held: int = 0 - state clicked: int = 0 -} diff --git a/examples/library/ui_pointer_parts/Chart.xml b/examples/library/ui_pointer_parts/Chart.xml deleted file mode 100644 index ba64f3d7..00000000 --- a/examples/library/ui_pointer_parts/Chart.xml +++ /dev/null @@ -1,9 +0,0 @@ -
-
- - -
-
- -

zoomed {zoomed} pad {px} {dragx} {ups} {wh} hold {holding} {held} {clicked}

-
diff --git a/examples/library/ui_pointer_through.ludic b/examples/library/ui_pointer_through.ludic deleted file mode 100644 index a2c5e69e..00000000 --- a/examples/library/ui_pointer_through.ludic +++ /dev/null @@ -1,55 +0,0 @@ -# ui_pointer_through.ludic - a screen whose root
holds only a positioned panel (a component's -# root round a modal) has no size of its own; the pointer looks through it, so the panel's button is -# pressed and hovered - once nothing under it was. A select's < and > answer :hover as elements do. -import "ludic.ui" -program UiPointerThrough { - numbers float - state ThroughState { - got: int = 0 - v: int = 1 - } - view V (through_st: mut ThroughState) { - v = through_st.v - on hit() { through_st.got += 1 } - on set(x: int) { through_st.v = x } - } - const PAGE: string = "
" - function frame(ui_st: mut UiState, x: float, y: float, down: bool) -> UiNode { - let i = new UiInput - i.x = x - i.y = y - i.down = down - ui_input(ui_st, i) - ui_show(ui_st, "s", view_v(), 0.0, 0.0, 400.0, 400.0) - let root: UiNode = ui_nodes(ui_st, "s", view_v()) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root - } - function find(n: UiNode, tag: string, cls: string) -> UiNode { - var has = cls == "" - for i in 0 .. len(n.classes) { if n.classes[i] == cls { has = true } } - if n.tag == tag and has { return n } - for i in 0 .. len(n.children) { - let f = find(n.children[i], tag, cls) - if f != null { return f } - } - return null - } - entry (ui_st: mut UiState, through_st: ThroughState) { - ui_backend(ui_st, new UiBackend) - ui_load_text(ui_st, PAGE, "s.xml") - var root = frame(ui_st, -1.0, -1.0, false) - let b = find(root, "button", "") - let x = b.x + b.cw / 2.0 - let y = b.y + b.ch / 2.0 - frame(ui_st, x, y, false) - root = frame(ui_st, x, y, false) - let hover = ui_hex(find(root, "button", "").bg) - frame(ui_st, x, y, true) - frame(ui_st, x, y, false) - let nx = find(root, "span", "ui-next") - frame(ui_st, nx.x + nx.cw / 2.0, nx.y + nx.ch / 2.0, false) - root = frame(ui_st, nx.x + nx.cw / 2.0, nx.y + nx.ch / 2.0, false) - print(`pressed {through_st.got}, hovered {hover}; > hovered {find(root, "span", "ui-next").hovered}`) - } -} diff --git a/examples/library/ui_popover.ludic b/examples/library/ui_popover.ludic deleted file mode 100644 index 4aafafdd..00000000 --- a/examples/library/ui_popover.ludic +++ /dev/null @@ -1,59 +0,0 @@ -# ui_popover.ludic — HTML's popover and progress in ludic.ui: a button opens a popover of verbs, the -# keyboard stays inside it, a press outside or Esc closes it, and a progress bar fills to its value. -import "ludic.ui" -import "ui_popover_parts/Verbs.ludic" -program UiPopover { - numbers float - function frame(ui_st: mut UiState, i: UiInput) -> UiNode { - ui_input(ui_st, i) - ui_show(ui_st, "Verbs", null, 0.0, 0.0, 400.0, 400.0) - let root: UiNode = ui_nodes(ui_st, "Verbs", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root - } - function click(ui_st: mut UiState, x: float, y: float) -> void { - let d = new UiInput - d.x = x - d.y = y - d.down = true - frame(ui_st, d) - let u = new UiInput - u.x = x - u.y = y - frame(ui_st, u) - } - function said(root: UiNode) -> string { - let page = root.children[0] - return page.children[len(page.children) - 1].text - } - entry (ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - var root = frame(ui_st, new UiInput) - let cell = root.children[0].children[0] - click(ui_st, cell.x + 2.0, cell.y + 2.0) - root = frame(ui_st, new UiInput) - let tab = new UiInput - tab.tab = true - frame(ui_st, tab) - frame(ui_st, tab) - root = frame(ui_st, tab) - let enter = new UiInput - enter.enter = true - root = frame(ui_st, enter) - root = frame(ui_st, new UiInput) - let first = said(root) - click(ui_st, cell.x + 2.0, cell.y + 2.0) - root = frame(ui_st, new UiInput) - let opened = said(root) - click(ui_st, 390.0, 390.0) - root = frame(ui_st, new UiInput) - let outside = said(root) - click(ui_st, cell.x + 2.0, cell.y + 2.0) - let esc = new UiInput - esc.escape = true - frame(ui_st, esc) - root = frame(ui_st, new UiInput) - let bar = root.children[0].children[1] - print(`{first} | {opened} | {outside} | {said(root)} | fill {int(bar.children[0].cw)} of {int(bar.cw)}`) - } -} diff --git a/examples/library/ui_popover_align.ludic b/examples/library/ui_popover_align.ludic deleted file mode 100644 index 45b8c6c2..00000000 --- a/examples/library/ui_popover_align.ludic +++ /dev/null @@ -1,20 +0,0 @@ -# ui_popover_align.ludic — anchored popovers aligned along their side (start, center, end), kept -# within a named container (within="panel") and, by default, within the scroll box they are in, -# flipping to the other side against it rather than against the screen. -import "ludic.ui" -import "ui_popover_align_parts/Menus.ludic" -program UiPopoverAlign { - numbers float - function at(n: UiNode, base: UiNode) -> string { return `{int(n.x - base.x)},{int(n.y - base.y)}` } - entry (ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - ui_show(ui_st, "Menus", null, 0.0, 0.0, 400.0, 400.0) - let root: UiNode = ui_nodes(ui_st, "Menus", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - let panel = root.children[0].children[0] - let list = root.children[0].children[1] - let b = list.children[1] - let p3 = list.children[2] - print(`centred, flipped in the panel {at(panel.children[1], panel)} | end below {at(panel.children[2], panel)} | above b in the list {int(p3.y + p3.ch - b.y)} inside {p3.y >= list.y}`) - } -} diff --git a/examples/library/ui_popover_align_parts/Menus.lss b/examples/library/ui_popover_align_parts/Menus.lss deleted file mode 100644 index 136750a7..00000000 --- a/examples/library/ui_popover_align_parts/Menus.lss +++ /dev/null @@ -1,8 +0,0 @@ -.page { width: 400px; height: 400px } -.panel { width: 200px; height: 150px; position: relative } -.a { position: absolute; left: 150px; top: 50px; width: 40px; height: 20px; padding: 0 } -.p1 { width: 60px; height: 40px } -.p2 { width: 80px; height: 30px } -.list { overflow: auto; width: 150px; height: 100px } -.gap { height: 60px } -.p3 { width: 50px; height: 40px } diff --git a/examples/library/ui_popover_align_parts/Menus.ludic b/examples/library/ui_popover_align_parts/Menus.ludic deleted file mode 100644 index 3269819a..00000000 --- a/examples/library/ui_popover_align_parts/Menus.ludic +++ /dev/null @@ -1,3 +0,0 @@ -# Menus.ludic - popovers anchored inside a panel and inside a scroll box, aligned along their side -component Menus { -} diff --git a/examples/library/ui_popover_align_parts/Menus.xml b/examples/library/ui_popover_align_parts/Menus.xml deleted file mode 100644 index bb735bfb..00000000 --- a/examples/library/ui_popover_align_parts/Menus.xml +++ /dev/null @@ -1,12 +0,0 @@ -
-
- -
-
-
-
-

x

- -
-
-
diff --git a/examples/library/ui_popover_mouse.ludic b/examples/library/ui_popover_mouse.ludic deleted file mode 100644 index 921a0864..00000000 --- a/examples/library/ui_popover_mouse.ludic +++ /dev/null @@ -1,52 +0,0 @@ -# ui_popover_mouse.ludic — a popover takes the mouse: its own button, at the same pixels as the grid -# cell under it, is the one a click presses; a click outside it (on that cell, or on nothing) closes -# it and presses nothing else. -import "ludic.ui" -import "ui_popover_mouse_parts/Grid.ludic" -program UiPopoverMouse { - numbers float - function frame(ui_st: mut UiState, i: UiInput) -> UiNode { - ui_input(ui_st, i) - ui_show(ui_st, "Grid", null, 0.0, 0.0, 400.0, 400.0) - let root: UiNode = ui_nodes(ui_st, "Grid", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root - } - function click(ui_st: mut UiState, x: float, y: float) -> string { - let d = new UiInput - d.x = x - d.y = y - d.down = true - frame(ui_st, d) - let u = new UiInput - u.x = x - u.y = y - frame(ui_st, u) - let root = frame(ui_st, new UiInput) - let page = root.children[0] - return page.children[len(page.children) - 1].text - } - function find(n: UiNode, id: string) -> UiNode { - if n.id == id { return n } - for i in 0 .. len(n.children) { - let f = find(n.children[i], id) - if f != null { return f } - } - return null - } - entry (ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - frame(ui_st, new UiInput) - let opened = click(ui_st, 20.0, 20.0) - let root = frame(ui_st, new UiInput) - let drop = find(root, "drop") - let cell = find(root, "cell") - let over = drop.x >= cell.x and drop.y >= cell.y and drop.x + drop.cw <= cell.x + cell.cw - let dropped = click(ui_st, drop.x + drop.cw / 2.0, drop.y + drop.ch / 2.0) - click(ui_st, 20.0, 20.0) - let on_cell = click(ui_st, 180.0, 90.0) - click(ui_st, 20.0, 20.0) - let on_nothing = click(ui_st, 390.0, 390.0) - print(`over the cell {over} | {opened} | {dropped} | {on_cell} | {on_nothing}`) - } -} diff --git a/examples/library/ui_popover_mouse_parts/Grid.lss b/examples/library/ui_popover_mouse_parts/Grid.lss deleted file mode 100644 index b80c6af9..00000000 --- a/examples/library/ui_popover_mouse_parts/Grid.lss +++ /dev/null @@ -1,3 +0,0 @@ -.page { width: 300px; height: 300px } -.cell { width: 200px; height: 100px } -.verbs { top: 10px; left: 10px; width: 120px } diff --git a/examples/library/ui_popover_mouse_parts/Grid.ludic b/examples/library/ui_popover_mouse_parts/Grid.ludic deleted file mode 100644 index 67823d5f..00000000 --- a/examples/library/ui_popover_mouse_parts/Grid.ludic +++ /dev/null @@ -1,8 +0,0 @@ -# Grid.ludic - a grid cell with a popover of verbs laid over it, written BEFORE the cell, so the cell -# comes later in the tree and would win any hit test that went by the order things are drawn in -component Grid { - state open: bool = false - state opened: int = 0 - state dropped: int = 0 - state closed: int = 0 -} diff --git a/examples/library/ui_popover_mouse_parts/Grid.xml b/examples/library/ui_popover_mouse_parts/Grid.xml deleted file mode 100644 index e119d580..00000000 --- a/examples/library/ui_popover_mouse_parts/Grid.xml +++ /dev/null @@ -1,9 +0,0 @@ -
- -
- -
-
- -

opened {opened} dropped {dropped} closed {closed} {open}

-
diff --git a/examples/library/ui_popover_parts/Verbs.lss b/examples/library/ui_popover_parts/Verbs.lss deleted file mode 100644 index ae965783..00000000 --- a/examples/library/ui_popover_parts/Verbs.lss +++ /dev/null @@ -1,3 +0,0 @@ -.page { width: 300px; height: 200px; gap: 4px } -.verbs { top: 40px; left: 10px; width: 120px } -progress { width: 200px } diff --git a/examples/library/ui_popover_parts/Verbs.ludic b/examples/library/ui_popover_parts/Verbs.ludic deleted file mode 100644 index 090f1929..00000000 --- a/examples/library/ui_popover_parts/Verbs.ludic +++ /dev/null @@ -1,6 +0,0 @@ -# Verbs.ludic - a grid cell that opens a popover of verbs -component Verbs { - state open: bool = false - state used: string = "none" - state health: int = 30 -} diff --git a/examples/library/ui_popover_parts/Verbs.xml b/examples/library/ui_popover_parts/Verbs.xml deleted file mode 100644 index 34ec8bb4..00000000 --- a/examples/library/ui_popover_parts/Verbs.xml +++ /dev/null @@ -1,11 +0,0 @@ -
- - -
- - -
-
- -

{used} {open}

-
diff --git a/examples/library/ui_react.ludic b/examples/library/ui_react.ludic deleted file mode 100644 index 09edaf48..00000000 --- a/examples/library/ui_react.ludic +++ /dev/null @@ -1,61 +0,0 @@ -# ui_react.ludic — ludic.ui's React side without a renderer: keyed lists keep each item's state when -# the list is reordered, names a value, context reaches through a component, named slots, -# on-mount and on-unmount, and a native element of the program's own firing on-change with a value. -import "ludic.ui" -program UiReact { - numbers float - state UiReactState { - names: []string = new []string - volume: int = 3 - mounted: int = 0 - unmounted: int = 0 - show_extra: bool = true - } - view Mixer (ui_react_st: mut UiReactState) { - names = ui_react_st.names - volume = ui_react_st.volume - extra = ui_react_st.show_extra - on set_volume(v: int) { ui_react_st.volume = v } - on mount() { ui_react_st.mounted += 1 } - on unmount() { ui_react_st.unmounted += 1 } - } - # a dial the program draws itself: it measures 40 square and, pressed, turns up by one - function dial_measure(n: UiNode, avail: float) -> void { - n.mw = 40.0 - n.mh = 40.0 - } - function dial_draw(n: UiNode) -> void { } - const PAGE: string = "\n\n

{theme}

\n
\n\n \n \n\n\n \n \n \n \n

{loud ? 'loud' : 'quiet'}

\n
\n
\n \n \n \n

extra

\n \n

checkbox {on}

\n
\n
\n" - function texts(n: UiNode, out: []string) -> void { - if n.text != "" { push(out, n.text) } - for i in 0 .. len(n.children) { texts(n.children[i], out) } - } - function frame(ui_st: mut UiState) -> UiNode { - ui_show(ui_st, "mix", view_mixer(), 0.0, 0.0, 400.0, 400.0) - let root: UiNode = ui_nodes(ui_st, "mix", view_mixer()) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root - } - entry (ui_react_st: mut UiReactState, ui_st: mut UiState) { - ui_native(ui_st, "dial", fn dial_measure, fn dial_draw) - push(ui_react_st.names, "bass") - push(ui_react_st.names, "drums") - ui_load_text(ui_st, PAGE, "ui/mix.xml") - var root = frame(ui_st) - ui_press(ui_st, root.children[2]) - ui_react_st.names[0] = "drums" - ui_react_st.names[1] = "bass" - root = frame(ui_st) - let dial = root.children[3] - ui_event(ui_st, dial, "change", Value.int(ui_react_st.volume + 4)) - ui_event(ui_st, root.children[4], "change", Value.bool(1)) - ui_react_st.show_extra = false - root = frame(ui_st) - let out = new []string - texts(root, out) - var joined = "" - for i in 0 .. len(out) { joined = joined + out[i] + "|" } - print(joined) - print(`volume {ui_react_st.volume}; dial {int(dial.cw)}x{int(dial.ch)}; mounted {ui_react_st.mounted}, unmounted {ui_react_st.unmounted}`) - } -} diff --git a/examples/library/ui_reload.ludic b/examples/library/ui_reload.ludic deleted file mode 100644 index 9084d431..00000000 --- a/examples/library/ui_reload.ludic +++ /dev/null @@ -1,29 +0,0 @@ -# ui_reload.ludic — a template read from disk, changed there, and read again by ui_reload: the new -# text shows at once, and the screen's state (a counter pressed before the change) is kept. -import "ludic.ui" -program UiReload { - numbers float - view Note { - title = "note" - } - const PATH: string = "build/ui_reload_example.xml" - const V1: string = "" - const V2: string = "

{title} again {n}

" - function shown(ui_st: mut UiState) -> string { - let root: UiNode = ui_nodes(ui_st, "note", view_note()) - let first = root.children[0] - if len(first.children) > 0 { return first.children[0].text } - return first.text - } - entry (ui_st: mut UiState) { - Fs.write_text(PATH, V1) - ui_load(ui_st, PATH) - let root: UiNode = ui_nodes(ui_st, "note", view_note()) - ui_press(ui_st, root.children[0]) - let before = shown(ui_st) - let same = ui_reload(ui_st) - Fs.write_text(PATH, V2) - let changed = ui_reload(ui_st) - print(`{before} | reloaded {same} then {changed} | {shown(ui_st)}`) - } -} diff --git a/examples/library/ui_remount.ludic b/examples/library/ui_remount.ludic deleted file mode 100644 index ed607165..00000000 --- a/examples/library/ui_remount.ludic +++ /dev/null @@ -1,30 +0,0 @@ -# ui_remount.ludic - a component that comes and goes: mounted again, it starts as new (its state back -# to its defaults), and a thousand comings and goings hold the heap flat, because an unmounted -# instance is put back and mounted again rather than a record, props and model made each time -import "ludic.ui" -import "ui_remount_parts/Counter.ludic" -import "ui_remount_parts/Shown.ludic" -program UiRemount { - numbers float - function frame(ui_st: mut UiState) -> UiNode { - let root: UiNode = ui_nodes(ui_st, "Shown", null) - ui_place(ui_st, root, 0.0, 0.0, 300.0, 200.0) - return root - } - entry (ui_st: mut UiState) { - var root = frame(ui_st) - let c = root.children[0].children[1] - ui_press(ui_st, c.children[1]) - ui_press(ui_st, c.children[1]) - root = frame(ui_st) - let before = root.children[0].children[1].children[0].text - var at = 0 - for i in 0 .. 2000 { - ui_press(ui_st, root.children[0].children[0]) - root = frame(ui_st) - if i == 99 { at = Os.heap_bytes() } - } - let grew = Os.heap_bytes() - at - print(`{before} | {root.children[0].children[1].children[0].text} | heap {grew}`) - } -} diff --git a/examples/library/ui_remount_parts/Counter.lss b/examples/library/ui_remount_parts/Counter.lss deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/library/ui_remount_parts/Counter.ludic b/examples/library/ui_remount_parts/Counter.ludic deleted file mode 100644 index 57112f96..00000000 --- a/examples/library/ui_remount_parts/Counter.ludic +++ /dev/null @@ -1,9 +0,0 @@ -# Counter.ludic - a counter that starts at 0 each time it is mounted -component Counter { - prop label: string - prop step: int = 1 - state count: int = 0 - doubled: int = count * 2 - function big() -> bool { return count > 3 } - on bump() { count += step } -} diff --git a/examples/library/ui_remount_parts/Counter.xml b/examples/library/ui_remount_parts/Counter.xml deleted file mode 100644 index 617de5aa..00000000 --- a/examples/library/ui_remount_parts/Counter.xml +++ /dev/null @@ -1,5 +0,0 @@ -
-

{label}: {count} ({doubled}){big() ? ' big' : ''}

- - -
diff --git a/examples/library/ui_remount_parts/Shown.ludic b/examples/library/ui_remount_parts/Shown.ludic deleted file mode 100644 index 8d808144..00000000 --- a/examples/library/ui_remount_parts/Shown.ludic +++ /dev/null @@ -1,5 +0,0 @@ -# Shown.ludic - a counter shown or not: each time it comes back it is a new counter -component Shown { - state on: bool = true - on flip() { on = not on } -} diff --git a/examples/library/ui_remount_parts/Shown.xml b/examples/library/ui_remount_parts/Shown.xml deleted file mode 100644 index cb93e733..00000000 --- a/examples/library/ui_remount_parts/Shown.xml +++ /dev/null @@ -1,4 +0,0 @@ -
- - -
diff --git a/examples/library/ui_scroll_hold.ludic b/examples/library/ui_scroll_hold.ludic deleted file mode 100644 index 70e2811a..00000000 --- a/examples/library/ui_scroll_hold.ludic +++ /dev/null @@ -1,32 +0,0 @@ -# ui_scroll_hold.ludic — scroll-top holds a box at an offset: one follows its state, which on-scroll -# keeps in step with the wheel; one stays where it is held; and ui_scroll_set moves another once. -import "ludic.ui" -import "ui_scroll_hold_parts/Held.ludic" -program UiScrollHold { - numbers float - function frame(ui_st: mut UiState, x: float, y: float, wheel: float) -> UiNode { - let i = new UiInput - i.x = x - i.y = y - i.wheel = wheel - ui_input(ui_st, i) - ui_show(ui_st, "Held", null, 0.0, 0.0, 400.0, 600.0) - let root: UiNode = ui_nodes(ui_st, "Held", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 600.0) - return root.children[0] - } - function at(p: UiNode) -> string { return `{int(p.children[0].scroll_y)} {int(p.children[1].scroll_y)} {int(p.children[2].scroll_y)} {p.children[3].text}` } - entry (ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - let p = frame(ui_st, -1.0, -1.0, 0.0) - let first = at(frame(ui_st, -1.0, -1.0, 0.0)) - frame(ui_st, p.children[0].x + 5.0, p.children[0].y + 5.0, 1.0) - let followed = at(frame(ui_st, -1.0, -1.0, 0.0)) - frame(ui_st, p.children[1].x + 5.0, p.children[1].y + 5.0, -1.0) - let fixed = at(frame(ui_st, -1.0, -1.0, 0.0)) - ui_scroll_set(ui_st, "free", 70.0) - let set = at(frame(ui_st, -1.0, -1.0, 0.0)) - frame(ui_st, p.children[2].x + 5.0, p.children[2].y + 5.0, 1.0) - print(`{first} | {followed} | {fixed} | {set} | {at(frame(ui_st, -1.0, -1.0, 0.0))}`) - } -} diff --git a/examples/library/ui_scroll_hold_parts/Held.lss b/examples/library/ui_scroll_hold_parts/Held.lss deleted file mode 100644 index 3d39fcdf..00000000 --- a/examples/library/ui_scroll_hold_parts/Held.lss +++ /dev/null @@ -1,3 +0,0 @@ -.page { width: 400px; gap: 4px } -.list { overflow: auto; width: 200px; height: 100px } -p { height: 40px } diff --git a/examples/library/ui_scroll_hold_parts/Held.ludic b/examples/library/ui_scroll_hold_parts/Held.ludic deleted file mode 100644 index a3c2c779..00000000 --- a/examples/library/ui_scroll_hold_parts/Held.ludic +++ /dev/null @@ -1,5 +0,0 @@ -# Held.ludic - three scroll boxes: one held at its state's offset and told when it moves, one held -# at a fixed offset, and one the program moves once -component Held { - state top: float = 120.0 -} diff --git a/examples/library/ui_scroll_hold_parts/Held.xml b/examples/library/ui_scroll_hold_parts/Held.xml deleted file mode 100644 index 94d975cb..00000000 --- a/examples/library/ui_scroll_hold_parts/Held.xml +++ /dev/null @@ -1,6 +0,0 @@ -
-

{r}

-

{r}

-

{r}

-

top {top}

-
diff --git a/examples/library/ui_scrollbar.ludic b/examples/library/ui_scrollbar.ludic deleted file mode 100644 index e7756500..00000000 --- a/examples/library/ui_scrollbar.ludic +++ /dev/null @@ -1,54 +0,0 @@ -# ui_scrollbar.ludic — a scroll box's bar held and dragged: a press on the thumb holds it where it -# was taken, the pointer held moves it in proportion, letting go leaves it; a press on the track -# jumps the thumb's middle there; the wheel still scrolls; and the rows under the bar are never pressed. -import "ludic.ui" -import "ui_scrollbar_parts/Long.ludic" -program UiScrollbar { - numbers float - state UiScrollbarState { - bx: float = 0.0 - by: float = 0.0 - } - function frame(ui_st: mut UiState, i: UiInput) -> UiNode { - ui_input(ui_st, i) - ui_show(ui_st, "Long", null, 0.0, 0.0, 400.0, 400.0) - let root: UiNode = ui_nodes(ui_st, "Long", null) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root.children[0] - } - # the pointer at (bx + x, by + y), held or not; how far the box is scrolled then - function at(ui_scrollbar_st: UiScrollbarState, ui_st: mut UiState, x: float, y: float, down: bool) -> int { - let i = new UiInput - i.x = ui_scrollbar_st.bx + x - i.y = ui_scrollbar_st.by + y - i.down = down - return int(frame(ui_st, i).children[0].scroll_y) - } - function wheel(ui_scrollbar_st: UiScrollbarState, ui_st: mut UiState, x: float, y: float) -> int { - let i = new UiInput - i.x = ui_scrollbar_st.bx + x - i.y = ui_scrollbar_st.by + y - i.wheel = 1.0 - frame(ui_st, i) - return int(frame(ui_st, new UiInput).children[0].scroll_y) - } - entry (ui_scrollbar_st: mut UiScrollbarState, ui_st: mut UiState) { - ui_backend(ui_st, new UiBackend) - let list = frame(ui_st, new UiInput).children[0] - ui_scrollbar_st.bx = list.x - ui_scrollbar_st.by = list.y - let grab = at(ui_scrollbar_st, ui_st, 195.0, 10.0, true) - let moved = at(ui_scrollbar_st, ui_st, 195.0, 40.0, true) - let far = at(ui_scrollbar_st, ui_st, 195.0, 300.0, true) - let back = at(ui_scrollbar_st, ui_st, 195.0, 25.0, true) - let kept = at(ui_scrollbar_st, ui_st, 195.0, 25.0, false) - at(ui_scrollbar_st, ui_st, 0.0 - ui_scrollbar_st.bx - 1.0, 0.0 - ui_scrollbar_st.by - 1.0, false) - let jump = at(ui_scrollbar_st, ui_st, 195.0, 80.0, true) - at(ui_scrollbar_st, ui_st, 195.0, 80.0, false) - let wheeled = wheel(ui_scrollbar_st, ui_st, 50.0, 50.0) - at(ui_scrollbar_st, ui_st, 50.0, 20.0, true) - at(ui_scrollbar_st, ui_st, 50.0, 20.0, false) - let page = frame(ui_st, new UiInput) - print(`grab {grab} moved {moved} far {far} back {back} kept {kept} | jump {jump} | wheel {wheeled} | {page.children[1].text}`) - } -} diff --git a/examples/library/ui_scrollbar_parts/Long.lss b/examples/library/ui_scrollbar_parts/Long.lss deleted file mode 100644 index 5cee44f2..00000000 --- a/examples/library/ui_scrollbar_parts/Long.lss +++ /dev/null @@ -1,3 +0,0 @@ -.page { width: 300px; height: 300px } -.list { overflow: auto; width: 200px; height: 100px } -.row { width: fill; height: 40px; padding: 0; border-radius: 0 } diff --git a/examples/library/ui_scrollbar_parts/Long.ludic b/examples/library/ui_scrollbar_parts/Long.ludic deleted file mode 100644 index bbdf2132..00000000 --- a/examples/library/ui_scrollbar_parts/Long.ludic +++ /dev/null @@ -1,5 +0,0 @@ -# Long.ludic - a box of ten rows, four times taller than it shows; each row is a button as wide as -# the box, so the scrollbar sits over them -component Long { - state pressed: int = 0 -} diff --git a/examples/library/ui_scrollbar_parts/Long.xml b/examples/library/ui_scrollbar_parts/Long.xml deleted file mode 100644 index 08daf0ea..00000000 --- a/examples/library/ui_scrollbar_parts/Long.xml +++ /dev/null @@ -1,4 +0,0 @@ -
-
-

pressed {pressed}

-
diff --git a/examples/library/ui_select_arrows.ludic b/examples/library/ui_select_arrows.ludic deleted file mode 100644 index 20352a2a..00000000 --- a/examples/library/ui_select_arrows.ludic +++ /dev/null @@ -1,51 +0,0 @@ -# ui_select_arrows.ludic - a select is a cycler: a press let go on its < steps back and on its > -# (or anywhere else on it, or Enter) forward. The < once stepped forward too. -import "ludic.ui" -program UiSelectArrows { - numbers float - state SelState { v: int = 1 } - view V (sel_st: mut SelState) { - v = sel_st.v - on set(x: int) { sel_st.v = x } - } - const PAGE: string = "" - function frame(ui_st: mut UiState, x: float, y: float, down: bool) -> UiNode { - let i = new UiInput - i.x = x - i.y = y - i.down = down - ui_input(ui_st, i) - ui_show(ui_st, "s", view_v(), 0.0, 0.0, 400.0, 400.0) - let root: UiNode = ui_nodes(ui_st, "s", view_v()) - ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) - return root - } - function part(n: UiNode, cls: string) -> UiNode { - for i in 0 .. len(n.children) { - if List.contains(n.children[i].classes, cls) { return n.children[i] } - let f = part(n.children[i], cls) - if f != null { return f } - } - return null - } - function click(ui_st: mut UiState, cls: string) -> void { - let root = frame(ui_st, -1.0, -1.0, false) - let p = part(root, cls) - let x = p.x + p.cw / 2.0 - let y = p.y + p.ch / 2.0 - frame(ui_st, x, y, false) - frame(ui_st, x, y, true) - frame(ui_st, x, y, false) - frame(ui_st, x, y, false) - } - entry (ui_st: mut UiState, sel_st: SelState) { - ui_backend(ui_st, new UiBackend) - ui_load_text(ui_st, PAGE, "s.xml") - frame(ui_st, -1.0, -1.0, false) - click(ui_st, "ui-prev") - let a = sel_st.v - click(ui_st, "ui-next") - click(ui_st, "ui-next") - print(`prev {a} next next {sel_st.v}`) - } -} diff --git a/examples/library/ui_select_disabled.ludic b/examples/library/ui_select_disabled.ludic deleted file mode 100644 index 2adba55d..00000000 --- a/examples/library/ui_select_disabled.ludic +++ /dev/null @@ -1,55 +0,0 @@ -# ui_select_disabled.ludic -