Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

2858 changed files with 60903 additions and 844844 deletions

View file

@ -6,12 +6,6 @@
"runtimeExecutable": "python3", "runtimeExecutable": "python3",
"runtimeArgs": ["-m", "http.server", "8123", "-d", "build/web"], "runtimeArgs": ["-m", "http.server", "8123", "-d", "build/web"],
"port": 8123 "port": 8123
},
{
"name": "ludic-docs",
"runtimeExecutable": "python3",
"runtimeArgs": ["-m", "http.server", "8124", "-d", "build/pages"],
"port": 8124
} }
] ]
} }

View file

@ -24,7 +24,7 @@ labels:
## Environment ## 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): - Target (native macOS / headless / web-wasm):
- Commit (`git rev-parse --short HEAD`): - Commit (`git rev-parse --short HEAD`):
- OS / arch: - OS / arch:

View file

@ -8,13 +8,13 @@ Closes #
## Checklist ## Checklist
- [ ] `bin/ludic-dev test` passes. - [ ] `bin/x test` passes.
- [ ] For compiler/runtime changes: `bin/ludic-dev reseed && bin/ludic-dev bootstrap-cfree` - [ ] For compiler/runtime changes: `bin/x reseed && bin/x bootstrap-cfree`
still reaches the self-hosting fixpoint with no C compiler in the loop. 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). - [ ] `ludic-fmt` leaves the touched files unchanged (2-space, LF, UTF-8).
- [ ] New/changed stdlib symbols are documented under `docs/language/**` and - [ ] New/changed stdlib symbols are documented under `docs/language/**` and
registered in `tools/docgen/inventory.json` 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). - [ ] Commits follow [Conventional Commits](https://www.conventionalcommits.org).
- [ ] No new C / Python / JS in tooling (Ludic only), and no generated - [ ] No new C / Python / JS in tooling (Ludic only), and no generated
artifacts committed outside `build/` / `bin/`. artifacts committed outside `build/` / `bin/`.

View file

@ -25,17 +25,14 @@ jobs:
clang-16 --version | head -1 clang-16 --version | head -1
- name: Check out the triggering commit - 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: | run: |
set -eu set -eu
git config --global --add safe.directory '*' 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 checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}"
git log --oneline -1 git log --oneline -1
# See ci.yml for why the Linux build injects the stdio shim via LUDIC_CC. # 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" echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV"
- name: Bootstrap x from the seed - name: Bootstrap x from the seed
@ -43,11 +40,11 @@ jobs:
set -eu set -eu
mkdir -p bin mkdir -p bin
clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc 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 - 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 # 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 # the checked-in seed. It returns non-zero if they differ — i.e. if the
# seed is stale relative to the compiler source. # seed is stale relative to the compiler source.
run: bin/ludic-dev bootstrap-cfree run: bin/x bootstrap-cfree

View file

@ -2,7 +2,7 @@ name: ci
# Build the language toolchain from its IR seed and run the regression suites on # 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 # 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 # compiler even building from the seed. See also bootstrap.yml, which proves the
# C-free self-rebuild reproduces the seed byte-for-byte. # C-free self-rebuild reproduces the seed byte-for-byte.
on: on:
@ -17,38 +17,35 @@ jobs:
# advertises `docker`, not the GitHub-ism `ubuntu-latest`. # advertises `docker`, not the GitHub-ism `ubuntu-latest`.
runs-on: docker runs-on: docker
# Reuse the runner's own base image (Debian bookworm with git + node already # 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 # 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). The docs # (LLVM 16 — the IR uses opaque pointers, so clang 15+ is required) and
# generator and its guards are now Ludic, so the job carries no Python. A # python3 for the docs/vocabulary checks. A prebuilt image with these baked
# prebuilt image with clang baked in is the obvious future speed-up (see # in is the obvious future speed-up (see issue #33's packaging work).
# issue #33's packaging work).
container: node:20-bookworm container: node:20-bookworm
steps: steps:
- name: Install clang-16 - name: Install clang-16 and python3
run: | run: |
set -eu set -eu
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update -qq 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 clang-16 --version | head -1
python3 --version
- name: Check out the triggering commit - 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: | run: |
set -eu set -eu
git config --global --add safe.directory '*' 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 checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}"
git log --oneline -1 git log --oneline -1
# The toolchain is macOS-first; on this Linux runner it links against a # 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 # tiny C-free IR shim that supplies the Darwin standard-stream globals
# (__stdoutp/__stderrp) over glibc's stdout/stderr. Injected through # (__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 # and each compiled test program — picks it up. Absolute path so it
# still resolves if a step changes directory. # 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" echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV"
- name: Bootstrap the toolchain from the IR seed (clang only) - 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. # pre-built binaries: the language builds itself from source + seed.
mkdir -p bin mkdir -p bin
clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc 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
bin/ludic-dev build bin/x build
- name: Regression suite (ludic-dev test) - name: Regression suite (x test)
run: bin/ludic-dev 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 # Grammar/lexer/vocabulary sync, ludic-fmt idempotence (the project's
# formatting contract — hand alignment is deliberately preserved, so the # 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 # 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 # assets. Cross-file LSP behaviour and the golden renders are macOS-ABI
# bound and skip here — visibly — until the runtime's directory walk and # bound and skip here — visibly — until the runtime's directory walk and
# windowing are portable. # windowing are portable.
run: bin/ludic-dev test-tools run: bin/x test-tools
- name: Docs cover the implementation - name: Docs cover the implementation
run: | run: |
set -eu set -eu
# The whole docs toolchain is written in Ludic and runs through x — python3 tools/docgen/gen.py --out build/pages
# no Python anywhere. check-impl / check-vocabulary / check-docs guard python3 tools/docgen/check.py build/pages
# the sources; docs-gen builds the site and docs-check is its coverage python3 tools/docgen/check-impl.py
# + 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

View file

@ -17,12 +17,13 @@ jobs:
steps: steps:
- name: Check out with history - name: Check out with history
env: env:
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git BEFORE: ${{ github.event.before }}
BASE: ${{ github.base_ref }}
run: | run: |
set -eu set -eu
git config --global --add safe.directory '*' git config --global --add safe.directory '*'
# Full clone so both endpoints of the range are present. # 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}" git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}"
- name: Lint the new commits - name: Lint the new commits
@ -35,15 +36,10 @@ jobs:
# - pull_request: base branch .. this commit # - pull_request: base branch .. this commit
# - push: the pushed range (event.before .. this commit) # - push: the pushed range (event.before .. this commit)
# - new branch / unknown: just the tip 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 if [ -n "${BASE:-}" ]; then
git fetch --quiet origin "${BASE}" 2>/dev/null || true git fetch --quiet origin "${BASE}" 2>/dev/null || true
RANGE="origin/${BASE}..${GITHUB_SHA}" RANGE="origin/${BASE}..${GITHUB_SHA}"
elif [ -n "${BEFORE:-}" ] && ! printf '%s' "$BEFORE" | grep -qE '^0+$' \ elif [ -n "${BEFORE:-}" ] && ! printf '%s' "$BEFORE" | grep -qE '^0+$'; then
&& git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
RANGE="${BEFORE}..${GITHUB_SHA}" RANGE="${BEFORE}..${GITHUB_SHA}"
else else
RANGE="${GITHUB_SHA}~1..${GITHUB_SHA}" RANGE="${GITHUB_SHA}~1..${GITHUB_SHA}"

View file

@ -10,22 +10,9 @@ on:
paths: paths:
- 'docs/**' - 'docs/**'
- 'tools/docgen/**' - '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' - '.forgejo/workflows/docs.yml'
workflow_dispatch: {} 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: permissions:
contents: write contents: write
@ -36,41 +23,22 @@ jobs:
# GitHub-ism this runner does not register, so a job requesting it sits in # GitHub-ism this runner does not register, so a job requesting it sits in
# "Waiting" forever with "no online runner found matching this label". # "Waiting" forever with "no online runner found matching this label".
runs-on: docker runs-on: docker
# The generator is now Ludic, so this builds the toolchain from its IR seed # Run in a Python image: the generator is pure-Python stdlib, and this image
# (clang assembles the seed into bin/ludicc, which compiles bin/ludic) exactly # already has git for the clone + publish. No node actions are used, so the
# like the ci workflow, then runs `ludic-dev docs-gen`. node:20-bookworm carries git # job never depends on the runner's base image having python installed.
# for the clone + publish; clang-16 is the only extra the bootstrap needs. container: python:3.12
container: node:20-bookworm
steps: 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 - name: Generate the documentation site
env: env:
SOURCE_REF: ${{ github.ref_name }} SOURCE_REF: ${{ github.ref_name }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: | run: |
set -eu set -eu
git config --global --add safe.directory '*' git config --global --add safe.directory '*'
git clone --depth 1 --branch "${SOURCE_REF:-main}" "$REPO_URL" src git clone --depth 1 --branch "${SOURCE_REF:-main}" \
cd src https://git.workshopsoft.io/workshopsoft/ludic.git src
# The toolchain is macOS-first; on this Linux runner it links against a python3 --version
# tiny C-free IR shim supplying the Darwin stdout/stderr globals over python3 src/tools/docgen/gen.py --out public
# glibc's, injected through LUDIC_CC. docs-gen is a pure CLI (no python3 src/tools/docgen/check.py public
# 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 ..
echo "--- generated files ---" echo "--- generated files ---"
ls -la public ls -la public
@ -79,8 +47,6 @@ jobs:
PAGES_TOKEN: ${{ secrets.PAGES_TOKEN }} PAGES_TOKEN: ${{ secrets.PAGES_TOKEN }}
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }} AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOURCE_SHA: ${{ github.sha }} SOURCE_SHA: ${{ github.sha }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
run: | run: |
set -eu set -eu
TOKEN="${PAGES_TOKEN:-${AUTO_TOKEN:-}}" TOKEN="${PAGES_TOKEN:-${AUTO_TOKEN:-}}"
@ -94,6 +60,5 @@ jobs:
git config user.email "docs@workshopsoft.io" git config user.email "docs@workshopsoft.io"
git add -A git add -A
git commit -q -m "docs: regenerate site from ${SOURCE_SHA}" 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 "https://ludic-docs-bot:${TOKEN}@git.workshopsoft.io/workshopsoft/ludic.git" pages
git push -f "${SERVER_URL%%://*}://ludic-docs-bot:${TOKEN}@${SERVER_URL#*://}/${REPO}.git" pages
echo "published $(git rev-parse --short HEAD) to pages" echo "published $(git rev-parse --short HEAD) to pages"

View file

@ -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"

1
.gitattributes vendored
View file

@ -1 +0,0 @@
packages/*/lib/** filter=lfs diff=lfs merge=lfs -text

37
.gitignore vendored
View file

@ -1,6 +1,6 @@
# Generated build tree: LLVM IR, objects, compiled apps, the headless render # 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` / # (build/out.ppm) and the docs site all land under build/ (see `bin/x build` /
# `bin/ludic clean`). Root-anchored so a source dir named "build" elsewhere is never # `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. # accidentally ignored. Nothing is written to the repo root any more.
/build/ /build/
@ -9,24 +9,16 @@
# packaged plugin .zip are local-only build inputs/outputs. # packaged plugin .zip are local-only build inputs/outputs.
*.zip *.zip
# the toolchain binaries (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp) — all built # the toolchain binaries (ludicc, ludic, x, ludic-fmt, ludic-lsp) — all built
# into bin/ by the one-line bootstrap + `bin/ludic-dev build`; never checked in. The # 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). # only thing published is the source and the LLVM-IR seed (selfhost/ludicc.seed.ll).
/bin/ /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 # editor toolchain build artifacts
tools/editors/vscode/node_modules/ tools/editors/vscode/node_modules/
tools/editors/vscode/*.vsix tools/editors/vscode/*.vsix
tools/editors/jetbrains/.gradle/ tools/editors/jetbrains/.gradle/
tools/editors/jetbrains/build/ tools/editors/jetbrains/build/
tools/editors/jetbrains/.kotlin/
# IntelliJ plugin SDK sandbox (tools/editors/jetbrains) # IntelliJ plugin SDK sandbox (tools/editors/jetbrains)
.intellijPlatform/ .intellijPlatform/
@ -39,24 +31,3 @@ tools/editors/jetbrains/.kotlin/
# Python bytecode cache from the docgen / release tooling # Python bytecode cache from the docgen / release tooling
__pycache__/ __pycache__/
*.pyc *.pyc
# Release artifacts produced by `ludic-dev release`
/dist/
# Build/release tarballs anywhere in the tree. `git -C <repo> 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/

984
BOOTSTRAP.md Normal file
View file

@ -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_<Name>` 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 <llty(T)>]`, already exactly how `@L_alive` and
`@S_<Comp>` 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 %<break>`. 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, <size_t>)` 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_<name>`. 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
```

File diff suppressed because it is too large Load diff

View file

@ -1,46 +1,37 @@
# Compiling Ludic # Compiling Ludic
> **Note:** `ludicc` is **written in Ludic** (`selfhost/*.ludic`) and built from a > **Note (2026-08-27):** `ludicc` is now **written in Ludic** (`selfhost/*.ludic`)
> checked-in IR seed — the C compiler this document once described has been > and built from a checked-in IR seed — the C compiler this document describes has
> deleted. The native pipeline below (Ludic → LLVM IR → object → binary) is > been deleted. The native pipeline below (Ludic → LLVM IR → object → binary) is
> unchanged. `ludicc` drives clang itself (via an `os_system` intrinsic), so > unchanged. `ludicc` now drives clang itself (via an `os_system` intrinsic), so
> `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly. `--fmt` is > `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly, and a sibling
> reimplemented as a lex+parse gate (the doc-check hook). The `--target`/ > command `ludic app.ludic` compiles to a temporary binary and runs it in one
> cross-compile and `--shared` paths are still features of the old C driver not > step. The whole toolchain is built by `bin/x build`; `bin/x app` remains as a
> yet re-implemented on the self-hosted toolchain. See the > convenience wrapper over the compiler. `--fmt` is reimplemented as a lex+parse
> [Bootstrap deep-dive](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Bootstrap) §5.7 on the wiki. > 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 > ```bash
> curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh # the toolchain, into ~/.ludic > # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/x
> ludic new mygame && cd mygame > clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x
> ludic run # compile + run > bin/x build # rebuild the whole toolchain into bin/
> ludic build --headless # compile, deterministic render > # (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 > The binaries are multi-call (one native binary under two names): invoked as
> CLI does the rest (run it from the repository root): > `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`
> ```bash > force the mode. The runtime (`runtime/native/cocoa.ll`) is found via
> # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/ludic > `$LUDIC_HOME`, defaulting to the directory the binary sits in — keep them in
> mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc > `bin/`, or set `LUDIC_HOME` and put them on `PATH`. `$LUDIC_CC` overrides the
> bin/ludicc --unsafe --globals tools/ludic-cli/dev.ludic -o bin/ludic-dev > assembler/linker (default `clang`).
> 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`).
`ludicc` is a compiler, not a translator. It lexes, parses, checks and lowers `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 app.ludic
│ ludicc — lex, parse, lower (selfhost/frontend/*.ludic, │ ludicc — lex, parse, check, lower (compiler/ludicc.c,
▼ selfhost/backend/*.ludic) ▼ compiler/native.c)
app.ll LLVM IR: your handlers, your properties, your runtime app.ll LLVM IR: your systems, your properties, your runtime
│ IR assembler (selfhost/main.ludic drives $LUDIC_CC) │ IR assembler (compiler/driver.c)
▼ ▼
app.o Mach-O / ELF / COFF object code app.o Mach-O / ELF / COFF object code
│ system linker │ 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 windowed native executable | `ludicc game.ludic -o build/game` |
| a headless executable | `ludicc game.ludic --headless -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 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 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` | | 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` | | 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 (see the note at the top). The rows above the line work today via the
self-hosted `ludicc`. self-hosted `ludicc`.
`bin/ludic build` wraps the common cases: `bin/x app` wraps the common cases:
```bash ```bash
bin/ludic build examples/games/snake.ludic # -> build/snake (native) bin/x app examples/games/snake.ludic # -> build/snake (native)
bin/ludic build examples/library/combat.ludic --lib # -> build/libcombat.* (library) bin/x app examples/library/combat.ludic --lib # -> build/libcombat.* (library)
bin/ludic build examples/games/snake.ludic --headless # -> build/snake_headless (out.ppm) bin/x app 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 --web # -> build/web/ (browser)
``` ```
The `--lib` and `--web` targets were part of the old C driver and are **not yet 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. windowed and `--headless` builds today.
## Programs and libraries ## Programs and libraries
> **Not yet on the self-hosted toolchain.** `--shared` and the `nm`/library > **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` > 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. > 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). (`Start`, then `Input → FixedUpdate → Update → LateUpdate → Render` each tick).
* A program with only an **`entry`** block is a tool: it runs `entry` and exits. * A **module** gets neither. It is a library, and only its `@export fn`s become
* Either kind can be a library: only its `@export function`s become public public symbols; everything else stays private to the library.
symbols; everything else stays private.
```ludic ```ludic
# doc-check: skip — illustrative: elided body # 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. symbol the window would have provided.
Other platforms build headless today. A Win32 or X11 port is another `.ll` file 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`, with the same five entry points — `win_open`, `win_poll`, `win_present`,
`win_running`, `win_close`), keys (`win_held`, `win_held_bit`), the mouse and `win_running`, `win_close` — and no compiler change.
cursor (`win_mouse`, `win_cursor_mode`, `win_cursor_confine`,
`win_cursor_maintain`), gamepad (`win_pad`) and touch (`win_touch`) — and no
compiler change.
## The web ## The web
@ -235,7 +219,7 @@ only the triple changes.
``` ```
```bash ```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/ 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 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 asserts exactly that, which is a much stronger check on the backend than
"it started". "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 graphics stack — framebuffer, PNG decoding, sprites, 9-slice, TrueType text and
the retained UI. 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 survives in `runtime/`, no C emitter survives in `ludicc`, and the examples all
build, run and render from IR alone. build, run and render from IR alone.
## Every flag ## 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:
``` ```
<file.ludic> the program to compile (first non-flag argument) <file.ludic> 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 --windowed force a windowed (Cocoa) build
--headless force a headless build (stdin input, out.ppm output) --headless force a headless build (stdin input, out.ppm output)
--emit-llvm stop at LLVM IR — write it and exit, no clang --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 --fmt lex + parse only; exit 0 if it parses, 1 on a parse error
(the check-docs gate; canonical formatting not yet restored) (the check-docs gate; canonical formatting not yet restored)
--save-temps keep the intermediate .ll --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) (unknown -flags are ignored with a warning, never taken as the input file)
environment: environment:
LUDIC_CC the LLVM that assembles IR and drives the linker (clang) LUDIC_CC the LLVM that assembles IR and drives the linker (clang)
LUDIC_HOME the install root — runtime/, packages/, VERSION LUDIC_HOME where runtime/native/ lives (default: the binary's dir)
(default: the parent of the binary's bin/ directory)
LUDIC_MODULES the project's fetched packages (default: ./ludic_modules)
``` ```
Mode is automatic when neither `--windowed` nor `--headless` is given: a program Mode is automatic when neither `--windowed` nor `--headless` is given: a program

View file

@ -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: From a clean checkout, one line lifts the toolchain off the seed:
```bash ```bash
mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x
bin/ludicc --unsafe --globals tools/ludic-cli/dev.ludic -o bin/ludic-dev
``` ```
That gives you `bin/ludic-dev`, the contributor tool: it replaces every That gives you `bin/x`, the Ludic task runner that replaces every build/test
build/test shell script in the repo and builds everything, including itself and shell script in the repo. From then on it builds everything — including itself:
`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.
```bash ```bash
bin/ludic-dev build # the whole toolchain into bin/ (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp) bin/x build # rebuild the whole toolchain into bin/ (ludicc, ludic, x, ludic-fmt, ludic-lsp)
bin/ludic-dev help # every contributor task bin/x help # list every command
bin/ludic help # what a user of the language sees
``` ```
Always run `ludic-dev` from the repository root, so `assets/` and `selfhost/` Always run `x` from the repository root, so `assets/` and `selfhost/` resolve.
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.)
## The development loop ## The development loop
@ -44,18 +37,18 @@ When you change the compiler or runtime, prove the self-hosting fixpoint still
holds before you push: holds before you push:
```bash ```bash
bin/ludic-dev reseed # regenerate selfhost/ludicc.seed.ll after a compiler change bin/x 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/x 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 test # the full regression suite
``` ```
Other useful targets: Other useful targets:
```bash ```bash
bin/ludic build <file.ludic> [--headless] # compile a program to a native app in build/ bin/x app <file.ludic> [--headless] # compile a program to a native app in build/
bin/ludic-dev selfhost-test # correctness + bootstrap fixpoints bin/x selfhost-test # correctness + bootstrap fixpoints
bin/ludic-dev test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp) bin/x test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp)
bin/ludic clean # remove build/, out.ppm and stray artifacts bin/x clean # remove build/, out.ppm and stray artifacts
``` ```
## Adding to the standard library ## 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 and register its id in `tools/docgen/inventory.json`. Each documented
namespace gets exactly **one** directory (the docs check enforces this). 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. 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. 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 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. [`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 ## Versioning & releases
The toolchain is versioned with [SemVer](https://semver.org); `VERSION` is the 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 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 ```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: 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
```bash `FORGEJO_TOKEN` set) to also push and create the Forgejo release with source and
ludic-dev release [major|minor|patch] # omit the level to derive it from the changesets toolchain tarballs. The tag doubles as the reproducible bootstrap point: the
git push origin main --follow-tags source archive plus its checked-in seed rebuild that exact toolchain.
```
`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.
## Conventions ## Conventions
@ -191,7 +99,7 @@ half-finished rename fails there rather than in someone's terminal.
| `perf` | a performance improvement | | `perf` | a performance improvement |
| `docs` | documentation only (`docs/`, README, comments) | | `docs` | documentation only (`docs/`, README, comments) |
| `test` | tests only | | `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/` | | `ci` | CI workflows under `.forgejo/` |
| `style` | formatting/whitespace, no behaviour change | | `style` | formatting/whitespace, no behaviour change |
| `chore` | routine housekeeping with no other bucket | | `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. so a green local commit is a green CI run.
- **Formatting:** `ludic-fmt` is the source of truth (2-space indent, LF, UTF-8); - **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 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 is a no-op — which leaves deliberate hand alignment in place; it is not a
blanket `fmt(x) == x`. blanket `fmt(x) == x`.
@ -241,44 +149,11 @@ non-destructive version of "tidy the history" without touching a single commit.
## Pull requests ## Pull requests
- Base your branch on `main`. - 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. pass, and that `ludic-fmt` leaves your files unchanged.
- Fill in the PR template checklist. Reference the issue you close with - Fill in the PR template checklist. Reference the issue you close with
`Closes #NN` in the description or a commit message. `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 ## Reporting issues
Use the templates under [`.forgejo/issue_template/`](.forgejo/issue_template): Use the templates under [`.forgejo/issue_template/`](.forgejo/issue_template):

670
EVENTS-DESIGN.md Normal file
View file

@ -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_<E>` dispatch (compile-time
> listeners), **plus the foreign C ABI** (`ludic_on_<E>`, the `%Ev_<E>` 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_<M>_spawn`/`_despawn`,
> [`examples/events/promote.ludic`](examples/events/promote.ludic)); **properties** (`@Public
> @OnAttach`/`@OnDetach`/`@OnEnable`/`@OnDisable` → `prop_<P>_attach` etc.,
> [`examples/events/prop_events.ludic`](examples/events/prop_events.ludic)); **scenes** (a
> `public` scene → `scene_<S>_enter`/`_exit`,
> [`examples/events/scene_events.ludic`](examples/events/scene_events.ludic)); and **layers** (a
> `public` layer + `enable/disable layer L` → `layer_<L>_show`/`_hide`,
> [`examples/events/layer_events.ludic`](examples/events/layer_events.ludic)) — which also
> landed **SCENES E2 layer toggle** (`@LE_<L>` 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_<E>(token)` (explicit
> unregister; dispatch skips tombstoned slots), `ludic_on_entity_<E>(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<T>` 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.<Name>.pre` / `.post` | `{ frame }` | the phase scheduler |
| model | `model.<M>.spawn` / `.despawn` | `{ entity, reason? }` | `@OnSpawn` / `@OnDespawn` |
| property (structural) | `prop.<P>.attach` / `.detach` | `{ entity, <fields> }` | `@OnAttach` / `@OnDetach` |
| property (toggle) | `prop.<P>.enable` / `.disable` | `{ entity }` | `@OnEnable` / `@OnDisable` |
| property (value) | `prop.<P>.change` | `{ entity, field, old, new }` | `@OnChange` (LC2) |
| query (membership) | `query.<Q>.enter` / `.exit` | `{ entity }` | `@OnStartMatch`/`@OnStopMatch` (LC3) |
| scene | `scene.<S>.enter` / `.exit` / `.push` / `.pop` | `{}` | `on enter`/`on exit`, `push`/`pop` |
| layer | `layer.<L>.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_<E>`
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_<P>_…`). Scenes and
layers use a `public` block modifier instead of an annotation:
`scene_<S>_enter`/`_exit` at the synthesized scene functions, and
`layer_<L>_show`/`_hide` at the layer-toggle site. Building layer events also
delivered **SCENES E2 layer toggle**: `enable/disable layer L` flips an `@LE_<L>`
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_<E>`, 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_<E>`
(-1 = program-scoped, ≥0 = owning entity); `ludic_on_<E>` and
`ludic_on_entity_<E>` register with the right owner; `ludic_off_<E>(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_<E>` 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.*

File diff suppressed because it is too large Load diff

348
LIFECYCLE-DESIGN.md Normal file
View file

@ -0,0 +1,348 @@
# Lifecycle events, expanded — a design doc
> **Status: LC0–LC1 shipped; LC2–LC6 are design.** The structural attach/detach
> pair and `@OnDetach` (§4, LC0), and reason-carrying `@OnDespawn` (§5, LC1), are
> implemented and tested ([`examples/lang/detach.ludic`](examples/lang/detach.ludic),
> [`examples/lang/reason.ludic`](examples/lang/reason.ludic), `bin/x test` checks). The
> extensions LC2–LC6 are research-informed proposals, not built. This document
> distills a survey of lifecycle models across seven systems (§3) into a roadmap
> for Ludic. §13 lists the open decisions.
---
## 1. Thesis
A game/ECS usually models lifetime as **create → destroy on a timeline**. A survey
of how other systems handle it — Unity (MonoBehaviour + DOTS), Unreal, Bevy,
flecs, EnTT, Godot, and non-game paradigms (actor model, declarative UI, RAII) —
shows that mature lifecycle designs model something richer than birth and death:
- **a reaction to a *reason*** — teardown that knows *why* it is ending (Unreal
`EndPlay(reason)`, Erlang `terminate(Reason)`, Akka `preRestart(reason, msg)`);
- **paired setup/teardown *keyed on dependencies*** — an update is teardown-then-
setup on a value change (React `useEffect`, Compose `DisposableEffect`);
- **a deterministic consequence of *scope / ownership*** — guaranteed, ordered,
single-shot teardown (C++/Rust RAII, DI scoped lifetimes);
- **an edge on *query membership*** — fire when data starts/stops matching a
composite condition (DOTS `OnStartRunning`, flecs `Monitor`).
Ludic's model is a good base: lifecycle hooks are `@`-annotations on handlers that
**desugar to ordinary code**, firing at fixed timeline moments, keeping the data
plain. This doc extends that base along the four axes above **without breaking the
desugars-to-code discipline** — every proposal lowers to plain branches and calls,
no hidden runtime.
**One structural advantage worth stating up front.** flecs and EnTT each carry
*two* lifecycle layers: a **memory** layer (ctor/dtor/move/copy — because C++
objects must be constructed and relocated as archetypes repack) and a **semantic**
layer (on_add/on_set/on_remove). Ludic's components are POD in packed `@S_`
arrays; there is nothing to construct, destruct, or move-relocate. **Ludic needs
only the semantic layer** — half the machinery, none of the "component isn't
movable" footguns. Keep it that way.
---
## 2. What Ludic has today
Seven hooks, each an annotation that desugars to a handler body at a timeline
moment ([LANGUAGE.md §Annotations](LANGUAGE.md)):
```
boot ─ @OnStart ─▶ spawn ─ @OnAttach(P), @OnSpawn(M) ─▶ … ─ @OnDetach(P)/@OnDespawn(M) ─▶ quit ─ @OnQuit
```
The lifecycle reads cleanest as a table of **paired setup/teardown** across five
scopes. Every cell is now filled — LC0 closed the one hole (`@OnDetach`):
| Scope | Setup | Teardown | Driven by |
|---|---|---|---|
| program | `@OnStart` | `@OnQuit` | boot / quit |
| entity | `@OnSpawn(M)` | `@OnDespawn(M)` | `spawn` / `despawn` |
| property (structural) | `@OnAttach(P)` | `@OnDetach(P)` ✅ | `attach` / `detach` |
| property (toggle) | `@OnEnable(P)` | `@OnDisable(P)` | `enable` / `disable` |
| scene | `on enter` | `on exit` | `become` |
Two things this table already gets right, which the survey flags as the frequent
mistakes to avoid:
- **The toggle pair is distinct from the structural pair.** Unity's clearest
lesson is separating the *repeatable* enable/disable cycle (pooling, pausing,
data kept) from the *once* create/destroy (data gone). Ludic has both, as
distinct verbs: `disable` pauses and keeps data; `detach` structurally removes
(a later `attach` re-seeds). This is exactly DOTS enableable-components vs
structural add/remove, and Bevy `disabled` vs `Remove`.
- **Hooks are typed annotations, not magic-named methods.** MonoBehaviour matches
`Awake`/`Update` by *string name* via reflection — a typo silently never runs.
Ludic's `@OnSpawn(Enemy)` is a checked reference; a wrong name is a compile
error. Preserve this.
What's missing is everything past "what happened": **why** it happened, **which
values changed**, **when composite conditions begin/end to hold**, and
**dependency-keyed** setup/teardown. That is the roadmap.
---
## 3. Research digest — the one idea to steal from each
| System | The transferable idea |
|---|---|
| **Unity MonoBehaviour** | Two-phase init with a global barrier (all `Awake` before any `Start`); repeatable enable-pair vs once create-pair. |
| **Unity DOTS** | *Data-driven activation*: `RequireForUpdate` + `OnStartRunning`/`OnStopRunning` — a system edge-triggers when its query starts/stops matching. Enableable components = cheap "logically off." |
| **Unreal** | *Reason-carrying teardown*: `EndPlay(EEndPlayReason)` — one teardown, branch on `Destroyed`/`LevelTransition`/`Quit`/…; forces enumerating every death path (no silent deaths). Provenance-tagged construction. |
| **Bevy** | Full structural event set Add/Insert/**Replace**/Remove/Despawn with strict order; **Replace exposes the old value before drop**. Hooks (type-level, singular, invariant) vs observers (plural, reactive). Declarative `before`/`after`/`chain` ordering. State `OnEnter`/`OnExit`/`OnTransition`. |
| **flecs** | `Monitor` observers fire on *composite query membership* start/stop. Events fire on **real transitions**, not every API call. Deferred-by-default with explicit sync points. |
| **EnTT** | `patch` as the *explicit mutation channel* that fires `on_update` (solves "raw writes are invisible"). Opt-in signals — zero cost when unused. |
| **Godot** | Tree membership *is* the lifecycle driver; enter top-down, **`_ready` bottom-up** (dependencies initialized first); `queue_free()` deferred safe-delete; `process_mode` pause inherited down the tree. |
| **Actor model (OTP/Akka)** | Lifecycle driven by *failure + supervision*: reason-carrying `terminate`, **restart as a state distinct from create/destroy** (stable identity, reset transient state), supervision trees, `code_change` = live state migration. |
| **Declarative UI (React/SwiftUI/Compose)** | *Paired setup/teardown keyed on a dependency list* — cleanup co-located with setup so it can't leak; an update **is** keyed teardown-then-setup; lifetime follows *identity*. |
| **RAII / Rust `Drop` / DI scopes** | *Scope = lifetime*: deterministic, reverse-construction-order, single-shot, no-resurrection teardown, guaranteed even on early exit; lifetime-mismatch checking (no long-lived thing holding a short-lived handle). |
Two recurring **footguns** the whole survey warns against, to design *out* of Ludic:
1. **Silent order-dependent reactivity.** Bevy's removal buffers are cleared at
end-of-frame, so a detector that runs before the mutator *misses removals
entirely*. If Ludic adds change/removal reactivity, make it either push-based
(fire at the mutation site — Ludic's natural style) or loudly order-checked.
2. **Invisible in-place writes.** flecs `on_set` and EnTT `on_update` don't fire
on a raw pointer write — you must call `modified()`/`patch`. Ludic can dodge
this entirely (see LC2): the compiler *sees* every write site.
---
## 4. LC0 — structural attach/detach + `@OnDetach` ✅ *shipped*
The one missing cell in §2's table. `attach P on e { overrides }` adds a property
to a **live** entity (seeding fields, firing `@OnAttach`); `detach P on e` removes
it (firing `@OnDetach`, which reads the outgoing value, before the has-flag
clears). Both fire only on a **real transition** (flecs/Bevy idempotent-add
semantics): re-attaching a present property or detaching an absent one is a no-op.
Lowering: `attach` guards on the has-flag and, when absent, reuses the existing
`emit_init_component` (seed + `@OnAttach`); `detach` guards on presence, clears the
flag, and fires `@OnDetach` with the property bound by name — the same binding the
`@OnDisable` path already uses. No new runtime; POD data stays in `@S_` storage.
See [`examples/lang/detach.ludic`](examples/lang/detach.ludic).
---
## 5. LC1 — reason-carrying teardown ✅ *shipped (`@OnDespawn`)*
The highest-conviction idea in the survey: it appears independently in Unreal
(`EndPlay`), Erlang (`terminate`), and Akka (`preRestart`), and Bevy has an open
issue asking for it. **Teardown should know *why*.** A destructor frequently needs
to branch — save on `Quit` but not on a scene swap, skip network cleanup when the
whole program is exiting.
`@OnDespawn` gains an optional bound **reason**:
```ludic
# doc-check: skip
# EndReason { Despawned, SceneExit, Quit } — the compiler owns this enum
@OnDespawn(Enemy, reason: r) handler Clean {
match r {
EndReason.Quit => {} # app closing — don't bother dropping loot
_ => drop_loot(Health.hp)
}
}
```
**What shipped.** The lowering is exactly the cheap desugars-to-code shape the
survey promises. The despawn hook compiles to `@on_despawn_<Model>(i32 %e, i32
%reason)`; when the hook writes `reason: r`, `r` is bound as an int local reading
`%reason`. Each teardown *site* passes a constant `EndReason`:
- `despawn e` passes `Despawned` (0) — an in-world death.
- **program shutdown** passes `Quit` (2): a generated `@L_despawn_all(reason)`
walks the live set at `done:` (before `@OnQuit`, matching the timeline) and
fires every survivor's `@OnDespawn`. This makes **"no silent deaths"** real —
an entity that outlives the run still gets its destructor, and can branch on
`Quit` to skip work that only matters mid-game. Emitted only when the program
has `@OnDespawn` hooks, so despawn-free programs are byte-for-byte unchanged.
- `SceneExit` (1) is reserved: a scene tearing down its owned entities
(SCENES-DESIGN E1) will pass it once scene-owned entities land.
`EndReason` is compiler-owned (resolved in `enum_ordinal`), so `EndReason.Quit`
works without a user declaration; a user enum of the same name still shadows it.
Backward-compatible: the `reason:` binding is optional, and `@OnDespawn` without
it is unchanged. `@OnDetach` and scene `on exit` do **not** yet take reasons
(§13.1). See [`examples/lang/reason.ludic`](examples/lang/reason.ludic).
---
## 6. LC2 — value-change hooks `@OnChange(P)` *(a compile-time win)*
Every reactive ECS wants "fire when a component's value changes" (flecs `on_set`,
EnTT `on_update`, Bevy `Changed<T>`), and every one hits the same footgun: a raw
in-place write is invisible, so you must route mutations through a special channel
(`modified()`, `patch`) or you miss changes.
**Ludic can sidestep the footgun because it is an AOT compiler that sees every
write site.** A field store `Health.hp = …` is a statement the compiler lowers; if
`Health` carries an `@OnChange`, the compiler can emit the hook call *right after
the store*. No dirty bits, no end-of-frame flush, no missed-write class of bugs —
the thing that is a runtime hazard everywhere else is resolved at compile time.
```ludic
# doc-check: skip
@OnChange(Health) handler Bar { hud_set_health(Health.hp) } # after any write to a Health field
```
Open question (§7): fire on *every* write (Bevy's `DerefMut` semantics — simple,
may over-fire) or guard with a value compare (fire only on actual change — needs
the old value, à la Bevy `Replace`). The compiler has the old value in hand at the
store site, so the value-compare form is feasible and is the more useful default.
---
## 7. LC3 — query-membership edges `@OnStartMatch` / `@OnStopMatch`
DOTS `OnStartRunning`/`OnStopRunning` and flecs `Monitor` fire when an entity
**starts or stops matching a composite query** — not a single component, but a
whole condition (`{Position, Velocity, moving}`). This is strictly more expressive
than per-property `@OnAttach`, which can't see "the entity now has *both* and is
alive." It's the natural ECS form of enter/exit.
```ludic
# doc-check: skip
@OnStartMatch(these: [Position, Velocity{dx != 0 or dy != 0}], on: Actor)
handler BeginMoving { play("footstep_loop.wav") }
@OnStopMatch(these: [Position, Velocity{dx != 0 or dy != 0}], on: Actor)
handler StopMoving { stop("footstep_loop.wav") }
```
Cost: unlike LC1/LC2 this needs runtime state — a per-entity shadow bit per
monitored query ("did it match last tick?"), checked once per frame, edge-
triggering the hook on a change. flecs does this by evaluating the query against
the entity's previous and current archetype. Ludic would keep a `@M_<query>` bit
array parallel to `@H_`. Medium cost; a genuinely differentiated feature.
---
## 8. LC4 — keyed effects (paired setup/teardown on a dependency list)
The declarative-UI headline, and the biggest reach. React `useEffect`, Compose
`DisposableEffect`, and SwiftUI `.task` all express: *while this thing exists (or
while key K holds), set up a resource; when it leaves or K changes, tear it down*
— with cleanup **co-located** with setup so it can't leak, and an *update* defined
as keyed teardown-then-setup. This collapses create/update/destroy into one
primitive.
```ludic
# doc-check: skip — sketch
@Effect(on: Enemy, keys: [Sprite.id]) handler Body {
let tex = image_load(Sprite.id)
dispose { image_drop(tex) } # runs on despawn OR when Sprite.id changes
}
```
Semantics: the setup runs on spawn (and whenever a listed key changes, after the
previous `dispose`), and `dispose` runs on despawn (and before each keyed re-run).
It unifies `@OnAttach`/`@OnDetach`/`@OnChange` into one leak-proof unit. Lowering
needs somewhere to stash the effect's captured teardown state and last key values
per entity — a per-effect side table, re-checked in a phase. Design only; the
syntax and storage model are open. This is where Ludic could feel genuinely modern
relative to every ECS surveyed (none of which have it).
---
## 9. LC5 — deferred structural changes with commit points
DOTS `EntityCommandBuffer`, flecs `defer_begin/end`, and Godot `queue_free()` all
make structural change **deferred with an explicit commit point**, so mutating
while iterating is safe and batched. Ludic's `spawn`/`despawn` are immediate today,
but *already* iteration-safe by a different route — matching is lazy per entity id
([LANGUAGE.md](LANGUAGE.md) "Matching is lazy, not snapshotted"), so despawning the
current entity is defined. A `defer { … }` block (or `despawn e at LateUpdate`)
that queues structural changes to a phase boundary would add batching and a single
predictable commit point, and is the prerequisite for safe parallel handlers
(the `reads`/`writes` scheduling in SCENES-DESIGN). Design only; lower priority
than LC1–LC3 because the immediate path is already safe.
---
## 10. LC6 — supervision, restart-as-a-state, live migration
The furthest-out cluster, from the actor model and OTP: lifecycle driven by
**failure**, not just create/destroy. Three ideas, all tied to Ludic's eventual
hot-reload / bytecode-VM roadmap rather than the near term:
- **Restart as a distinct state** between create and destroy — preserve an
entity's identity, reset its transient components, re-run setup (respawn,
hot-reload). Akka's "stable external ref, replaced internal state."
- **Supervision / failure escalation** — a subsystem owner declares a policy for
child faults (restart one / restart the group / escalate to reload the scene)
instead of defensive inline checks. Ludic has no failure model yet, so this
waits on one.
- **Live state migration** (`code_change`) — a hook that transforms an entity's
persistent state across a code/schema version, so hot-reload evolves data
instead of destroying it. Directly relevant to a self-hosting language.
---
## 11. Design principles distilled from the footguns
1. **No silent deaths.** Enumerate every teardown reason (LC1). If the compiler
must name the reason at each site, it can't forget a path.
2. **Fire on real transitions, not API calls.** Idempotent add/remove — LC0
already does this; keep it for every future hook.
3. **Keep "paused" and "gone" distinct.** `disable`/`enable` (data kept) vs
`detach`/`attach` (structural) — already true; don't let a future feature blur
them.
4. **Prefer compile-time resolution to runtime tracking.** LC2 turns the
universal "invisible write" footgun into a compile-time hook emission because
Ludic sees write sites. Reach for this wherever a runtime dirty-bit is the
obvious-but-worse option.
5. **If reactivity is order-dependent, make it loud.** Never silently drop events
at a frame boundary (Bevy's removal-buffer trap). Ludic's push-at-the-site
style avoids this by default.
6. **Deterministic teardown order.** When a scope tears down many things (a scene
unloading its owned entities — SCENES-DESIGN E1), define the order (reverse of
creation, RAII-style) rather than leaving it unspecified.
7. **Only the semantic layer.** POD components mean no ctor/dtor/move hooks. Don't
grow a memory-lifecycle layer Ludic doesn't need.
---
## 12. Suggested implementation order
- **LC0 — attach/detach + `@OnDetach`.** ✅ Done. Closes the structural pair.
- **LC1 — reason-carrying teardown.** ✅ Done for `@OnDespawn` (an `i32 %reason`
param + a constant at each site, plus a shutdown despawn-all for `Quit`).
`@OnDetach` / `on exit` reasons remain open (§13.1).
- **LC2 — `@OnChange(P)`.** Compile-time hook emission at write sites — a
Ludic-specific win over every ECS's invisible-write footgun. **Recommended next.**
- **LC3 — `@OnStartMatch`/`@OnStopMatch`.** First feature needing runtime shadow
state; the expressive ECS enter/exit.
- **LC4 — keyed effects.** The modern, leak-proof unification. Design first.
- **LC5 — deferred structural changes.** Batching + parallel-safety; the immediate
path is already iteration-safe, so lower urgency.
- **LC6 — supervision / restart / migration.** Waits on a failure model and the
hot-reload roadmap.
---
## 13. Open decisions
1. **Reason enum (LC1):** *resolved for `@OnDespawn`* — ships `Despawned`,
`SceneExit`, `Quit` as a compiler-owned `EndReason`, passed as an optional
`reason:` binding (not a separate annotation). Still open: `SceneExit` has no
firing site until scene-owned entities (SCENES-DESIGN E1); should `@OnDetach`
and scene `on exit` take reasons too, and if so with which reason values?
2. **`@OnChange` (LC2):** fire on every write (simple, over-fires) or only on an
actual value change (needs the old value at the store site)? Per-field or
whole-property granularity?
3. **Membership edges (LC3):** where do the shadow bits live, and is the check
per-frame or event-driven off attach/detach/spawn? Cost budget.
4. **Keyed effects (LC4):** syntax (`@Effect` annotation vs an `effect { … dispose
{ … } }` statement), and where per-entity teardown/key state is stored.
5. **Ordering:** none of this addresses intra-phase handler ordering (Bevy
`before`/`after`, flecs `DependsOn`). Worth a separate proposal; declarative
relational ordering over priority integers, per the survey.
---
*Companion to [LANGUAGE.md §Annotations](LANGUAGE.md) and
[SCENES-DESIGN.md](SCENES-DESIGN.md) (scene-owned entities and reasons intersect at
LC1/LC5). Supersedes nothing until the compiler work in §12 lands.*

1560
LUANTI-ROADMAP.md Normal file

File diff suppressed because it is too large Load diff

338
MOBILE-DESIGN.md Normal file
View file

@ -0,0 +1,338 @@
# iOS & Android — a design doc
> **Status: all design, nothing shipped.** Ludic builds windowed on macOS
> (`runtime/native/cocoa.ll`) and has a documented — but currently un-reimplemented
> — wasm32 web target. iOS and Android are not buildable today, and the
> cross-compile plumbing that would target them died with the C driver. This doc
> lays out the whole path so we can decide the shape before building any of it. The
> headline decision (§7): render on the **GPU via `extern fn` FFI**, not the CPU
> framebuffer. §11 lists the open decisions.
---
## 1. Where we are
A Ludic program compiles to LLVM IR, then clang assembles and links it. The
platform story has **two independent axes**, and it's essential not to conflate
them:
| Axis | What it is | State today |
|---|---|---|
| **Target** (triple + toolchain) | how IR becomes a runnable binary for an OS/arch | barely plumbed — no `--target`, no emitted `target triple`, host-only |
| **Platform runtime** (window/input/present) | one file implementing the 5-function window protocol | well-factored — `cocoa.ll` is ~328 lines, swappable |
**What exists:**
- The window seam is exactly five functions — `win_open` / `win_poll` /
`win_present` / `win_running` / `win_close` — declared by the compiler
([emit_head.ludic:58](selfhost/emit_head.ludic:58)) and lowered as intrinsics
([emit_intrin2.ludic:39](selfhost/emit_intrin2.ludic:39)). The runtime calls them
through `rt_*` wrappers ([core.ludic:48](runtime/native/core.ludic:48),
[:103](runtime/native/core.ludic:103), [:217](runtime/native/core.ludic:217)).
`COMPILING.md` states the intent plainly: a new platform is "another `.ll` file
with the same five entry points and no compiler change."
- **`extern fn` FFI is real and live** — `extern function c_hypot(a: fixed, b: fixed) ->
fixed = "hypot_fx"` ([LANGUAGE.md:565](LANGUAGE.md:565)), with a full pipeline:
parse ([parse_game.ludic:236](selfhost/parse_game.ludic:236)) → call lowering to a
direct `call @<sym>` ([emit_expr.ludic:168](selfhost/emit_expr.ludic:168)) →
`declare` emission ([emit_head.ludic:105](selfhost/emit_head.ludic:105)). Working
examples: [examples/networking/net_echo.ludic:12](examples/networking/net_echo.ludic:12),
[examples/library/arena.ludic:14](examples/library/arena.ludic:14). This is the single
most important fact in this document — see §7.
**What's missing (all of it must be built):**
| Gap | Why mobile needs it |
|---|---|
| `--target <triple>` flag + emitted `target triple`/`datalayout` | iOS = `aarch64-apple-ios`, Android = `aarch64-linux-android`; both are cross-compiles |
| per-target `size_t` width (i32/i64) | already a known wasm trap; every allocation sizing depends on it |
| **OS-owned frame loop** (`ludic_boot`/`ludic_frame`/`ludic_alive`/`ludic_teardown`) | iOS (CADisplayLink) and Android (Choreographer) own the loop — you cannot `while(alive)` |
| per-platform window shim + touch input | UIKit/`CAMetalLayer`, Android `Surface`/NDK; input is touch, not a keycode |
| SDK sysroot + packaging + signing | `.app` bundle / `.apk`, not a bare executable |
The frame-loop gap is shared with the web target — `tools/ludic-web/run.mjs`
already expects `ludic_boot`/`ludic_frame`, but the self-hosted emitter only
produces a monolithic `@main` ([emit_game.ludic:685](selfhost/emit_game.ludic:685)).
So the wasm path is half-broken for the same reason mobile can't exist yet.
---
## 2. Design principles
1. **Two axes, kept separate.** "Add a platform" = a cross-compile *target* plus a
platform *runtime*. Muddling them is why this looks bigger than it is. Most of
the compiler work (§4, §5) is target plumbing that serves web, iOS, and Android
at once; the per-OS work (§6) is genuinely small by design.
2. **The OS owns the loop — so we must too.** Mobile, like the browser, forbids an
inline frame loop. Rather than special-case mobile, adopt the frame-driven model
*everywhere* the OS demands it, from one emitter change. This is the keystone.
3. **The GPU is an ABI to call, not a program to compile.** `extern fn` already
binds C libraries; bind GL ES / Metal the same way. No IR-per-API (the `cocoa.ll`
route — 328 lines for *five* functions), no per-symbol intrinsics. The roadmap
reaches this conclusion independently ([LUANTI-ROADMAP.md:1083](LUANTI-ROADMAP.md:1083),
[:1375](LUANTI-ROADMAP.md:1375)).
4. **The 2D stack stays byte-identical.** The framebuffer graphics
(`rt_fb` + all `rt_*`/`image`/`truetype`/`ui` primitives) keep working
unchanged. GPU rendering is *additive*: 2D composites as one texture on top of
GPU 3D. Nothing above the window seam is rewritten.
---
## 3. Core model
Everything below reduces to plumbing one new flag through the compiler and swapping
two runtime files per OS. The mental model:
```
ludicc app.ludic --target aarch64-apple-ios -o app
│
├─ emit_head: target triple / datalayout / size_t width (§4)
├─ emit_game: ludic_boot/frame/alive/teardown not @main (§5)
├─ link: runtime/ios/uikit.ll + gfx3d.ldylib (§6, §7)
└─ package: .app bundle + codesign (§8)
```
The game source and the entire ECS/graphics/UI stack compile **unchanged** for
every target. Only the head declarations, the entry-point shape, the linked
platform file, and the packaging step vary.
---
## 4. Extension M1 — the target axis: `--target`, triple, `size_t`
Today [main.ludic:66](selfhost/main.ludic:66) parses `--windowed`/`--headless`/
`--emit-llvm`/… and nothing selects an arch; the IR carries no `target triple`, so
native inherits clang's host default and the only explicit triple in the tree is
`wasm32-unknown-unknown` ([runtime/web/wasm.ll:23](runtime/web/wasm.ll:23)).
Proposal: a `--target <triple>` flag that drives three things.
```
ludicc app.ludic --target aarch64-apple-ios -o app
ludicc app.ludic --target aarch64-apple-ios-simulator -o app # x86_64 host → arm64 sim varies
ludicc app.ludic --target aarch64-linux-android -o libapp.so
```
- **Emit the triple + datalayout.** `emit_header`
([emit_head.ludic:37](selfhost/emit_head.ludic:37)) gains a `target triple = …`
/ `target datalayout = …` line, chosen from a small table keyed on `--target`.
Absent the flag, emit nothing (host default) — keeps existing native builds
byte-identical.
- **Per-target `size_t` width.** wasm32 already needs `i32` sizes; the same helper
discipline (`ll_size_t`/`ll_widen`/`ll_narrow`, per the web-backend notes) applies
to any 32-bit target. iOS/Android arm64 are LP64 like macOS, so `i64` — but the
flag must *select* the width, not assume the host's.
- **Toolchain construction.** The linker command
([main.ludic:130](selfhost/main.ludic:130)) becomes target-conditional: an SDK
sysroot (`-isysroot`/`--sysroot`), the platform `.ll`, and target-specific link
flags (§8). `$LUDIC_CC` still overrides; add `$LUDIC_SYSROOT_<target>` for the
SDK path so CI and local machines can differ.
This axis is **shared with reviving wasm** — do it once, three targets benefit.
---
## 5. Extension M2 — the OS-owned frame loop (the keystone)
A native build emits `@main` with the frame loop inline — an `rt_init`, then a
`loop:`/`done:` block calling `rt_poll`/`rt_running`
([emit_game.ludic:685](selfhost/emit_game.ludic:685)). **iOS and Android cannot run
this.** UIKit calls back into your code once per display refresh (CADisplayLink);
Android's Choreographer does the same; the browser's `requestAnimationFrame` already
does. In all three the OS owns the loop and calls *you*.
Proposal: emit four exported functions instead of an inline-loop `@main`, exactly
as `COMPILING.md` already describes and `run.mjs` already expects:
```
ludic_boot() → rt_init (once)
ludic_frame() → rt_poll · systems · rt_present (per OS callback)
ludic_alive() → i1 → rt_running (OS asks: keep going?)
ludic_teardown() → rt_shutdown (once)
```
- **`@main` becomes the composed default, not the only shape.** For host desktop
and headless, the compiler synthesizes an `@main` that *calls* the four in an
inline loop — so native/headless output is unchanged in behavior. For
OS-owned-loop targets (`--target` is wasm/ios/android, or a new
`--loop=external` mode), emit only the four exports and no driving `@main`.
- **One emitter change, three targets fixed.** This simultaneously un-breaks the
web target (whose runner already calls these) and unlocks both mobile OSes. It is
the highest-leverage change in this doc.
- **State stays where it is.** The four functions close over the same globals
`rt_init`/`rt_poll`/`rt_running`/`rt_shutdown` already touch
([core.ludic:48](runtime/native/core.ludic:48)); no new runtime state, no heap.
---
## 6. Extension M3 — the per-OS window shim + touch input
Each OS gets one platform file implementing the five-function seam, modeled on
`cocoa.ll` but rewritten for its UI toolkit. This is the part the codebase is
explicitly built for.
- **iOS — `runtime/ios/uikit.ll` (or a thin `.m` shim).** `win_open` creates a
`UIWindow` + a `UIViewController` whose view is a `CAMetalLayer`/`MTKView`;
`win_present` presents the current drawable; the loop is driven by M2's
`ludic_frame` from a `CADisplayLink`, so `win_poll`/`win_running` adapt to the
callback model rather than a spin. Hand-written IR against `objc_msgSend` is
possible (it's how `cocoa.ll` works) but a small compiled `.m` linked in is more
maintainable for UIKit's larger surface — an open decision (§11).
- **Android — `runtime/android/ndk.ll` + a Kotlin/Java `Activity` host.** The
native code is a `.so` loaded by an `Activity`; the window is an
`ANativeWindow`/`Surface` obtained via `GameActivity`/NDK, GPU via EGL + GL ES.
Frames are driven by Choreographer through JNI into `ludic_frame`.
- **Touch input changes the input seam.** `win_poll()` returns a single `int`
keycode today ([emit_intrin2.ludic:41](selfhost/emit_intrin2.ludic:41),
[core.ludic:217](runtime/native/core.ludic:217)) — insufficient for touch, which
needs `(x, y, phase, id)`. Options: (a) a parallel `win_poll_touch() -> pointer`
draining an event queue, or (b) widen the input model to a small event struct for
all platforms. This is the one place mobile forces a decision above the window
seam. Proposed: add touch as a **separate** seam so keyboard platforms stay
untouched and byte-identical.
Everything above the seam — framebuffer, PNG sprites, TrueType, retained UI — is
portable Ludic and compiles unchanged.
---
## 7. Extension M4 — GPU rendering via `extern fn` (the headline)
Today **all** drawing writes into one CPU framebuffer: `rt_fb`, a
`words(320*240)` buffer of `0x00RRGGBB` i32 pixels
([core.ludic:23](runtime/native/core.ludic:23)), written by every primitive
(`rt_clear`/`rt_fill_rect`/glyphs/`rt_blend_px`/`tt_blit`/UI) and handed whole to
`win_present`. `cocoa.ll` blits it through CoreGraphics —
`CGBitmapContextCreate`→`CGImage`→`CGContextDrawImage` inside `@ludic_drawRect`
([cocoa.ll:94](runtime/native/cocoa.ll:94)). There is no GPU context anywhere.
Because **`extern fn` already exists**, binding the GPU is ordinary runtime code —
no new language feature, no new intrinsic:
```ludic
# doc-check: skip — runtime/native/gfx3d.ludic, illustrative
extern function gl_gen_textures(n: int, out: pointer) -> void = "glGenTextures"
extern function gl_tex_image_2d(t: int, w: int, h: int, px: pointer) -> void = "gl_tex_image_2d"
extern function gl_draw_elements(mode: int, count: int, ty: int, idx: pointer) -> void = "glDrawElements"
```
Two phases, additive:
1. **Framebuffer-as-texture (drop-in).** Keep the entire 2D stack. `rt_present`
([core.ludic:103](runtime/native/core.ludic:103)) uploads `rt_fb` as one texture
and draws a full-screen quad. The `win_present(fb,w,h)` signature is unchanged;
only the pixel-delivery core of the platform file differs (texture upload instead
of CoreGraphics blit). This is the minimum viable GPU path and gets mobile on
screen with zero changes above the seam.
2. **True GPU 3D (additive).** Geometry goes straight to GL/Metal via `gfx3d.ludic`
`extern fn` calls; the CPU framebuffer is reused only for the 2D UI overlay,
composited as a texture on top. New GPU-draw entry points live in `gfx3d.ludic`
as `extern fn`s — the five-function window protocol does **not** widen.
Language-level cost is narrow and already scoped by the roadmap:
- **`f32`** (roadmap gate G-04) for vertex/matrix data — the *only* hard language
dependency ([LUANTI-ROADMAP.md:1087](LUANTI-ROADMAP.md:1087)).
- Optional vector operator overloading for `v3f`/`m4` ergonomics (G-29,
[:1107](LUANTI-ROADMAP.md:1107)) — a "nicer, not necessary."
The roadmap's own decision is explicit: FFI over IR-per-API, because "`cocoa.ll`
is 327 lines for *five* window functions — OpenGL has hundreds of entry points"
([LUANTI-ROADMAP.md:1375](LUANTI-ROADMAP.md:1375)).
---
## 8. Extension M5 — packaging, SDKs, and signing
The current driver is one `clang` call ([main.ludic:130](selfhost/main.ludic:130))
producing a bare binary. Mobile output is a bundle, and this is where most
real-world friction lives — it is deliberately the *last* phase.
- **iOS.** Cross-compile with the iPhoneOS SDK sysroot → an executable, wrap in an
`App.app` bundle with an `Info.plist`, `codesign` with a development identity,
install to simulator/device. Simulator is the cheap inner loop
(`aarch64-apple-ios-simulator`); device needs a provisioning profile. ludicc
should emit the binary and shell a packaging step (or emit a manifest a small
script consumes), not learn Xcode's project format.
- **Android.** Cross-compile with the NDK → `libapp.so`, drop it into a minimal
Gradle/Kotlin `Activity` shell, build the `.apk`/`.aab`, sign with a keystore.
The `Activity` is fixed boilerplate that ships in the repo (`runtime/android/`),
parameterized by app name/id.
- **Keep the compiler out of it.** Both flows are "produce native code + assemble a
package around it." The compiler's job ends at the object/`.so`; a `--package`
step or an external `build-mobile.sh` owns the bundle. This mirrors how ludicc
already drives clang without becoming a build system.
---
## 9. Lowering / build summary
| Construct | Reduces to |
|---|---|
| `--target <triple>` (M1) | a triple/datalayout line in `emit_header` + a `size_t`-width choice + target-conditional link command |
| OS-owned loop (M2) | emit `ludic_boot`/`ludic_frame`/`ludic_alive`/`ludic_teardown`; host/headless get a synthesized `@main` calling them |
| window shim (M3) | one `.ll`/shim per OS implementing the same five `win_*` intrinsics; no compiler change |
| touch input (M3) | a **new, separate** input seam (`win_poll_touch`), so keycode platforms stay byte-identical |
| framebuffer→texture (M4.1) | `rt_present` uploads `rt_fb` as a texture + full-screen quad; `win_present` signature unchanged |
| GPU 3D (M4.2) | `extern fn` calls in `runtime/native/gfx3d.ludic` — data in `prog`, zero compiler edits, needs only `f32` |
| packaging (M5) | binary/`.so` unchanged; an external `--package`/script builds `.app`/`.apk` and signs |
No new allocator, no new dispatch, no per-API intrinsics. The game and the 2D
graphics stack compile identically for every target; only head declarations, the
entry-point shape, the linked platform file, and packaging vary.
---
## 10. Suggested implementation phases
Each is independently shippable and testable, matching how the repo phases work.
- **M0 — target axis** (M1) + **revive the OS-owned loop** (M2). *Do these first
and together* — they're the shared compiler plumbing, they un-break the existing
web target (proving the frame-loop split against `run.mjs`/`bin/x test` before any
mobile SDK is involved), and they need no mobile toolchain. This is the floor.
- **M1 — iOS simulator, framebuffer-as-texture** (M3 iOS shim + M4.1). First pixels
on a phone, GL/Metal binding proven, no signing/device friction yet.
- **M2 — iOS device** (M5 iOS packaging + signing).
- **M3 — Android** (M3 Android shim + M4.1 + M5 Android packaging), reusing every
M0 change.
- **M4 — `f32` + GPU 3D** (M4.2), gated on roadmap G-04; the additive 3D path over
`gfx3d.ludic`.
- **M5 (later) — touch-input model** hardening (M3), gesture/multitouch, once a real
app exercises it.
M0 is the honest prerequisite and the highest-leverage work — it serves three
targets and revives a fourth. M1 is the first thing anyone can *see*.
---
## 11. Open decisions
1. **Loop selection:** does `--target ios/android/wasm` *imply* the external loop,
or is there an explicit `--loop=external` flag? (Proposed: implied by target,
with the flag as an override for headless testing.)
2. **iOS shim language:** hand-written `.ll` against `objc_msgSend` like `cocoa.ll`,
or a small compiled `.m`? (Proposed: `.m` — UIKit's surface is too large for
maintainable IR, and Metal setup is verbose.)
3. **Touch seam shape:** a separate `win_poll_touch` queue, or a unified event
struct replacing the keycode `win_poll` on all platforms? (Proposed: separate,
to keep desktop/web byte-identical.)
4. **GPU API baseline:** GL ES 3.0 everywhere (Android native, iOS via ANGLE/Metal
translation), or Metal on iOS + GL ES on Android from day one? (Proposed: GL ES
3.0 first for a single codepath; Metal later.)
5. **Android host:** ship a fixed Kotlin `GameActivity` in `runtime/android/`, or
generate it per app? (Proposed: fixed boilerplate, parameterized by name/id.)
6. **Packaging home:** a `--package` step inside ludicc, or an external
`build-mobile.sh`? (Proposed: external script; keep the compiler out of bundle
formats.)
7. **`size_t` for arm64:** confirm iOS/Android arm64 are LP64 (`i64`) in the width
table, and that the `ll_size_t` discipline covers every new size-taking call.
8. **Simulator arch:** how to handle `aarch64-apple-ios-simulator` vs. x86_64 sim on
Intel hosts in the target table.
---
*Companion to [COMPILING.md](COMPILING.md) (§ toolchain, the wasm frame-loop
split), [LANGUAGE.md §"Functions & FFI"](LANGUAGE.md:560) (`extern fn`), and
[LUANTI-ROADMAP.md](LUANTI-ROADMAP.md) (G-04 `f32`, G-28 GPU FFI, G-29 3D math).
Supersedes nothing until the M0 compiler work lands.*

505
NETWORKING-DESIGN.md Normal file
View file

@ -0,0 +1,505 @@
# Networking, from primitives up — a design doc
> **Status: N0–N6 all shipped.** The whole stack is implemented and self-hosted,
> and — unlike the original N0/N1 which linked C hosts — every phase now runs as a
> self-contained **pure-Ludic** program (no `.c`, no foreign host): a built-in
> loopback transport fills the seam, and each `examples/net_*.ludic` drives and
> asserts itself from its own `entry`. See `bin/x test` (checks `net_echo` … `net_demo`)
> and `examples/networking/net_demo.ludic` for a full RPC→authority→replicate→reconcile loop.
> clang remains only as the LLVM-IR assembler/linker (no C is compiled), the floor
> Rust and Swift stand on.
>
> _Historical note:_ **N0 + N1 shipped first; N2–N6 were design.** Two phases landed as `bin/x test`
> checks. **N0 (transport seam):** `extern fn` now lowers end to end — a direct
> `@<sym>` call plus a `declare`, no networking logic in the compiler — so the whole
> transport is two externs (`net_send`/`net_poll`) a host fills. Proven by
> [`examples/networking/net_echo.ludic`](examples/networking/net_echo.ludic) sending four bytes through
> the loopback host in [`tests/net_c/loopback.c`](tests/net_c/loopback.c) and
> polling them back (`4 10 20 30 42`). **N1 (snapshot-to-buffer):**
> `world_size()`/`world_save(buf)`/`world_load(buf, len)` generalize `save()`/`load()`
> from a file to a caller-owned memory buffer — the same block layout via `memcpy` —
> so the whole ECS world round-trips through bytes. Proven by
> [`examples/networking/net_snapshot.ludic`](examples/networking/net_snapshot.ludic) +
> [`tests/net_c/snapshot_mod.c`](tests/net_c/snapshot_mod.c) (snapshot, mutate,
> restore → `50 7 50`). Both are byte-identical when unused, so the offline dividend
> (§8) holds. This is a companion to
> [EVENTS-DESIGN.md](EVENTS-DESIGN.md), [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md),
> and [SCENES-DESIGN.md](SCENES-DESIGN.md). Where the events work made Ludic
> *moddable*, this proposes making it *networked* — and it deliberately does **not**
> ship a multiplayer framework. Ludic is a language: it exposes the low-level
> mechanism (transport seam, world snapshot, generated serializers, ownership, a
> drivable sim) and a thin high-level *declarative* layer that lowers onto that
> mechanism, and it leaves the netcode *policy* (authority, prediction, relevancy)
> to the developer or a library. §14 lists the open decisions.
---
## 1. Thesis
Every networking model dies on one of two problems: **determinism** or **state
serialization**. Ludic already solves both, almost by accident.
- **Determinism** is designed in — seeded RNG, `fixed` (Q16.16) instead of floats,
byte-identical golden renders, and (as of [EVENTS-DESIGN EV6](EVENTS-DESIGN.md))
bounded, array-ordered event dispatch. A modded, event-driven Ludic game still
replays identically. That is exactly the property lockstep multiplayer needs, and
the reason Factorio's heavily-modded multiplayer stays in sync.
- **State serialization** already exists — `save()`/`load()` snapshot the *entire*
ECS World to a byte buffer ([`selfhost/emit_save.ludic`](selfhost/emit_save.ludic)),
and the world-table schema built for [EVENTS-DESIGN EV2](EVENTS-DESIGN.md) (prop →
field → offset) is exactly the descriptor you serialize against.
So networking is not a new subsystem. It is a **fourth lens on the event + world
layer** — the same layer modding used. And it obeys the same two-altitude rule as
everything else in Ludic:
> **Low-level is freedom; high-level is developer experience; they are the same
> feature at two altitudes.** `@Queries` lowers to a query loop, `scene` lowers to a
> machine, `@Public @OnSpawn` lowers to `emit`. Networking's high-level annotations
> lower to a transport seam, generated serializers, and a drivable sim — and the
> primitives stay exposed underneath for anyone the sugar doesn't fit.
The developer writes **one simulation**, declares *what* replicates, *who* owns
each entity, and *where* each handler runs — and never branches on `is_server()`
in ordinary code. The compiler lowers the declarations; a networking *runtime*
(the seam-filler, like `rt_*` for windowing) supplies the transport and the tick.
---
## 2. Two altitudes, one system
| Altitude | Who writes it | Surface |
|---|---|---|
| **High-level (DX)** | the developer, declaratively | `@Sync` (field/property/model), `@Owned`, `@Server`/`@Predicted`, directional remote events |
| **Lowering** | the compiler | per-model serializers, role-guarded dispatch, remote-event send/recv, ownership storage |
| **Runtime seam** | a networking library (blessed or custom) | binds the socket, sets `role`, drives the replication tick |
| **Low-level (freedom)** | power users, when the sugar doesn't fit | `net_send`/`net_poll`, `world_save`/`world_load`, generated `serialize_*`/`apply_*`, `owner()`, the drivable sim |
Everyone lives at the top row for normal games; the bottom row stays open for
someone building something no framework could express. The split that keeps this a
*language* and not a *framework*: **annotations and their lowering are the language;
the replication driver and the transport are a library.** It is precisely the
events story — `@On`/`emit` are the language, the *modding system* is library code —
applied again.
---
## 3. Research digest — the one idea to steal from each
| System | The transferable idea |
|---|---|
| **Quake / QuakeWorld** | The founding pattern: **client-side prediction + server reconciliation**, and delta-compressed snapshots against the last acked baseline. Predict locally, correct from the authority. |
| **Source (Valve)** | **Entity interpolation** (render remote entities slightly in the past, smoothly) paired with **lag compensation** (the server rewinds to the shooter's view for hit detection). Interpolation and rewind are two halves of one clock discipline. |
| **Unity NGO** (GameObject) | `NetworkVariable<T>` with **read/write permissions** + `OnValueChanged`; ownership as `OwnerClientId`. Also the **anti-pattern to avoid**: `IsServer`/`IsOwner` branching sprinkled through gameplay code. |
| **Unity Netcode for Entities** (ghosts) | The model Ludic is closest to: **replication is a compile-time property of components and fields** — `[GhostField]`, `[GhostComponent]`, `GhostOwner`, and `Predicted`/`Interpolated` ghost modes — with serializers *generated* from the ECS schema. |
| **Mirror / FishNet** | The community-ergonomic take: `SyncVar` with change **hooks**, and clean **directional RPCs** — `Command` (client→server) / `ClientRpc` (server→clients). |
| **GGPO / rollback** | Save state → predict → on misprediction **restore and re-simulate**. Its one hard requirement is *cheap, complete state snapshot/restore* — which Ludic already has in `save()`/`load()`. |
| **Factorio** | Fully **deterministic lockstep** for heavy mod multiplayer: only *inputs* cross the wire; the whole sim is reproduced. Proof that determinism (EV6) is the enabler, not a nicety. |
| **Photon Quantum** | A shipping product that *is* deterministic-ECS-rollback. Validates the exact combination — ECS + determinism + rollback — Ludic is already positioned for. |
| **Roblox** | The **local/remote split** (`BindableEvent` vs `RemoteEvent`), server-authority by default, and engine-replicated properties: "some state just replicates, and RPCs are directional events." |
Six **footguns** the survey warns against, to design *out* from the start:
1. **Role branching everywhere.** `if (IsServer)` scattered through gameplay is the
NGO readability tax. Fix: **role is a handler annotation** (`@Server`/`@Predicted`),
never a runtime branch in ordinary code.
2. **Float nondeterminism.** Lockstep breaks the instant the networked sim touches
`f32` across platforms. Fix: the determinism contract (§11) — the networked sim
stays `int`/`fixed`.
3. **Replicating pointers / heap refs.** A `ptr` field holds a machine-local
address; it cannot cross the wire. Fix: **the compiler rejects `@Sync` on a
non-POD-scalar field** — a checked guarantee, not a convention.
4. **Sending everything every tick.** Fix: `@Sync` is **opt-in at the field level**
(only marked fields replicate), plus change-driven dirty tracking (`@OnChange`,
[LIFECYCLE LC2](LIFECYCLE-DESIGN.md)) so an unchanged field costs nothing.
5. **Hidden authority.** Magic "the server decides" behavior is unclear and
unauditable. Fix: **explicit** `@Server`/`@Predicted`; unmarked code runs
everywhere by definition.
6. **Schema-less snapshots.** A raw state blob with no version desyncs silently on a
version mismatch. Fix: the **world-table schema is the versioned descriptor** the
serializer is generated against.
---
## 4. What Ludic already has
The substrate is unusually complete for an engine that has never networked:
- **A deterministic simulation** — seeded RNG, `fixed` math, ordered ECS iteration,
EV6-bounded event dispatch. Lockstep's precondition.
- **World snapshot/restore** — `save()`/`load()` serialize the whole World
([emit_save.ludic](selfhost/emit_save.ludic)); today to a file, trivially
retargetable to a memory buffer. Rollback's precondition.
- **A reflective world table** — `ludic_get`/`set`/`has`/`query`/`register_prop`
and the prop→field→offset schema (EV2/EV2b). The apply-and-serialize substrate.
- **An event bus with a foreign ABI and POD payloads** (EV0). Directional remote
events (RPCs) are one flag on this.
- **The `rt_*` seam pattern** — the compiler already emits calls to
`rt_init`/`rt_poll`/`rt_present` that a runtime library fills. Networking's
transport and role registers plug into the identical seam.
What is missing is small and named: a transport seam, snapshot-to-*buffer*,
generated per-field serializers, ownership storage, role-guarded dispatch, and a
developer-drivable loop. Each is a phase in §13.
---
## 5. The low-level primitives (the freedom layer)
Unopinionated, composable, host- or developer-owned. A power user builds any model
directly from these; the high-level layer (§6) is sugar over them.
| Primitive | Signature (sketch) | Enables |
|---|---|---|
| **Transport seam** | `extern function net_send(peer: int, buf: pointer, len: int)` · `extern function net_poll(buf: pointer, cap: int) -> int` | any model; host binds UDP (native) or WebRTC/WebSocket (wasm), or a loopback for tests |
| **World snapshot ↔ buffer** | `world_save(buf: pointer) -> int` · `world_load(buf: pointer, len: int)` | rollback, replication, join/resync — generalizes `save()`/`load()` off the filesystem |
| **Generated serializers** | `serialize_<Model>(e: entity, buf: pointer) -> int` · `apply_<Model>(e: entity, buf: pointer, len: int)` | per-model, touch only the `@Sync` fields; emitted from the schema |
| **Ownership** | `owner(e: entity) -> int` · `set_owner(e: entity, id: int)` | authority checks, per-entity owner metadata (an `@L_owner` array, like `@L_kind`) |
| **Role registers** | `is_server() -> bool` · `is_owner(e: entity) -> bool` · `local_id() -> int` | the runtime sets these; role-guarded dispatch reads them |
| **Drivable sim** | `tick_fixed()` · `tick_render()` · seed get/set | a developer-owned loop for prediction/rollback (also: replay, headless tests, AI) |
| **Remote-event serde** | `emit`-site serialize + `net_send`; inbound bytes rebuild + re-`emit` | RPCs |
Transport is the one that needs *no* language work at all — a developer can already
`extern fn` a socket library and link it, exactly as the windowing layer is linked.
The language's genuine contributions are snapshot-to-buffer, the generated
serializers, ownership storage, and the drivable loop.
```ludic
# doc-check: skip — the freedom layer, a hand-rolled replication tick
entry {
while running() {
if is_server() {
for (Transform) in query [Transform, Owned] {
let n = serialize_Player(self(), buf) # compiler-generated
net_send(ALL, buf, n) # developer's transport
}
} else {
let n = net_poll(buf, CAP)
if n > 0 { apply_Player(target_of(buf), buf, n) }
}
tick_render(); present()
}
}
```
This *works*, but it is deliberately not how most games should be written — it puts
serialization and role branching in the developer's face. That is what §6 fixes.
---
## 6. The high-level DX layer (the default)
The developer declares **what** replicates, **who** owns, and **where** handlers
run. No serialization, no transport, no `is_server()` in ordinary code.
### 6.1 `@Sync` — what replicates, at three granularities
Replication is **opt-in at the field level**: a field crosses the wire only when it
is explicitly marked. There is no `@NoSync` — the surface is purely additive.
Two independent switches, and **both must be on** for a field to replicate:
1. **A field is *replicable*** iff it is `@Sync`-marked — directly
(`@Sync hp: int`), or via `@Sync property P { … }` (a shorthand that marks
*every* field of `P` replicable). *Only marked fields — never all-by-default.*
2. **A component *participates* in a model** iff the model marks it `@Sync`
(`@Sync Transform` inside the `model`). Participation is decided **per model
use-site**, so the same property syncs in one model and not another.
A field of an entity replicates **iff it is replicable AND its component
participates in that entity's model.**
```ludic
# doc-check: skip — the three levels
@Sync property Position { x: int, y: int } # every field of Position is replicable
property Health { @Sync hp: int, max: int } # only hp is replicable; max never is
property Transform { @Sync x: int, @Sync y: int, angle: int } # x, y replicable; angle not
@Owned model Player { # entities carry a network owner
@Sync Transform # participates → replicates x, y (not angle)
@Sync Health # participates → replicates hp (not max)
@Sync Position # participates → replicates x, y
}
model Prop { # a non-owned decoration
Transform # not @Sync here → Transform does NOT replicate — the
# "non-synced Transform sometimes" case, for free
}
```
- **Checked, not silent.** `@Sync` on a `ptr`/non-POD-scalar field is a **compile
error** ("networked fields must be POD scalars" — footgun 3). A model that
`@Sync`es a component with *zero* replicable fields is a **compile warning**
(participation that replicates nothing).
- **Per-field direction** rides the same annotation as an argument, mirroring how
`@Queries(these:…, on:…)` takes args: `@Sync(to: owner) hp: int` replicates a
field only to the entity's owner (Unity's `SendToOwner`). Default is `to: all`.
### 6.2 Roles — where a handler runs
The role is a **declarative annotation on the handler**, never a runtime branch.
Unmarked code is the shared, deterministic simulation and runs everywhere.
| Annotation | Runs where | Meaning |
|---|---|---|
| *(none)* | everywhere | shared, deterministic simulation |
| **`@Server`** | the authority only | server-authoritative logic; clients receive the result via `@Sync` |
| **`@Predicted`** | the owning client (speculatively) **and** the server (authoritatively) | responsive local control, auto-reconciled against the server |
`@Predicted` is **explicit** — the developer opts an owned entity's control handlers
into prediction; the language does not silently predict. The name states the netcode
role (owner-predicts + server-authoritative + reconcile), not the machine, and
matches Unity's `GhostMode.Predicted` so the concept transfers.
`@Interpolated` — how a *non-owned* synced component is smoothed between snapshots on
a remote client — is a **presentation** concern on the component, kept separate from
these sim-handler roles rather than muddying them.
### 6.3 Ownership
```ludic
# doc-check: skip
@Owned model Player { @Sync Transform; @Sync Health } # every Player entity has a network owner
```
`@Owned` gives the model an owner slot (the `@L_owner` array); `owner(e)` /
`set_owner(e, id)` read and assign it (the authority assigns). `is_owner(e)` and
`@Predicted` dispatch read it. Ownership gates who may write `@Sync(to: owner)`
fields and who runs `@Predicted` handlers.
### 6.4 RPCs are directional remote events
RPCs are the event bus with a direction flag — no new concept:
```ludic
# doc-check: skip
@ToServer event Fire { dir: int } # client → server (a request)
@ToClients event Boom { x: int, y: int } # server → clients (a broadcast)
@Server @On(Fire) handler DoFire { spawn Bullet { dir: Fire.dir } } # authority handles the request
@On(Boom) handler Vfx { spawn Explosion { x: Boom.x, y: Boom.y } } # every client reacts
```
`@ToServer`/`@ToClients` mark an `event` remote; the compiler serializes its POD
payload (already flat — [EVENTS-DESIGN EV0](EVENTS-DESIGN.md)) and routes it through
the transport seam in the declared direction, re-`emit`ting it on the far side into
the ordinary event dispatch.
### 6.5 The whole game, high-level
```ludic
# doc-check: skip — read top to bottom: you always know where each line runs
program Shooter {
@Sync property Position { x: int, y: int }
property Health { @Sync hp: int, max: int }
@Owned model Player { @Sync Position; @Sync Health }
model Bullet { Position }
handler Physics phase FixedUpdate { … } # no tag → shared, identical everywhere
@Predicted handler Move phase Input { … } # owner predicts, server authoritative
@Server handler Death phase Update { … } # authority only; clients get the result via @Sync
@ToServer event Fire { dir: int }
@Server @On(Fire) handler DoFire { spawn Bullet { … } }
}
```
No `is_server()`, no `net_send`, no serializer — yet every line's role is legible,
and every replicated field is explicitly opted in.
---
## 7. Lowering summary
Everything above reduces to the §5 primitives, gated so an un-networked build is
unchanged:
| High-level | Lowers to |
|---|---|
| `@Sync` field / `@Sync C` in a model | a per-model `serialize_<M>` / `apply_<M>` over the replicable-and-participating fields, + a `sync manifest` a runtime reads |
| `@Sync(to: owner)` | a field tag in the manifest; the serializer branches on `owner(e) == peer` |
| `@Owned` | an `@L_owner` array + `owner()`/`set_owner()`, like `@L_kind` |
| `@Server` / `@Predicted` handler | the handler's dispatch wrapped in a role guard the runtime's role register drives (the `rt_*` seam pattern) |
| `@ToServer` / `@ToClients event` | payload serialize + `net_send(direction, …)` at the `emit` site; inbound bytes rebuild + re-`emit` |
| `world_save`/`world_load` to buffer | the existing `save()`/`load()` snapshot machinery, retargeted from a file handle to a memory buffer |
| drivable `tick_fixed`/`tick_render` | the phase runners the compiler already generates for the frame loop, exposed as callables when a game owns its `entry` loop |
No heap, no hidden runtime beyond the honestly-named transport/role seams a
networking library fills — the same relationship windowing already has.
---
## 8. The offline dividend
Because these are **opt-in-cost annotations** — serializers *generated*, nothing
*run* until a networking runtime is spliced — a build with no runtime is
**byte-identical to single-player**, and every role guard collapses to "run here."
You build the game offline, drop in a runtime, and the same annotated code starts
replicating. That is Unity's "offline mode adjustable," achieved by the same
opt-in-cost invariant the whole event system already holds.
---
## 9. The one genuinely hard corner
Determinism holds beautifully for `int`/`fixed` simulations, which makes lockstep
and rollback cheap. It **breaks for `f32` across platforms** — so **3D/voxel +
lockstep stays the hard corner** (3D wants floats; the Luanti analysis flagged that
`fixed` saturates at ±32768). No language sleight-of-hand fixes this; the
determinism contract (§11) states it plainly, and a developer choosing lockstep for
a 3D game has to accept it (or choose state replication, §10's other branch, where
per-frame determinism is not required).
---
## 10. Two model families, both reachable — neither built in
The language commits to **neither**; both are library policy over the §5 primitives.
- **Deterministic lockstep / rollback** — exchange only inputs; reproduce the sim;
on misprediction, `world_load` a snapshot and re-`tick_fixed`. Plays to Ludic's
determinism, and GGPO-cheap because snapshot/restore already exists. Best for
2D/integer/fixed games.
- **State replication** — the authority `world_save`s (or per-`@Sync` serializes),
delta-encodes against the last acked snapshot per peer, ships the diff; peers
`apply_*` it and interpolate/predict. Heavier, but needed when the sim can't be
deterministic (float physics, 3D).
A **blessed reference runtime** (§13, N6) can ship one of these so `@Sync` games
work out of the box — the way [`tests/mod_c/mod.c`](tests/mod_c/mod.c) proved the
event ABI — while the seams stay open for others.
---
## 11. The determinism contract (what the language must guarantee)
For a developer to *trust* lockstep, the language must promise, document, and where
possible *enforce*:
1. **`fixed`/`int` math is bit-identical across platforms.** The networked sim must
avoid `f32` (footgun 2). *(Enforcement: at least a documented rule; ideally a
`@Sync`/`@Server`-reachable-code float lint.)*
2. **ECS iteration order is stable** — query order is declaration/id order, and
EV6 already fixes event-dispatch order. No hash-map iteration in the sim path.
3. **RNG is deterministic from a shared seed** — `seed()` exists; the seed must be
synchronized at session start (library policy) and never re-seeded from
wall-clock mid-sim.
4. **Networked components are POD scalars** — no `ptr`/heap fields cross the wire
(footgun 3). *Enforced:* `@Sync` on a non-scalar field is a compile error.
5. **Entity ids agree across peers** — lockstep gets this free from determinism;
replication needs an id-mapping table (library policy).
This contract is the language's real networking responsibility. Most of it is
*already true*; the work is stating and enforcing it, not inventing it.
---
## 12. Design principles
1. **Mechanism in the language, policy in the library.** Expose serializers,
transport seam, ownership, snapshot, drivable sim. Never bake in authority,
prediction, or matchmaking.
2. **Role is declared, not branched.** `@Server`/`@Predicted` on handlers; unmarked
code runs everywhere. No `is_server()` in ordinary gameplay.
3. **Replication is explicit and opt-in.** Only `@Sync`-marked fields cross the
wire; participation is decided per model. Nothing replicates by surprise.
4. **Opt-in cost.** Un-networked builds are byte-identical; the sim runs offline
with the same code.
5. **Determinism is a promise the language keeps.** Enforce the POD-scalar rule;
document the float/iteration/seed rules; keep the sim reproducible.
6. **Two altitudes, always.** The high-level lowers to primitives that stay
callable. The sugar is the default; the freedom layer is never removed.
7. **Reuse, don't reinvent.** Snapshot = generalized `save()`; RPC = directional
`event`; serializer = generated from the EV2 schema; role seam = the `rt_*`
pattern. Networking is the fourth lens, not a parallel stack.
---
## 13. Suggested implementation order
Each phase is independently shippable and testable, matching how the repo phases
work (and how EVENTS-DESIGN sequenced EV0–EV7).
- **N0 — transport seam + loopback. ✅ SHIPPED.** The `net_send`/`net_poll` extern
seam and a loopback host stub; an echo test. The floor; needed almost no compiler
work — just finishing `extern fn`: a call lowers to a direct `@<sym>` call and the
header emits a matching `declare`, so any C/Rust/Zig library (a socket, here the
loopback) binds through the same seam windowing uses. `find_extern` (emit_core),
the extern branch in emit_expr's call path, `emit_extern_decls` (emit_head).
([`examples/networking/net_echo.ludic`](examples/networking/net_echo.ludic),
[`tests/net_c/loopback.c`](tests/net_c/loopback.c) → `4 10 20 30 42`.)
- **N1 — snapshot-to-buffer. ✅ SHIPPED.** Generalized `save()`/`load()` to a memory
buffer: `world_size()` (exact snapshot bytes), `world_save(buf) -> int`,
`world_load(buf, len)`. The same fixed block list (entity count, freelist, alive,
kind, vars, per-component `@S_`/`@H_`) now feeds a file (fwrite/fread) *or* a buffer
(memcpy over a threaded i64 offset), chosen by `g_snap_mode` in emit_save.ludic;
no rt_ hook (the ECS world only). The rollback/replication substrate.
([`examples/networking/net_snapshot.ludic`](examples/networking/net_snapshot.ludic),
[`tests/net_c/snapshot_mod.c`](tests/net_c/snapshot_mod.c) → `50 7 50`.)
- **N2 — `@Sync` codegen. ✅ SHIPPED.** The three-level annotations → generated
per-model `serialize_<M>`/`apply_<M>` + by-kind dispatchers (`ludic_serialize`/
`apply`/`sync_size`, and the `serialize`/`apply`/`sync_size` builtins); the
POD-scalar compile error and the empty-participation warning. The declarative
core. ([`examples/networking/net_sync.ludic`](examples/networking/net_sync.ludic) → `12 3 4 50 999`,
emit in [`selfhost/emit_net.ludic`](selfhost/emit_net.ludic).)
- **N3 — ownership. ✅ SHIPPED.** `@Owned` + the `@L_owner_arr` array +
`owner()`/`set_owner()`/`is_owner()`; owners are part of the world snapshot.
([`examples/networking/net_owner.ludic`](examples/networking/net_owner.ludic) → `-1 7 0 1`.)
- **N4 — remote events (RPCs). ✅ SHIPPED.** `@ToServer`/`@ToClients` on `event`s →
payload serialize (`[event id][fields]`) + directional `net_send` + `net_pump()`
far-side re-`emit`. ([`examples/networking/net_rpc.ludic`](examples/networking/net_rpc.ludic) → `0 8`.)
- **N5 — roles + drivable sim. ✅ SHIPPED.** `@Server`/`@Predicted` role-guarded
dispatch driven by the `@L_role` register (`set_role`/`is_server`/`local_id`);
the opt-in `entry`-owns-the-loop with `tick_fixed()`/`tick_render()`. Together
these let prediction/rollback be written in developer/library code.
([`examples/networking/net_roles.ludic`](examples/networking/net_roles.ludic) → `1 102`.)
- **N6 — a blessed reference netcode runtime. ✅ SHIPPED.** A Ludic library
([`examples/networking/net_rt.ludic`](examples/networking/net_rt.ludic)) — server-authoritative state
replication over the primitives — plus a full end-to-end demo, proving the seams
the way the C mod proved the event ABI, but in pure Ludic over the built-in
transport. Library policy, swappable for lockstep+rollback.
([`examples/networking/net_demo.ludic`](examples/networking/net_demo.ludic) → `5 999 5`.) A built-in
loopback transport (N0) means all of this needs **no foreign code at all**.
N0–N2 deliver "state can be declared, serialized, and moved." N3–N4 add ownership
and RPCs. N5 unlocks prediction. N6 is a batteries-included default that others can
replace. The **determinism contract (§11)** is cross-cutting — documented from N0,
enforced incrementally.
---
## 14. Open decisions
1. **Field direction vocabulary.** `@Sync(to: owner)` / `@Sync(to: all)` confirmed
in spirit; is `to:` the right key, and do we also want `to: server` (a field only
the authority reads)? How does per-field direction interact with `@Predicted`?
2. **Blessed runtime, or seams only?** Events chose "seams + reference mod, bless
nothing." Networking's DX may justify shipping one reference runtime (N6). One,
or none?
3. **Authority default.** Server-authoritative with `@Predicted` opt-in is the safe,
Unity-ish default. Confirm, or keep the language authority-neutral and leave even
that to the runtime?
4. **Drivable loop shape.** Whole-frame `tick()` vs the `tick_fixed()`/`tick_render()`
split; how a developer-owned `entry` loop coexists with scenes, the `rt_*` hooks,
and the auto-loop (opt-in via presence of an `entry` block?).
5. **Snapshot granularity.** Full `world_save` vs per-`@Sync` serialize vs a
generated delta between two snapshots — which does the language provide, and which
is library work?
6. **Float determinism enforcement.** A documented rule only, or a real lint that
flags `f32` reachable from `@Server`/`@Predicted`/`@Sync` code paths?
7. **Ownership at component granularity.** Unity's DOTS allows per-component owner
send-rules. Is `@Owned` per-*entity* enough, or do we need per-component owners
(a real complexity jump)?
8. **Networking substrate for the remote half of EVENTS EV7.** This doc's directional
remote events (N4) *are* the local/remote split EVENTS-DESIGN EV7 deferred for
"no networking substrate." N4 is that substrate — the two docs meet here.
---
*Companion to [EVENTS-DESIGN.md](EVENTS-DESIGN.md) (remote events are directional
events; serializers reuse the EV2 world-table schema; EV7's deferred local/remote
split lands here as N4), [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md) (`@OnChange`/LC2
is the dirty-tracking primitive for delta replication), and
[SCENES-DESIGN.md](SCENES-DESIGN.md). Supersedes nothing until the compiler work in
§13 lands.*

286
README.md
View file

@ -1,200 +1,170 @@
# Ludic # Ludic
A compiled language for 2D games. The entity-component system is part of the Ludic is an **ahead-of-time compiled** language for 2D games with an
syntax, the runtime is deterministic fixed-point, and `ludicc` lowers Ludic entity-component core, a deterministic fixed-point runtime, and its graphics
straight to LLVM IR — **no C is generated, compiled or linked in a build.** stack built into the language. `ludicc` lowers Ludic straight to LLVM IR and
emits a native binary — and **`ludicc` is itself written in Ludic**, compiles
its own source to a byte-exact fixpoint, and rebuilds from a checked-in IR seed
with **no C compiler in the loop**.
The compiler is written in Ludic. It compiles its own source to a byte-exact ```
fixpoint and rebuilds from a checked-in IR seed with clang alone; CI asserts .ludic ──► ludicc ──► LLVM IR ──► object ──► native binary
that on every push. (in Ludic)
- **Documentation:** <https://workshopsoft.pages.workshopsoft.io/ludic/>
- **API reference:** <https://workshopsoft.pages.workshopsoft.io/ludic/api.html>
- **Issues:** <https://git.workshopsoft.io/workshopsoft/ludic/issues>
```ludic
program Hello {
property Position { column: int = 0, row: int = 0 }
property Velocity { delta_x: int = 0, delta_y: int = 0 }
handler SpawnEnemies phase Start {
spawn Enemy { Position { column: 3, row: 4 }, Velocity { delta_x: 1, delta_y: 0 } }
spawn Enemy { Position { column: 10, row: 2 }, Velocity { delta_x: 0, delta_y: 1 } }
}
# a handler declares the entities it touches; the body runs
# once per match, with each property bound by name.
@Queries(these: [Position, Velocity])
handler AdvancePositions phase FixedUpdate {
Position.column += Velocity.delta_x
Position.row += Velocity.delta_y
}
}
``` ```
## Getting started **No C is generated, compiled or linked in a build.** No interpreter, no
transpiler, no C runtime: the framebuffer, sprites, PNG/DEFLATE decoding,
TrueType text, the retained UI, the registers and the RNG are all written in
Ludic (`runtime/native/*.ludic`); only the window seam — five `win_*` functions
— is hand-written LLVM IR against the platform ABI (`runtime/native/cocoa.ll`),
the same floor Rust and Swift stand on.
Install the toolchain — the compiler, the `ludic` CLI, the engine runtime, the ## Backends
formatter and the language server — with one command:
| Backend | Status |
|---|---|
| **Native 2D** (macOS/Cocoa window; headless render for CI) | **Shipping** — the default `bin/x app` target. |
| **Web / wasm32** | **In progress.** The browser platform layer is in-tree and documented — `runtime/web/` (the `<canvas>` window `platform.js`, the libc-free `wasm.ll` floor) and a Node harness that diffs native vs. wasm frame-for-frame (`tools/ludic-web/run.mjs`). Emitting wasm was a capability of the retired C compiler and is **not yet re-wired on the self-hosted toolchain**; see [COMPILING.md](COMPILING.md). |
The same is true of `--target` cross-compilation and `--shared` libraries: both
are designed and documented, both lived in the old C compiler, and both are
pending re-implementation on the self-hosted native toolchain.
## Quick start
`bin/x` is the project's task runner — one native binary, written in Ludic and
compiled by Ludic, that replaces every build/test/bootstrap shell script.
Bootstrap it once from a clean checkout (the only step Ludic can't do for
itself, since compiling Ludic needs a compiler) with clang alone:
```bash ```bash
curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x
``` ```
It installs into `~/.ludic` and puts `~/.ludic/bin` on your `PATH` in every Then build the whole toolchain and run a game:
shell — the PATH line lives in `~/.ludic/env`, sourced from `~/.profile`,
`~/.zshenv` and your bash or fish config. Nothing else on the machine is touched;
uninstalling is `rm -rf ~/.ludic` and deleting those two-line blocks. Where a
prebuilt toolchain exists for your platform it is downloaded and verified against
a published checksum; where it does not, the installer bootstraps from the
compiler's own IR seed with clang. Either way you need clang (or Xcode's Command
Line Tools) to link, since Ludic emits LLVM IR and links it natively.
Then make a game:
```bash ```bash
ludic new mygame bin/x build # -> bin/{ludicc,ludic,x,ludic-fmt,ludic-lsp}
cd mygame bin/x app examples/games/snake.ludic # compile + open a native window
ludic run # compiles src/main.ludic and opens a native window ./build/snake
``` ```
`ludic new` writes a manifest, a program that already moves something on screen, Render a frame headlessly (what CI checks) — output lands in `build/`, never the
and a test. `ludic build` stops at the binary; `ludic bundle` goes on to the repo root:
thing you can actually give someone. Rendering is deterministic, so a frame can
be produced without a window, which is what CI diffs:
```bash ```bash
ludic test bin/x app examples/games/chronorift.ludic --headless
ludic build --headless mkdir -p build && printf 'ddddwww' | ./build/chronorift_headless # writes build/out.ppm
printf 'ddddwww' | ./build/mygame_headless # writes build/out.ppm sips -s format png build/out.ppm --out frame.png
``` ```
`ludic help` lists every command, and `ludic doctor` checks the install. Run the suites:
[`examples/`](examples/README.md) is a tour grouped by intent: games, rendering,
ECS, events, networking, language features and the standard library — compile any
of them with `ludic build examples/games/snake.ludic`.
### Building from a checkout
Contributors also get `ludic-dev`, a second binary carrying the toolchain's own
tasks — building the compiler, the suites, the docs site, releases. It is built
from a checkout and is not part of an install, so nothing a user runs is mixed
up with it. Bootstrapping is the only step Ludic cannot do for itself, since
compiling Ludic needs a compiler — clang assembles the checked-in IR seed, and
that compiler builds the rest:
```bash ```bash
mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc bin/x test # full regression: compiler builds from seed, every example, golden renders
bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev bin/x selfhost-test # correctness + the self-hosting / C-free bootstrap fixpoints
bin/ludic-dev build # -> bin/{ludicc,ludic,ludic-dev,ludic-fmt,ludic-lsp} bin/x help # every command
bin/ludic-dev test # the regression suite
``` ```
## The language ## Layout
- **ECS in the syntax.** `property`, `model` and `handler` are keywords. Query | Path | What it is |
with `for (a, b) in query [A, B, {Tag}] where <expr> { … }`; `spawn` and |------|-----------|
`despawn` recycle entity slots; `@`-annotations drive lifecycle hooks. | [`selfhost/*.ludic`](selfhost/) | **the compiler, written in Ludic** — lexer, parser, and the LLVM-IR backend (ECS storage, queries, spawn, `match`/`machine`, UI, scenes, save/load, fixed-point). Built from `selfhost/ludicc.seed.ll` with clang alone. |
- **Deterministic by construction.** Q16.16 `fixed` arithmetic and a seeded RNG | [`selfhost/golden/renders.sha256`](selfhost/golden/renders.sha256) | text baseline of render-output hashes (replaces binary `.ppm` fixtures); regenerate with `bin/x golden`. |
give the same frame byte-for-byte on every run — the basis for replays, | [`tools/x/*.ludic`](tools/x/) | **the task runner, written in Ludic** — one binary (`bin/x`) that builds, tests, bootstraps and reseeds the project, replacing every shell script. |
lockstep netcode and golden-image tests. | [`runtime/native/`](runtime/native/) | the runtime **in Ludic** for the native path: `core` (framebuffer, input, RNG), `image`/`inflate` (PNG + DEFLATE, no zlib), `truetype` (glyph rasterizer), `ui` (retained widget tree); plus `cocoa.ll`, the macOS window seam in LLVM IR. |
- **Scenes and state machines.** `scene` / `layer` / `become` model | [`runtime/web/`](runtime/web/) | the browser platform layer: `platform.js` (the `<canvas>` window), `wasm.ll` (the libc-free floor), `index.html`. |
mutually-exclusive game states with enter and exit hooks; `match` / `machine` | [`examples/`](examples/README.md) | the example tour, grouped by intent — `games/`, `rendering/`, `ecs/`, `events/`, `networking/`, `lang/`, `library/`. See [examples/README.md](examples/README.md). |
/ `state` handle dispatch and per-entity FSMs. | [`tools/ludic-tools/`](tools/ludic-tools/) | the editor toolchain **in Ludic**: `ludic-fmt` (formatter) and `ludic-lsp` (language server) — one lexer, one vocabulary shared by both. |
- **Events and networking.** A cancellable event bus (`event` / `emit` / `@On`) | [`tools/editors/`](tools/editors/README.md) | plugins for VS Code and JetBrains, plus config for Neovim, Helix, Emacs, Sublime and Zed. |
and networking primitives (`@Sync`, ownership, RPCs) over a built-in transport. | [`docs/`](docs/) | the per-symbol API reference, regenerated into the docs site. |
- **Batteries in the language.** Framebuffer primitives, PNG sprites, TrueType | [`COMPILING.md`](COMPILING.md) | the native pipeline: `ludicc → LLVM IR → exe`, the `rt_*` runtime protocol, and the (pending) wasm/cross-compile/shared-library paths. |
text and a retained `ui` widget tree declared as data, plus a namespaced
standard library (`Math`, `Text`, `List`, `Random`, `Crypto`, `Tiled`, …).
- **Whole-world snapshots.** `save()` and `load()` serialize every entity,
property and program `var` in one call.
[LANGUAGE.md](LANGUAGE.md) is the full reference; the Design and roadmap documents — `LANGUAGE.md`, `EVENTS-DESIGN.md`,
[API reference](https://workshopsoft.pages.workshopsoft.io/ludic/api.html) `NETWORKING-DESIGN.md`, `SCENES-DESIGN.md`, `LIFECYCLE-DESIGN.md`,
documents every symbol on its own page. `SYNTAX-REDESIGN.md`, `MOBILE-DESIGN.md`, `LUANTI-ROADMAP.md`, `BOOTSTRAP.md` —
live at the repository root today and are being migrated to the wiki.
## Shipping ## Language at a glance
A built binary is a program, not an application: it opens its assets by a path - `program` / `property` (typed fields + defaults) / `model` (named entity kinds)
relative to the working directory, so it runs from the project root and nowhere / `system` (`phase`, `@annotations`, `reads`/`writes`).
else, and it wears the generic executable icon. - ECS queries `for (a, b) in query [A, B, {Tag}] where <expr> { … }`,
`spawn`/`despawn` with slot reuse, `@`-driven lifecycle hooks.
- An **event bus** (`event` / `emit` / `@On`, cancellable, `@Public` promotion)
and **networking** primitives (`@Sync`, ownership, RPCs) over a built-in
loopback transport — all deterministic, all pure Ludic.
- `scene` / `layer` / `become`, `match` / `machine` + `state`.
- Types `int`, `fixed` (Q16.16), `bool`, `entity`, `str`, `byte`, typed buffers;
a growing namespaced **standard library** (`Math`, `Vector`, `Time`/`Date`/
`Duration`/`Clock`, `Random`, `Hash`, `Crypto`, sorting, …).
- Deterministic seeded RNG and `save()`/`load()` snapshot of the whole World.
- Built-in 2D: framebuffer primitives, PNG sprites, TrueType text, 9-slice, and
a retained `ui` widget tree declared as data.
```bash See [LANGUAGE.md](LANGUAGE.md) for the full reference, and
ludic pack # every asset the game opens, into one .lpak [examples/README.md](examples/README.md) for runnable demos of each feature.
ludic bundle # ...and that, the binary, an icon and the metadata, as a .app
```
Nothing about how the game is written changes. `gltf_load("assets/kit/hiker",
…)` reads a file during development and a run of bytes inside the bundle once
shipped, and cannot tell which — the pack is spliced in at `file_open`, the one
place every asset in a Ludic program comes through. A bundled game also gets a
boot splash it controls (`App.splash_hide()`) and a writable home under
Application Support, because Finder starts a `.app` at `/` where no save could
be written.
Without a pack beside it — which is every `ludic run` — nothing mounts and every
open goes to the filesystem exactly as before. See [docs/SHIPPING.md](docs/SHIPPING.md).
## Packages
Dependencies are identified by URL, resolved with minimal version selection, and
cached in a content-addressed store:
```bash
ludic add git.workshopsoft.io/user/pkg # resolve, fetch, link into ludic_modules/
ludic get # install from package.ludic, write the lock
ludic remove git.workshopsoft.io/user/pkg # the inverse of add
ludic verify # check locked packages against the store
```
The `ludic.*` packages — canonical ECS components, the gameplay, platformer,
RPG, shooter and NPC-AI modules — ship with the toolchain, so importing one needs
no fetch step at all.
See [`docs/PACKAGES.md`](docs/PACKAGES.md) for the manifest and lockfile model.
## Editor support ## Editor support
Editors spawn `ludic lsp`; the server ships with the toolchain, so there is ```bash
nothing extra to install. It speaks LSP 3.17 over stdio, so one binary serves bin/x tools # -> bin/ludic-fmt, bin/ludic-lsp
every editor: completion, diagnostics from the compiler itself, go-to-definition ```
and rename across imports, and comment-preserving formatting. `ludic fmt` runs
the same formatter as a CLI, for pre-commit hooks. Both understand
```` ```ludic ```` fences in Markdown. Plugins and drop-in config for VS Code, JetBrains, Neovim,
Helix, Emacs, Sublime and Zed are in [`tools/editors/`](tools/editors/README.md).
## Status `ludic-lsp` speaks LSP 3.17 over stdio, so one binary serves every editor:
context-aware completion, diagnostics from the compiler itself,
go-to-definition and rename across `import`ed files, and comment-preserving
formatting. `ludic-fmt` is the same formatter as a CLI, for pre-commit hooks and
CI. Both also understand ```` ```ludic ```` fences in Markdown. Plugins and
drop-in config are in [`tools/editors/`](tools/editors/README.md).
The native 2D backend ships: a Cocoa window on macOS, a headless renderer for ## Chrono Rift — the flagship game
CI, and the whole runtime — framebuffer, PNG/DEFLATE decoding, TrueType
rasterizer, retained UI, RNG — written in Ludic under
[`runtime/native/`](runtime/native/). Only the window seam (`win_*`: window,
keys, mouse, cursor, gamepad, touch) is hand-written LLVM IR against the
platform ABI, the same floor Rust and Swift stand on.
The **web/wasm32 backend is not currently available.** The browser platform [`examples/games/chronorift.ludic`](examples/games/chronorift.ludic) is a
layer is in-tree under [`runtime/web/`](runtime/web/), but emitting wasm was a playable co-op JRPG — overworld, dungeon, random encounters, a turn-based co-op
capability of the retired C compiler and has not been re-wired on the battle, a boss, an item shop and snapshot save/load — split across modules under
self-hosted toolchain. `--target` cross-compilation and `--shared` libraries are [`games/chronorift/`](examples/games/chronorift/). Its art is CC0
in the same position. See [COMPILING.md](COMPILING.md). [Kenney](https://kenney.nl) sprites, decoded from PNG at runtime by the
Ludic-written PNG/DEFLATE decoder — no zlib, no external dependency.
Releases follow SemVer and are cut from changesets by `ludic-dev release`, then built - **Overworld:** `WASD` move, `K` save, `L` load.
and published by CI from the tag; see [CHANGELOG.md](CHANGELOG.md). - **Battle (local co-op):** P1/Knight `W`/`S` select, `Space` confirm;
P2/Mage `I`/`K` select, `J` confirm.
## Status & roadmap
The compiler self-hosts to a byte-exact fixpoint and rebuilds from its IR seed
with no C compiler; the ECS runtime, windowed + headless 2D rendering, the event
bus, the deterministic networking stack, scenes, and save/load are all in place
and covered by `bin/x test`. CI gates every push and PR on the build, the test
suites, and that C-free fixpoint. The toolchain is versioned with SemVer
(`ludicc --version`); releases and the `CHANGELOG.md` are cut from changesets by
`x release`.
Active work and proposals — the standard library, a fuller type system,
rendering/animation/lighting extras, input, filesystem/IO, testing, and
re-wiring the web/wasm and cross-compile backends — are tracked as issues, not
inlined here:
- **Issues & proposals:** <https://git.workshopsoft.io/workshopsoft/ludic/issues>
- **Docs site (API reference):** <https://workshopsoft.pages.workshopsoft.io/ludic/>
- **Wiki (design & roadmap):** <https://git.workshopsoft.io/workshopsoft/ludic/wiki>
## Contributing ## Contributing
[CONTRIBUTING.md](CONTRIBUTING.md) covers the development loop, the commit and See [CONTRIBUTING.md](CONTRIBUTING.md) for the development loop
code conventions, how the bootstrap fixpoint works, and what a self-hosted CI (`bin/x reseed` → `bin/x bootstrap-cfree` → `bin/x test`), the code and commit
runner needs. Issue and pull-request templates are under conventions, and how the bootstrap fixpoint works. Issue and pull-request
[`.forgejo/`](.forgejo/). templates live under [`.forgejo/`](.forgejo/).
## License ## License
The compiler and runtime are licensed under the The Ludic compiler and runtime source are licensed under the
[Apache License 2.0](LICENSE) (`SPDX-License-Identifier: Apache-2.0`). [Apache License 2.0](LICENSE) (`SPDX-License-Identifier: Apache-2.0`) — a
permissive license with an explicit patent grant.
The bundled [Kenney](https://kenney.nl) art under `assets/kenney/` is The bundled [Kenney](https://kenney.nl) art under `assets/kenney/` is
third-party and released under **CC0 1.0**; each pack keeps its own third-party and released under **CC0 1.0** (public domain); each pack keeps its
`License.txt`. Code and assets are licensed separately — Apache-2.0 covers the own `License.txt`. Code and assets are licensed separately: Apache-2.0 covers
source, not the art. the source, not the art.

354
SCENES-DESIGN.md Normal file
View file

@ -0,0 +1,354 @@
# Scenes, expanded — a design doc
> **Status: S0 shipped; S1–S6 are design.** The base construct — `scene` /
> `layer` / `on enter` / `on exit` / `become`, lowered to the implicit machine of
> §3 and §9 — is implemented and tested ([`examples/lang/scenes.ludic`](examples/lang/scenes.ludic),
> a `bin/x test` check). The extensions in §4–§8 (scene-owned entities, richer
> layers, the overlay stack, scene-local state, transition parameters) are still
> design targets. This document reaches deliberately past the thin sketch so we
> can decide the shape before building each one. §11 lists the open decisions.
---
## 1. Where we are
A Ludic program is almost always several mutually-exclusive states — a title
screen, the overworld, a battle, a pause menu. Two ways to write that exist in
the language today, and a third is sketched:
| Approach | Status | Cost |
|---|---|---|
| Mode register consulted at the top of every handler (`if reg(R_MODE) == …`) | works | a guard re-read per handler per frame; state is a magic number; nothing scopes to it |
| `machine`/`state`/`become` over a register | works | dispatch on the register each frame; still one flat register, no per-state handlers or lifecycle |
| `scene`/`layer`/`on enter`/`on exit` | **sketch only** | — |
The sketch ([`examples/lang/scenes.ludic`](examples/lang/scenes.ludic)) specs:
- Exactly **one scene active**; the `start` scene runs first.
- A scene's handlers run only while it is active; handlers outside any scene are
global.
- **Layers group handlers; declaration order is draw order** — within a phase,
globals first, then the active scene's layers in written order.
- `on enter` / `on exit` are lifecycle hooks (not phases).
- `become Name` runs the old scene's `on exit`, switches, runs the new `on enter`
— two direct calls and a store, no dispatch table.
That's a good spine. The problem is it's specced as **sugar over a mode
register**: it tidies the syntax but adds little the register didn't already
have. The compiler knows *much* more at a scene boundary than a register does,
and this doc is about spending that knowledge.
---
## 2. Design principles
1. **The scene boundary is a compile-time fact — use it.** The set of handlers,
layers, and owned state for each scene is known statically. Transitions should
be direct calls and a single store, never a table walk. (The sketch already
promises this; the extensions must preserve it.)
2. **Structure, not registers.** Anything you'd track with a hand-managed
register alongside the mode — which entities belong to this state, which layers
are drawn, what's paused — should be expressible *as* scene structure and
enforced by the compiler.
3. **Reuse the machinery we already have.** Layers pausing, scenes tearing down
their entities, and hooks firing are all expressible in terms of
`enable`/`disable` (cheap flag flips), `despawn`, and the lifecycle-hook
lowering. Scenes should *compose* those, not introduce a parallel runtime.
4. **One active-scene path stays hot; overlays are the exception, not the rule.**
The common case (one full-screen scene at a time) must lower to the cheapest
possible dispatch. Richer shapes (a pause menu over a frozen world) are opt-in
and pay only for what they use.
---
## 3. Core model (firmed up from the sketch)
```ludic
# doc-check: skip — illustrative
scene Title start {
on enter { ui_open(UI_Menu) }
on exit { ui_visible(UI_Menu, 0) }
layer Main {
handler Choose phase Update {
if ui_clicked(UI_NewGame) { become Overworld }
}
}
}
scene Overworld {
on enter { spawn_party() }
layer World { handler Move phase Update { … } }
layer Hud { handler Draw phase Render { … } }
}
```
Unchanged from the sketch, made precise:
- **Scenes number themselves by declaration order**, exactly like `machine`
states — `Title` is `0`, `Overworld` is `1`. The active scene lives in one
implicit register (`__scene`). This makes `scene` a `machine` the compiler
writes for you, which is the right mental model and the right lowering.
- **A layer handler may not use phase `Start`.** `Start` runs once at boot,
before any scene is entered; scene setup goes in `on enter`.
- **Global handlers still run every frame**, before any scene's layers, in every
phase. A scene's layers run only while it is active.
Everything below is new.
---
## 4. Extension E1 — scene-owned entities (scoped lifetime)
The single biggest thing a mode register cannot do: **own the entities that only
make sense in this state, and tear them down automatically on exit.** Today a
battle scene spawns combatants in `on enter` and must remember to despawn every
one in `on exit` — miss one and it leaks into the overworld.
Proposal: entities spawned *by a scene's handlers or `on enter`* are tagged with
that scene, and `on exit` despawns them by default.
```ludic
# doc-check: skip
scene Battle {
on enter { spawn Foe; spawn Foe; spawn Foe } # tagged @Battle
# on exit: implicit `despawn all @Battle` — no manual cleanup
layer World { handler Fight phase Update { … } }
}
```
- Implemented as an implicit **scene tag** (a `{Battle}`-style kind bit) added at
`spawn` time while a scene is active, plus a generated `despawn`-by-tag in the
synthesized `on exit`. Reuses the existing tag-filter and despawn-hook
machinery — no new runtime.
- **Opt out** for entities that should outlive the scene: `spawn Foe persist` (or
spawn it from a global handler). Persisted entities keep their data across the
transition, matching how `disable` keeps field data.
- Composes with `@OnDespawn(Model)`: the destructor hook fires for each
scene-owned entity as it's torn down, so `drop_loot`-style cleanup still runs.
**Open:** does a re-`become Battle` get fresh entities (fresh tag generation) or
resume the old ones? Default: fresh. See §9.
---
## 5. Extension E2 — layers are more than draw order
The sketch uses layers only to order `Render`. Layers are the natural unit for
three more things, all built on the existing `enable`/`disable` flag flips:
1. **Per-layer toggle.** `disable Hud` / `enable Hud` flips one flag; the layer's
handlers stop running and drawing. This is `disable Handler` generalized to a
named group — same one-flag-flip cost.
2. **Pause vs. tear-down.** A layer can keep drawing while its *update* handlers
are suspended:
```ludic
# doc-check: skip
scene Overworld {
layer World { handler Move phase Update { … } handler Draw phase Render { … } }
layer Hud { handler DrawHud phase Render { … } }
}
```
When a pause menu opens over the Overworld (see E3), `World`'s `Update`
handlers suspend but its `Render` handler still paints the frozen world behind
the menu. Today that requires a `if !paused` guard in every update handler;
with layers it's structural.
3. **Layer lifecycle hooks.** `on show` / `on hide` per layer, mirroring scene
`on enter`/`on exit`, for the toggle points. (Naming TBD — could fold into the
`@OnEnable`/`@OnDisable` annotations, which already exist for properties.)
---
## 6. Extension E3 — the scene *stack* (the headline)
The sketch says "exactly one scene is active." That's the right default and the
wrong constraint. The states a mode register handles *worst* are the ones that
**overlay without replacing**: a pause menu over live gameplay, a dialog box, an
inventory screen, a confirmation prompt. With one register you either lose the
underlying state or hand-roll a "previous mode" variable and restore it.
Proposal: keep "one *base* scene," but allow scenes to be **pushed as overlays**.
```ludic
# doc-check: skip
scene Overworld {
layer World { handler Move phase Update { … } handler Draw phase Render { … } }
layer Hud { handler DrawHud phase Render { … } }
on enter { … }
handler PauseKey phase Input { if pressed(KEY_ESC) { push Pause } }
}
scene Pause overlay { # `overlay` = pushed, not swapped
on enter { dim_backdrop() }
layer Menu {
handler Nav phase Update {
if pressed(KEY_ESC) { pop } # back to Overworld, untouched
}
handler Draw phase Render { ui_render() }
}
}
```
- `push Name` runs `Name`'s `on enter` and makes it the top scene **without**
running the base scene's `on exit`. `pop` runs the overlay's `on exit` and
returns to whatever was beneath.
- **Update belongs to the top of the stack; render walks the whole stack bottom
to top.** So `Pause`'s `Menu` layer draws over `Overworld`'s frozen `World` and
`Hud`. This is the default that makes pause menus "just work." An overlay that
should let the layer beneath keep updating opts in with `push Name passthrough`.
- **The stack is a small fixed-capacity array of scene ids** (say 8) in a
compiler-owned buffer — not heap, not a linked structure. `push`/`pop` are an
index bump and an `on enter`/`on exit` call. Depth overflow is a compile-time
or trap decision (§9).
- `become` still exists and still means "swap the base scene" (full `on exit` →
`on enter`, stack cleared). `push`/`pop` are the overlay verbs. Keeping the two
distinct is what lets the common single-scene path stay a single register.
This is the extension that turns `scene` from "nicer mode register" into
something with no clean equivalent in the register world.
---
## 7. Extension E4 — scene-local state
A scene almost always has state that exists only while it's active — a battle's
turn counter, a menu's cursor index. Today that's a global register that other
scenes could stomp. Proposal: **`var` / `const` declared inside a `scene` is
scoped to it**, storage shared across scenes that are never simultaneously active
(the compiler can overlap their storage since only one base scene runs at a
time — an arena-per-scene, or a union).
```ludic
# doc-check: skip
scene Battle {
var turn = 0 # visible only inside Battle; reset by `on enter` if desired
layer World { handler Step phase Update { turn += 1 } }
}
```
- Reads/writes lower to a fixed offset in the scene's state block, no register
indirection.
- Overlay scenes (E3) that *can* be live simultaneously with their base cannot
share storage — the compiler keeps their blocks distinct. Base scenes that
never coexist share.
---
## 8. Extension E5 — parameterized transitions, and the reserved annotations
**Parameters on transitions.** `become`/`push` can carry arguments that the
target's `on enter` binds — so a battle knows which foes, a dialog knows which
line:
```ludic
# doc-check: skip
scene Battle {
on enter (foe_kind: int, count: int) { for i in 0 .. count { spawn_foe(foe_kind) } }
}
# elsewhere:
become Battle(FOE_GOBLIN, 3)
```
Lowers to argument stores into the scene's state block (E4) immediately before
the `on enter` call. No variadic runtime; the arity is checked at compile time.
**The already-reserved annotation form.** [LANGUAGE.md:374](LANGUAGE.md:374)
reserves `@OnEnter` / `@OnExit` as handler annotations "waiting on scene support."
This doc adopts them as the annotation spelling of `on enter` / `on exit`,
mirroring how `@OnStart` is the annotation form of `phase Start`:
```ludic
# doc-check: skip
@OnEnter(Battle) handler Setup { … } # == Battle's `on enter`
@OnExit(Battle) handler Teardown { … }
```
Both spellings desugar to the same synthesized scene-lifecycle function; a scene
may use either, not both, for a given hook.
**`reads`/`writes` + scenes (forward-looking).** The `reads`/`writes` clauses are
parsed but unconsumed ([LANGUAGE.md:717](LANGUAGE.md:717)). Once an analysis pass
exists, a scene's layers declare which state they touch, and the scheduler can run
independent layers of the active scene in parallel within a phase — the scene
boundary gives the pass a natural scope to reason about. Noted as a destination,
not part of the first cut.
---
## 9. Lowering summary
Everything above reduces to existing runtime concepts:
| Construct | Lowers to |
|---|---|
| active base scene | one implicit register `__scene`, states numbered by decl order — literally a compiler-written `machine` |
| `become Name` | `on exit` call · `set __scene` · `on enter` call (two direct calls + store, as the sketch promises) |
| scene layers in a phase | the phase scheduler, after global handlers, dispatches on `__scene` to that scene's layer handlers in declaration order |
| `push`/`pop` (E3) | fixed-capacity scene-id array + index; render walks it, update reads its top |
| scene-owned entities (E1) | implicit kind tag at `spawn`; generated `despawn`-by-tag in synthesized `on exit`; reuses despawn hooks |
| layer toggle / pause (E2) | the same one-flag-flip as `disable Handler`, keyed per layer |
| scene-local `var` (E4) | fixed offsets in a per-scene state block; non-coexisting scenes share storage |
| transition args (E5) | arg stores into the state block before the `on enter` call |
| `@OnEnter`/`@OnExit` (E5) | the same synthesized lifecycle functions as `on enter`/`on exit` |
No heap, no dispatch tables, no new allocator. The active-scene path is a
register read and a static branch; the stack adds a small array only for programs
that push overlays.
---
## 10. Suggested implementation phases
Each is independently shippable and testable, matching how the repo phases work.
- **S0 — parse & lower the sketch.** ✅ **Done.** `scene`/`layer`/`on enter`/`on
exit`/`become` lowered to the implicit `machine`; the active scene is
snapshotted per phase so exactly one scene's layers dispatch in any phase.
[`examples/lang/scenes.ludic`](examples/lang/scenes.ludic) compiles, runs, and is checked
by `bin/x test`. This is the floor everything else builds on.
- **S1 — `@OnEnter`/`@OnExit` annotation form** (E5, cheap once S0 exists).
- **S2 — layer toggle & pause** (E2) on top of the existing `enable`/`disable`.
✅ *Toggle shipped* (via EVENTS-DESIGN EV1 layers): `enable layer L` / `disable
layer L` flips an `@LE_<L>` flag that gates the layer's handlers (emitted only
for toggled layers, so untouched scene programs stay byte-identical), and a
`public` layer fires `layer_<L>_show`/`_hide` — see
[`examples/events/layer_events.ludic`](examples/events/layer_events.ludic). Still open: the
*pause* half (keep drawing while `Update` handlers suspend) and `on show`/`on
hide` blocks.
- **S3 — the scene stack** (E3): `push`/`pop`/`overlay`/`passthrough`. The big one.
- **S4 — scene-owned entities** (E1) and **scene-local state** (E4).
- **S5 — transition parameters** (E5).
- **S6 (later) — `reads`/`writes` scheduling** (E5), gated on the analysis pass.
S0–S1 deliver the sketch as promised; S2–S3 are where the "great potential"
actually lands; S4–S5 are ergonomics; S6 is a performance destination.
---
## 11. Open decisions
1. **Re-entering a scene:** fresh entities/state, or resume? (Default proposed:
`become` = fresh, `push`/`pop` = the pushed scene is fresh each push, the base
underneath is untouched.)
2. **Stack depth:** compile-time cap with an error on overflow, or a runtime trap?
What capacity (8? configurable)?
3. **`passthrough` granularity:** does a passthrough overlay let *all* lower
layers update, or can it name which phases fall through?
4. **Layer hook naming:** `on show`/`on hide`, or reuse `@OnEnable`/`@OnDisable`?
5. **Scene-local storage sharing:** union non-coexisting scenes automatically, or
require an explicit opt-in so the sharing is visible in source?
6. **Global handlers and overlays:** do globals run once per frame regardless of
stack depth (proposed: yes), or per active scene?
7. **`become` from inside an overlay:** does it clear the stack (proposed: yes) or
is it an error while overlays are pushed?
---
*Companion to [LANGUAGE.md §"Scenes & layers"](LANGUAGE.md) and the ordering
sketch in [`examples/lang/scenes.ludic`](examples/lang/scenes.ludic). Supersedes nothing
until the compiler work in §10 lands.*

375
SYNTAX-REDESIGN.md Normal file
View file

@ -0,0 +1,375 @@
# Ludic Syntax Redesign — Cohesion Pass
A plan to make Ludic's syntax internally consistent. It fixes the drift between
the spec and the compiler, then unifies the grammar around two rules. Scope:
**full redesign (Phases 0–5)**. Named-field direction: **colon everywhere**.
> Status: **Phases 1–5 complete.** Every phase kept the compiler self-hosting to
> a fixpoint (`bin/x test` 14/14), and each syntax migration was proven
> behaviour-preserving (the migrated compiler compiles itself to byte-identical
> IR; every golden game renders byte-identically). Landed on branch
> `syntax-redesign-phase2` over a committed baseline on `main`.
>
> Coordinated with the toolchain agent (CLI front-end / `ludicc`+`ludic`
> binaries) via serialized reseeds of `selfhost/ludicc.seed.ll`; Phase 1 rode in
> alongside their `emit_*`/`main.ludic` work, combined suite **14/14 green**.
---
## Why (the findings)
Verified against the self-hosted compiler ([selfhost/parse.ludic](selfhost/parse.ludic),
[selfhost/parse_game.ludic](selfhost/parse_game.ludic), [selfhost/lex.ludic](selfhost/lex.ludic)):
**Structural incoherence**
1. **Five micro-syntaxes for named parts** — `name: type = d` (fields), `name: type`
(params), `Field = { k = v }` (spawn), `[Name, {Tag}]` (query), whitespace
`phase X reads [..]` (system clauses), `key=value` (ui props).
2. **`=` means seven things, `:` means one** — assignment, default, record init,
ui prop, extern symbol, const value, `state X = N` all use `=`.
3. **No statement terminators** — `\n` and `;` both lex to `TK_NL`
([lex.ludic:45,121](selfhost/lex.ludic)) but the parser never requires a
separator, so `t.kind = k t.text = x t.ival = v` (three statements, spaces
only) is idiomatic.
**Broken / dead syntax (compiler-verified)**
4. `edge system` — **hard parse error** (documented at [LANGUAGE.md:188](LANGUAGE.md)). *(✅ fixed in Phase 1)*
5. `pure fn` — parses, `pure` silently discarded ([parse.ludic:272](selfhost/parse.ludic)); undocumented. *(✅ Phase 3d: now `@pure`)*
6. `@anno` + `reads/writes/needs/uses [..]` — parsed then thrown away
([parse_game.ludic:15-31](selfhost/parse_game.ludic)); four synonyms, two undocumented. *(✅ Phase 3c: `needs`/`uses` dropped)*
7. `scene`/`layer`/`on enter` — full LANGUAGE.md section + [examples/lang/scenes.ludic](examples/lang/scenes.ludic),
**does not compile** (`expected declaration`).
8. `query (v) [..]` in a system signature — two LANGUAGE.md sections +
[examples/lang/qdecl.ludic](examples/lang/qdecl.ludic), **does not compile** (`parse error: {`). *(✅ implemented in Phase 1)*
9. `when cond {}` — documented ([LANGUAGE.md:329](LANGUAGE.md)) + in all three editor
highlighters, **never parsed**. *(✅ implemented in Phase 1 as an if-without-else alias)*
10. CLI `--emit-llvm`/`-o`/`--shared`/`--fmt` — documented, but `ludicc` only
accepts `--windowed`/`--headless` ([main.ludic:8-14](selfhost/main.ludic)).
**Philosophical splits**
11. Operators are words (`and`/`or`/`not`), symbols (`==`/`<=`), *and* functions
(`band`/`shl`) at once.
12. Three overlapping control families — `if`/`when`, `match`, `machine`/`become`
— and `enter` reuses `become`'s AST node ([parse.ludic:176-177](selfhost/parse.ludic)). *(Phase 4: `if`/`when` kept by choice; magic-int dispatch resolved)*
13. Typed components/structs exist, but real state lives in 64 untyped int
registers (`reg`/`set_reg`), so `machine`/`match` dispatch on magic numbers. *(✅ Phase 4: auto-numbered states + `enum` name the values)*
---
## The two rules everything converges on
**Rule A — `:` associates, `=` binds.**
- `:` introduces a *named part* and its type or value in a declarative structure:
component/struct fields' types, record initializers, ui props, (future) named
call arguments.
- `=` binds a value to a storage location or a constant: `let`, assignment
(`+=` …), `const` value, a field's **default**, and the extern symbol.
- A field declaration uses both, unambiguously: `x: int = 0` reads "`x` *has type*
`int` (`:`), *defaulting to* `0` (`=`)" — same shape as Rust/TypeScript.
- A record/spawn initializer is declarative, so it uses `:` — `Pos { x: 10 }`.
**Rule B — a statement ends at a newline (or `;` or `}`).**
- Newlines become significant. Two statements on one line require an explicit
`;`. `ludic-fmt` normalizes one statement per line and inserts/removes `;`.
Everything below is these two rules applied construct by construct.
---
## Target grammar (before → after)
### Records / spawn initializers
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before
spawn Hero { Pos = { x = 10, y = 5 } Player = { } }
# after
spawn Hero {
Pos { x: 10, y: 5 }
Player {}
}
```
`Field = { k = v }` → `Field { k: v }`. The component name is followed directly
by a record; fields use `:`. (Record literals elsewhere read the same:
`{ x: 10, y: 5 }`.)
### UI props → named-argument form
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before
panel id=Root w=288 pad=16 gap=6 align=center { label text="HI" size=26 }
# after
panel(id: Root, w: 288, pad: 16, gap: 6, align: center) {
label(text: "HI", size: 26)
}
```
A widget becomes "a constructor with named args, then an optional child block."
This deletes the bespoke `key=value` dialect and reuses `:` + commas. (Lower-churn
alternative if the paren form is disliked: keep whitespace separation but colonize
— `panel id: Root w: 288` — still removes the `=` overload.)
### System clauses & modifiers → one annotation channel
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before
edge handler Move @deterministic reads [Vel] writes [Pos] phase FixedUpdate
query (p, v) [Pos, Vel] where a.x > 0 { … }
# after
@edge @deterministic
handler Move
phase FixedUpdate
reads [Vel] writes [Pos]
query (p, v) [Pos, Vel] where p.x > 0
{ … }
```
- Prefix modifier words (`edge`, `pure`, `export`) are **retired**; all modifiers
become `@annotations`, parsed into a real list on the node (not skipped). This
fixes the `edge system` parse bug (#4) by construction.
- `needs`/`uses` are dropped; `reads`/`writes` stay as the two structural clauses
and are **stored** (even if analysis is future work) rather than discarded.
- The `query (v) [..]` signature clause is **actually implemented** in
`parse_system` (#8), lowering to the same `S_QUERY` node as the inline `for`.
### extern
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
extern function c_hypot(a: fixed, b: fixed) -> fixed = "hypot_fx" # unchanged
```
The `= "symbol"` is a binding under Rule A — it stays.
### Statements
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before (legal today)
t.kind = kind t.text = text t.ival = ival
# after
t.kind = kind
t.text = text
t.ival = ival
# or, explicitly, on one line:
t.kind = kind; t.text = text; t.ival = ival
```
### Control flow (Phase 4)
- **`when` vs `if`** — `when` is now a working `if`-without-else alias (Phase 1).
Phase 4 decides whether to keep both spellings or collapse to one; if collapsed,
remove `when` from docs, the parser, and all editor highlighters together.
- **Typed states replace magic-int machines.** Introduce `enum`, and let
`machine` dispatch on a typed variable instead of a register:
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before # after
const R_PHASE: int = 0 enum Phase { KnightMenu, KnightResolve, MageMenu, EnemyTurn }
machine R_PHASE { var phase: Phase = Phase.KnightMenu
state KnightMenu = 0 { … become … } machine phase {
state KnightResolve = 1 { … } state KnightMenu { … become KnightResolve }
} state KnightResolve { … }
}
```
`state X = N` loses the magic `= N` (ordinal comes from the enum). `become`
and `enter` (scenes) keep one shared lowering but read from a typed slot.
---
## Phase sequence
Each phase is independently shippable and ends green on `bin/x test` +
`bin/x selfhost-test` (fixpoint).
### Phase 0 — Doctrine (done here)
Rules A and B above; colon-everywhere; `@`-annotations as the single modifier
channel; typed enums for state. No code.
### Phase 1 — Truth-in-documentation ✅ DONE
Made spec ⇄ compiler agree **before** any grammar change. What landed:
- ✅ **`edge system` crash fixed** (#4) — `parse_system` now consumes an optional
`edge` marker before `system` ([parse_game.ludic](selfhost/parse_game.ludic)).
(`edge` is a pure marker; the emitter never lowered it differently.)
- ✅ **Signature-`query` implemented** (#8) — `query (vars) [terms] where c` in a
system header desugars to the same `S_QUERY` node the inline `for` builds, so
`examples/lang/qdecl.ludic` compiles and runs. Also fixed multi-line clause parsing
(clauses may now span lines).
- ✅ **`when c { }` implemented** (#9) — as an `if`-without-else alias in
[parse.ludic](selfhost/parse.ludic). Docs + editors already listed it; now the
compiler agrees, so no editor-vocab churn was needed.
- ✅ **`scene`/`layer` marked not-yet-implemented** (#7) — prominent note in
LANGUAGE.md §"Scenes & layers" + a header on [examples/lang/scenes.ludic](examples/lang/scenes.ludic).
Full scene front-end + emission deferred (real work, out of Phase 1 scope).
- ✅ **`reads`/`writes` honesty** (#6) + the stale "Not yet implemented" section
updated in [LANGUAGE.md](LANGUAGE.md); scenes/reads-writes/dropped-CLI-flags now
listed there.
- ✅ **`bin/x test` guards drift** — added a `qsmoke qdecl` compile check. Suite
green (14/14 incl. the toolchain agent's CLI smoke tests).
- ✅ **CLI flags** (#10) — `--shared`/`--fmt`/wasm noted as dropped-with-the-C-driver
in LANGUAGE.md; `-o`/`--emit-llvm` were being re-added by the toolchain agent
(now real, verified in `bin/x test`); COMPILING.md updated by that agent.
- Deferred (intentionally): `pure`-is-ignored (#5) is undocumented and harmless;
it will be folded into `@pure` in Phase 3 rather than churned now.
- **Not done / by design:** `scenes.ludic` is *not* added to `bin/x test` (it can't
compile yet — a positive test would fail; the header note + LANGUAGE.md warning
cover the drift instead).
### Phase 2 — Statement separation (Rule B) ✅ DONE
Landed on branch `syntax-redesign-phase2` (baseline committed on `main` first).
- ✅ **Parser enforces a separator** — `block()` requires a newline or `;` after
each statement, else `expected newline or ';' between statements`
([parse.ludic](selfhost/parse.ludic)). Also fixed `if`-without-`else` swallowing
its trailing separator (it now peeks for `else` and restores if absent).
- ✅ **Interpretation chosen:** *require a separator*, not *reflow to one-per-line*.
The migration **inserts `;` at statement boundaries** and leaves lines intact —
comment-safe, minimal-diff, and it makes boundaries visible without an
opinionated reflow. One-per-line stays the recommended hand-written form.
- ✅ **Migration tool** ([tools/ludic-tools/migrate_separators.c](tools/ludic-tools/migrate_separators.c),
reuses the toolchain lexer) with a
**verification oracle**: a `;` inserted at a real boundary is a semantic no-op,
proven by the migrated compiler compiling itself to **IR byte-identical to the
seed** and every golden game rendering identically. ~1100 boundaries across the
corpus (examples, runtime, and the 25 self-host fragments).
- ✅ **Reseeded** to the strict compiler (19557 lines); C-free bootstrap fixpoint
holds; `bin/x test` 14/14; all goldens byte-identical; qdecl runs correctly.
- ✅ **Docs updated** — Rule B documented in LANGUAGE.md §Statements; BOOTSTRAP.md
R1 (which advertised no-separator juxtaposition as legal) and its stale code
fences updated; `check-docs` (now a live strict parse gate) green across all docs.
**Bug found & fixed en route:** a multi-line string literal in
[emit_expr.ludic](selfhost/emit_expr.ludic) (`emit(")<newline>")`) lexed fine in
the self-host lexer but the **C toolchain lexer** (`ludic_syntax.h`, shared by
sepfix, `ludic-fmt`, and the LSP) stops strings at newline — so it mis-lexed and
`ludic-fmt` would corrupt such a file. Converted it to the byte-identical `\n`
escape. **Open follow-up:** align the C lexer to allow newlines in strings, or
forbid literal newlines in string literals language-wide (the two lexers should
agree). Flagged to the toolchain owners.
### Phase 3 — Named-field unification (Rule A)
**3a — spawn/record initializers ✅ DONE.** `Comp = { f = v }` → `Comp { f: v }`.
`record()` requires `:` and `parse_spawn()` drops the `=` before the record
([parse.ludic](selfhost/parse.ludic), [parse_game.ludic](selfhost/parse_game.ludic)).
The `=` is now assignment/const/default/extern-binding only. Migration tool:
[migrate_records.c](tools/ludic-tools/migrate_records.c) (spawn-context aware).
Records live only in games, so the seed was unaffected; verified every golden
byte-identical, old `=` form now rejected, reseeded, `bin/x test` 14/14. Doc examples
updated (LANGUAGE.md, BOOTSTRAP.md R2).
**3b — ui props → `key: value` ✅ DONE.** `panel id=Root w=288` → `panel id: Root
w: 288`. `parse_widget` now reads props with `:` ([parse_game.ludic](selfhost/parse_game.ludic)).
Chose the **colonized** form over parenthesized named-args: it satisfies Rule A
(the `=` overload is gone) with minimal churn, needs no new grammar, and `emit_ui`
(which reads the AST) and `ludic-fmt` (which formats `:` correctly by default)
were both untouched. Migration: [migrate_ui.c](tools/ludic-tools/migrate_ui.c).
menu golden byte-identical, old `=` form rejected, reseeded, `bin/x test` 14/14.
(The parenthesized form `panel(id: Root, w: 288)` remains a possible future
refinement if the language ever gains named call arguments.)
**3c — dropped the dead `needs`/`uses` clause synonyms ✅ DONE.** `reads`/`writes`
stay (documented; still parsed-and-reserved). `needs`/`uses` were undocumented and
unused anywhere in the corpus — removed from `parse_system`. *Not done:* actually
*storing* reads/writes on the node for an analysis pass — that's analysis
infrastructure, out of scope for a syntax pass.
**3d — modifiers → `@`-annotations ✅ DONE.** `edge`/`pure`/`export` prefix keywords
are retired; declaration modifiers are now leading `@annotations`: `@export fn`,
`@edge system`, `@pure`, `@deterministic`. `parse_one_decl` collects a leading
`@anno` run and `@export` sets the fn export flag ([parse.ludic](selfhost/parse.ludic));
the dead `edge`-dispatch was removed from `parse_system`. Migrated the one
`@export` user ([examples/library/combat.ludic](examples/library/combat.ludic)); old
prefix forms now rejected. Behavior-identical: the export flag is parse-only in
the self-hosted emitter (it emits `@fn_<name>` for every function and never reads
the flag — the C-ABI-export capability is vestigial, a pre-existing gap), so
`@export` and the old `export` produce byte-identical IR. Reseeded, fixpoint
holds, `bin/x test` 14/14, goldens byte-identical.
**Phase 3 is complete.** The `=`/`:` overload (finding #2) and the modifier-zoo
(findings #5, #6) are resolved; `:` associates and `=` binds throughout.
Each sub-phase follows the proven pattern: parser change → verification-gated
migration (IR byte-identical / goldens identical) → reseed → docs. The migration
tools ([migrate_separators.c](tools/ludic-tools/migrate_separators.c),
[migrate_records.c](tools/ludic-tools/migrate_records.c)) are the reusable spine.
### Phase 4 — Control-flow & state consolidation
**4a — machine states auto-number ✅ DONE.** `state KnightMenu = 0 { }` →
`state KnightMenu { }`; a state's value is its declaration index (an explicit
`= expr` still works). Removes the magic constants from state machines
([parse.ludic](selfhost/parse.ludic)). combat.ludic migrated; chronorift golden
byte-identical.
**4b — `enum` types ✅ DONE.** `enum Action { Attack, Guard, Item, Flee }` declares
named `int` constants; a variant is a compile-time int accessed as `Action.Guard`
(= 1), numbered by order. Parser `parse_enum` + dispatch, `enum_ordinal` resolver
in [emit_core.ludic](selfhost/emit_core.ludic), and `Enum.Variant` handling in
[emit_expr.ludic](selfhost/emit_expr.ludic). combat.ludic's battle menus now
dispatch on `KnightAct`/`MageAct` instead of `0..3`; chronorift golden
byte-identical. Editor vocab (`ludic_syntax.h`, JetBrains, TextMate, emacs) gained
`enum` and lost the retired `edge`/`export`/`pure` decl keywords; check-vocabulary
+ test-tools green. **Scoped:** enums are a naming layer over `int` (no distinct
runtime type / enum-typed variables yet) — that keeps register/save semantics
untouched, which the "enum var replaces the register" vision would have to solve.
**`when` vs `if` — kept both (decision).** `when` stays as the `if`-without-else
spelling: it is not incoherent so much as a readability signal ("no else here"),
it is documented and highlighted, and it is a pure alias with no semantic overlap
to untangle. The real target of finding #12 — dispatch on magic integers — is
addressed by 4a/4b, not by collapsing `if`/`when`.
**Bitwise operators — kept as functions (decision).** `band`/`bor`/`bxor`/`bnot`/
`shl`/`shr` stay functions, documented as the deliberate "one spelling, symbols
stay free" choice (LANGUAGE.md §Expressions already states this). Promoting them
to operators would re-introduce the symbol soup the current design avoids.
### Phase 5 — Vocabulary anchored to the compiler ✅ DONE
The editor vocabulary already stayed in sync *with itself* (`check-vocabulary.py`
compares `ludic_syntax.h`, the JetBrains lexer, and the TextMate grammar). The
missing anchor was the **compiler**: a keyword could be highlighted everywhere
and still be silently unparsed. Closed both loops:
- ✅ **Vocabulary ⇄ parser.** `check-vocabulary.py` now extracts every keyword
`selfhost/parse*.ludic` dispatches on (`is_id(...)` / `streq(t.text, ...)`) and
requires the header's declaration + clause keywords to be a subset — with a
`LUDIC_KW_RESERVED` escape hatch for documented, not-yet-implemented keywords
(`scene`/`layer`/`on`/`start`), itself checked so a reserved word that gets
implemented must be promoted. Verified it catches an injected bogus keyword.
- ✅ **Reconciled the drift it exposed.** Removed the highlighted-but-unparsed
`scene`/`layer`/`on`/`start` (→ RESERVED) and the never-implemented
`needs`/`uses`/`requires`/`ensures`/`invariant`/`effects` clause words, and the
retired `edge`/`export`/`pure` prefix modifiers, from `ludic_syntax.h`, the
JetBrains lexer, the TextMate grammar, and the emacs mode; added `enum`/`main`.
`@`-annotations already highlight generically (`@[A-Za-z_]…`). test-tools 28/0.
- ✅ **Doc-fence compilation** — the other half of "single source of truth" — was
already live: `check-docs.py` compiles every ` ```ludic ` fence through the
self-hosted `ludicc --fmt` parse gate (revived during Phase 1's coordination).
Full generation-from-one-list (emit the editor files from a manifest) was not
needed: the bidirectional *checks* give the same guarantee — nothing can drift
without CI failing — without a code-generation step to maintain.
**Phases 1–5 are complete.**
### Phase 6 — vocabulary rename + annotation DSL ✅ DONE (follow-on request)
Renamed the core nouns: `game`/`module` → `program`, `main` → `entry`,
`component` → `property`, `archetype` → `model`, `system` → `handler`. Done via a
transitional self-hosting bootstrap (accept both → reseed → move the compiler's
own source to new keywords + tighten → reseed); old keywords now rejected.
Token-safe corpus migration ([rename_kw.c](tools/ludic-tools/rename_kw.c)), goldens
byte-identical. Reconciled the LSP indexer, editor vocab, check-docs wrapper, and
docs; fixed two pre-existing toolchain bugs (a `set -e` bug in build-tools.sh that
blocked all editor-binary rebuilds, and a stale LSP test offset).
Added an **annotation DSL**: `@Queries(these: [Prop{constraint}, …], on: Model)` on
a handler desugars to the existing `S_QUERY` loop (each property binds by its own
name; a `Prop{…}` constraint qualifies its bare fields; `on:` adds a `{Model}`
tag), and `@Handles(…)` on a program parses as documentation. See
[examples/lang/annotations.ludic](examples/lang/annotations.ludic); bin/x test 15/15. All thirteen findings are resolved or resolved by an
explicit, documented decision.
---
## Decision log
- **Scope:** full redesign, Phases 0–5. *(chosen)*
- **Named fields:** colon everywhere; `=` is binding-only. *(chosen)*
- **Open — Phase 4 detail:** typed `enum` state vs. keep integer registers.
Recommended: typed enums (fixes #13), but it's the deepest change; can be
deferred without blocking Phases 1–3.
- **Open — ui props:** paren named-args (`panel(id: Root)`) vs. colonized
whitespace (`panel id: Root`). Recommended: paren form for full cohesion.
- **Open — bitwise ops:** functions (status quo, documented) vs. operators.

View file

@ -1 +1 @@
0.22.0 0.1.0

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
Everything under assets/polyhaven/ is fetched from https://polyhaven.com and is
released by Poly Haven under CC0 1.0 (public domain). Files are not committed;
see manifest.txt for the exact sources. Re-fetch with tools/glgen/fetch_assets.sh.

View file

@ -1,29 +0,0 @@
# Tiled golden fixtures
The curated, version-pinned corpus for the Ludic Tiled reader (design record:
[Design: Tiled maps](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design%2FTiled),
issues #67–#74). No single Tiled file covers the format surface, so this is a
subset of the official [`mapeditor/tiled`](https://github.com/mapeditor/tiled)
`examples/` tree plus a few hand-authored files for the gaps the official
examples miss.
## Vendored from mapeditor/tiled (`examples/`)
Fetched from `https://raw.githubusercontent.com/mapeditor/tiled/master/examples/`.
Tiled's example assets carry their own licenses (see the upstream repo's
per-folder `*.license`/README); vendored here with attribution for testing only.
| File | Exercises |
|---|---|
| `desert.tmx` + `desert.tsx` | orthogonal, external `.tsx`, base64+zlib (P0/P1) |
| `sewers.tmx` | orthogonal, base64+zlib, embedded tileset, opacity (P0) |
| `orthogonal-outside.tmx` | object layers, shapes, custom properties (P4) |
| `perspective_walls.tsx` | per-tile bool properties, `<tileoffset>` (P4) |
| `isometric_grass_and_water.tmx` | isometric orientation, Wang set (P5) |
| `hexagonal-mini.tmx` | hexagonal orientation (P5) |
## Hand-authored (CC0 / public domain)
| File | Exercises |
|---|---|
| `handmade.tmx` + `handmade.tsx` | a 4×4 orthogonal map whose four layers carry the **same** GID array in CSV, base64-uncompressed, base64+gzip and base64+zlib — so every encoding path must decode identically. The last tile is GID 1 with the horizontal-flip flag (`0x80000001`), forcing real GID decode. `handmade.tsx` also carries a `solid` bool property and a per-tile `<objectgroup>` collision shape for P2. |

View file

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored P3 fixture: animated tile + tile object (issue #71). CC0. -->
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="4" height="4" tilewidth="16" tileheight="16" infinite="0" nextlayerid="3" nextobjectid="2">
<tileset firstgid="1" source="anim_tiles.tsx"/>
<layer id="1" name="bg" width="4" height="4">
<data encoding="csv">
1,0,0,0,
0,0,0,0,
0,0,0,0,
0,0,0,0
</data>
</layer>
<objectgroup id="2" name="objects">
<object id="1" gid="53" x="16" y="32" width="16" height="16"/>
</objectgroup>
</map>

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored animated tileset over the Kenney atlas (issue #71). CC0. -->
<tileset version="1.10" tiledversion="1.10.2" name="anim" tilewidth="16" tileheight="16" spacing="1" tilecount="132" columns="12">
<image source="../kenney/tiny-dungeon/Tilemap/tilemap.png" width="203" height="186"/>
<tile id="0">
<animation>
<frame tileid="0" duration="100"/>
<frame tileid="40" duration="100"/>
<frame tileid="80" duration="100"/>
</animation>
</tile>
</tileset>

View file

@ -1,267 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.8" tiledversion="1.8.2" name="beach_tileset" tilewidth="16" tileheight="16" tilecount="936" columns="36">
<image source="beach_tileset.png" width="576" height="416"/>
<tile id="37">
<animation>
<frame tileid="37" duration="250"/>
<frame tileid="46" duration="250"/>
<frame tileid="55" duration="250"/>
<frame tileid="64" duration="250"/>
</animation>
</tile>
<tile id="38">
<animation>
<frame tileid="38" duration="250"/>
<frame tileid="47" duration="250"/>
<frame tileid="56" duration="250"/>
<frame tileid="65" duration="250"/>
</animation>
</tile>
<tile id="39">
<animation>
<frame tileid="39" duration="250"/>
<frame tileid="48" duration="250"/>
<frame tileid="57" duration="250"/>
<frame tileid="66" duration="250"/>
</animation>
</tile>
<tile id="41">
<animation>
<frame tileid="41" duration="250"/>
<frame tileid="50" duration="250"/>
<frame tileid="59" duration="250"/>
<frame tileid="68" duration="250"/>
</animation>
</tile>
<tile id="42">
<animation>
<frame tileid="42" duration="250"/>
<frame tileid="51" duration="250"/>
<frame tileid="60" duration="250"/>
<frame tileid="69" duration="250"/>
</animation>
</tile>
<tile id="43">
<animation>
<frame tileid="43" duration="250"/>
<frame tileid="52" duration="250"/>
<frame tileid="61" duration="250"/>
<frame tileid="70" duration="250"/>
</animation>
</tile>
<tile id="73">
<animation>
<frame tileid="73" duration="250"/>
<frame tileid="82" duration="250"/>
<frame tileid="91" duration="250"/>
<frame tileid="100" duration="250"/>
</animation>
</tile>
<tile id="75">
<animation>
<frame tileid="75" duration="250"/>
<frame tileid="84" duration="250"/>
<frame tileid="93" duration="250"/>
<frame tileid="102" duration="250"/>
</animation>
</tile>
<tile id="76">
<animation>
<frame tileid="76" duration="250"/>
<frame tileid="85" duration="250"/>
<frame tileid="94" duration="250"/>
<frame tileid="103" duration="250"/>
</animation>
</tile>
<tile id="77">
<animation>
<frame tileid="77" duration="250"/>
<frame tileid="86" duration="250"/>
<frame tileid="95" duration="250"/>
<frame tileid="104" duration="250"/>
</animation>
</tile>
<tile id="79">
<animation>
<frame tileid="79" duration="250"/>
<frame tileid="88" duration="250"/>
<frame tileid="97" duration="250"/>
<frame tileid="106" duration="250"/>
</animation>
</tile>
<tile id="109">
<animation>
<frame tileid="109" duration="250"/>
<frame tileid="118" duration="250"/>
<frame tileid="127" duration="250"/>
<frame tileid="136" duration="250"/>
</animation>
</tile>
<tile id="110">
<animation>
<frame tileid="110" duration="250"/>
<frame tileid="119" duration="250"/>
<frame tileid="128" duration="250"/>
<frame tileid="137" duration="250"/>
</animation>
</tile>
<tile id="114">
<animation>
<frame tileid="114" duration="250"/>
<frame tileid="123" duration="250"/>
<frame tileid="132" duration="250"/>
<frame tileid="141" duration="250"/>
</animation>
</tile>
<tile id="115">
<animation>
<frame tileid="115" duration="250"/>
<frame tileid="124" duration="250"/>
<frame tileid="133" duration="250"/>
<frame tileid="142" duration="250"/>
</animation>
</tile>
<tile id="146">
<animation>
<frame tileid="146" duration="250"/>
<frame tileid="155" duration="250"/>
<frame tileid="164" duration="250"/>
<frame tileid="173" duration="250"/>
</animation>
</tile>
<tile id="148">
<animation>
<frame tileid="148" duration="250"/>
<frame tileid="157" duration="250"/>
<frame tileid="166" duration="250"/>
</animation>
</tile>
<tile id="150">
<animation>
<frame tileid="150" duration="250"/>
<frame tileid="159" duration="250"/>
<frame tileid="168" duration="250"/>
<frame tileid="177" duration="250"/>
</animation>
</tile>
<tile id="181">
<animation>
<frame tileid="181" duration="250"/>
<frame tileid="190" duration="250"/>
<frame tileid="199" duration="250"/>
<frame tileid="208" duration="250"/>
</animation>
</tile>
<tile id="182">
<animation>
<frame tileid="182" duration="250"/>
<frame tileid="191" duration="250"/>
<frame tileid="200" duration="250"/>
<frame tileid="209" duration="250"/>
</animation>
</tile>
<tile id="186">
<animation>
<frame tileid="186" duration="250"/>
<frame tileid="195" duration="250"/>
<frame tileid="204" duration="250"/>
<frame tileid="213" duration="250"/>
</animation>
</tile>
<tile id="187">
<animation>
<frame tileid="187" duration="250"/>
<frame tileid="196" duration="250"/>
<frame tileid="205" duration="250"/>
<frame tileid="214" duration="250"/>
</animation>
</tile>
<tile id="217">
<animation>
<frame tileid="217" duration="250"/>
<frame tileid="226" duration="250"/>
<frame tileid="235" duration="250"/>
<frame tileid="244" duration="250"/>
</animation>
</tile>
<tile id="219">
<animation>
<frame tileid="219" duration="250"/>
<frame tileid="228" duration="250"/>
<frame tileid="237" duration="250"/>
<frame tileid="246" duration="250"/>
</animation>
</tile>
<tile id="220">
<animation>
<frame tileid="220" duration="250"/>
<frame tileid="229" duration="250"/>
<frame tileid="238" duration="250"/>
<frame tileid="247" duration="250"/>
</animation>
</tile>
<tile id="221">
<animation>
<frame tileid="221" duration="250"/>
<frame tileid="230" duration="250"/>
<frame tileid="239" duration="250"/>
<frame tileid="248" duration="250"/>
</animation>
</tile>
<tile id="223">
<animation>
<frame tileid="223" duration="250"/>
<frame tileid="232" duration="250"/>
<frame tileid="241" duration="250"/>
<frame tileid="250" duration="250"/>
</animation>
</tile>
<tile id="253">
<animation>
<frame tileid="253" duration="250"/>
<frame tileid="262" duration="250"/>
<frame tileid="271" duration="250"/>
<frame tileid="280" duration="250"/>
</animation>
</tile>
<tile id="254">
<animation>
<frame tileid="254" duration="250"/>
<frame tileid="263" duration="250"/>
<frame tileid="272" duration="250"/>
<frame tileid="281" duration="250"/>
</animation>
</tile>
<tile id="255">
<animation>
<frame tileid="255" duration="250"/>
<frame tileid="264" duration="250"/>
<frame tileid="273" duration="250"/>
<frame tileid="282" duration="250"/>
</animation>
</tile>
<tile id="257">
<animation>
<frame tileid="257" duration="250"/>
<frame tileid="266" duration="250"/>
<frame tileid="275" duration="250"/>
<frame tileid="284" duration="250"/>
</animation>
</tile>
<tile id="258">
<animation>
<frame tileid="258" duration="250"/>
<frame tileid="267" duration="250"/>
<frame tileid="276" duration="250"/>
<frame tileid="285" duration="250"/>
</animation>
</tile>
<tile id="259">
<animation>
<frame tileid="259" duration="250"/>
<frame tileid="268" duration="250"/>
<frame tileid="277" duration="250"/>
<frame tileid="286" duration="250"/>
</animation>
</tile>
</tileset>

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored collision tileset for the Ludic Tiled demos (issue #69). CC0. -->
<tileset version="1.10" tiledversion="1.10.2" name="collision" tilewidth="16" tileheight="16" tilecount="2" columns="1">
<tile id="1">
<properties>
<property name="oneway" type="bool" value="true"/>
</properties>
</tile>
</tileset>

View file

@ -1 +0,0 @@
{"maps": [{"fileName": "zstd_map.tmx", "x": 0, "y": 0, "width": 384, "height": 256}, {"fileName": "grid_maze.tmx", "x": 384, "y": 0, "width": 128, "height": 80}], "onlyShowAdjacentMaps": false, "type": "world"}

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.0" tiledversion="1.1.5" orientation="orthogonal" renderorder="right-down" width="40" height="40" tilewidth="32" tileheight="32" infinite="0" nextlayerid="2" nextobjectid="1">
<tileset firstgid="1" source="desert.tsx"/>
<layer id="1" name="Ground" width="40" height="40">
<data encoding="base64" compression="zlib">
eJztmNkKwjAQRaN9cAPrAq5Yq3Xf6v9/nSM2VIbQJjEZR+nDwQZScrwztoORECLySBcIgZ7nc2y4KfyWDLx+Jb9nViNgDEwY+KioAXUgQN4+zpoCMwPmQAtoAx2CLFbA2oDEo9+hwG8DnIDtF/2K8ks086Tw2zH0uyMv7HcRr/6/EvvhnsPrsrxwX7rwU/0ODig/eV3mh3N1ld8eraWPaX6+64s9McesfrqcHfg1MpoifxcVEWjukyw+9AtFPl/I71pER3Of6j4bv7HI54s+MChhqLlPdZ/P3qMmFuo5h5NnTOhjM5tReN2yT51n5/v7J3F0vi46fk+ne7aX0i9l6If7mpufTX3f5wsqv9TAD2fJLT9VrTn7UeZnM5tR+v0LMQOHXwFnxe2/warGFRWf8QDjOLfP
</data>
</layer>
</map>

View file

@ -1,68 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.4" tiledversion="1.4.3" name="Desert" tilewidth="32" tileheight="32" spacing="1" margin="1" tilecount="48" columns="8">
<image source="tmw_desert_spacing.png" width="265" height="199"/>
<tile id="30" probability="0.01"/>
<tile id="31" probability="0.01"/>
<tile id="37" probability="0.01"/>
<tile id="38" probability="0.01"/>
<tile id="39" probability="0.01"/>
<tile id="45" probability="0"/>
<tile id="46" probability="0.01"/>
<tile id="47" probability="0.01"/>
<wangsets>
<wangset name="Desert" type="corner" tile="5">
<wangcolor name="Desert" color="#ff0000" tile="29" probability="1"/>
<wangcolor name="Brick" color="#00ff00" tile="9" probability="1"/>
<wangcolor name="Cobblestone" color="#0000ff" tile="33" probability="1"/>
<wangcolor name="Dirt" color="#ff7700" tile="14" probability="1"/>
<wangtile tileid="0" wangid="0,1,0,2,0,1,0,1"/>
<wangtile tileid="1" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="2" wangid="0,1,0,1,0,2,0,1"/>
<wangtile tileid="3" wangid="0,4,0,1,0,4,0,4"/>
<wangtile tileid="4" wangid="0,4,0,4,0,1,0,4"/>
<wangtile tileid="5" wangid="0,1,0,4,0,1,0,1"/>
<wangtile tileid="6" wangid="0,1,0,4,0,4,0,1"/>
<wangtile tileid="7" wangid="0,1,0,1,0,4,0,1"/>
<wangtile tileid="8" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="9" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="10" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="11" wangid="0,1,0,4,0,4,0,4"/>
<wangtile tileid="12" wangid="0,4,0,4,0,4,0,1"/>
<wangtile tileid="13" wangid="0,4,0,4,0,1,0,1"/>
<wangtile tileid="14" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="15" wangid="0,1,0,1,0,4,0,4"/>
<wangtile tileid="16" wangid="0,2,0,1,0,1,0,1"/>
<wangtile tileid="17" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="18" wangid="0,1,0,1,0,1,0,2"/>
<wangtile tileid="19" wangid="0,2,0,1,0,2,0,2"/>
<wangtile tileid="20" wangid="0,2,0,2,0,1,0,2"/>
<wangtile tileid="21" wangid="0,4,0,1,0,1,0,1"/>
<wangtile tileid="22" wangid="0,4,0,1,0,1,0,4"/>
<wangtile tileid="23" wangid="0,1,0,1,0,1,0,4"/>
<wangtile tileid="24" wangid="0,1,0,3,0,1,0,1"/>
<wangtile tileid="25" wangid="0,1,0,3,0,3,0,1"/>
<wangtile tileid="26" wangid="0,1,0,1,0,3,0,1"/>
<wangtile tileid="27" wangid="0,1,0,2,0,2,0,2"/>
<wangtile tileid="28" wangid="0,2,0,2,0,2,0,1"/>
<wangtile tileid="29" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="30" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="31" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="32" wangid="0,3,0,3,0,1,0,1"/>
<wangtile tileid="33" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="34" wangid="0,1,0,1,0,3,0,3"/>
<wangtile tileid="35" wangid="0,3,0,1,0,3,0,3"/>
<wangtile tileid="36" wangid="0,3,0,3,0,1,0,3"/>
<wangtile tileid="37" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="38" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="39" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="40" wangid="0,3,0,1,0,1,0,1"/>
<wangtile tileid="41" wangid="0,3,0,1,0,1,0,3"/>
<wangtile tileid="42" wangid="0,1,0,1,0,1,0,3"/>
<wangtile tileid="43" wangid="0,1,0,3,0,3,0,3"/>
<wangtile tileid="44" wangid="0,3,0,3,0,3,0,1"/>
<wangtile tileid="45" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="46" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="47" wangid="0,1,0,1,0,1,0,1"/>
</wangset>
</wangsets>
</tileset>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored map for the Ludic Tiled demos (issue #69). CC0. -->
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="8" height="5" tilewidth="16" tileheight="16" infinite="0" nextlayerid="2" nextobjectid="1">
<tileset firstgid="1" source="collision.tsx"/>
<layer id="1" name="collision" width="8" height="5">
<data encoding="csv">
1,1,1,1,1,1,1,1,
1,0,0,0,0,0,0,1,
1,0,1,1,1,1,0,1,
1,0,0,0,0,1,0,1,
1,1,1,1,1,1,1,1
</data>
</layer>
</map>

View file

@ -1,65 +0,0 @@
{
"type": "map",
"version": "1.10",
"tiledversion": "1.10.2",
"orientation": "orthogonal",
"renderorder": "right-down",
"width": 4,
"height": 4,
"tilewidth": 16,
"tileheight": 16,
"infinite": false,
"nextlayerid": 3,
"nextobjectid": 1,
"tilesets": [
{
"firstgid": 1,
"source": "handmade.tsx"
}
],
"layers": [
{
"type": "tilelayer",
"id": 1,
"name": "csv",
"width": 4,
"height": 4,
"x": 0,
"y": 0,
"opacity": 1,
"visible": true,
"data": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
2147483649
]
},
{
"type": "tilelayer",
"id": 2,
"name": "zlib",
"width": 4,
"height": 4,
"x": 0,
"y": 0,
"opacity": 1,
"visible": true,
"encoding": "base64",
"compression": "zlib",
"data": "eJwNw4cNACAMBLEPvYaVGT1nySYpMbOwsrFzcHJx8/DS+WjSDw1EAPo="
}
]
}

View file

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored fixture for the Ludic Tiled reader (issue #67). Public domain (CC0). -->
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="4" height="4" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="1">
<tileset firstgid="1" source="handmade.tsx"/>
<layer id="1" name="csv" width="4" height="4">
<data encoding="csv">
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,2147483649
</data>
</layer>
<layer id="2" name="base64" width="4" height="4">
<data encoding="base64">
AQAAAAIAAAADAAAABAAAAAUAAAAGAAAABwAAAAgAAAAJAAAACgAAAAsAAAAMAAAADQAAAA4AAAAPAAAAAQAAgA==
</data>
</layer>
<layer id="3" name="gzip" width="4" height="4">
<data encoding="base64" compression="gzip">
H4sIAAAAAAAC/w3Dhw0AIAwEsQ+9hpUZPWfJJikxs7CysXNwcnHz8NL5aNIPlvf4ekAAAAA=
</data>
</layer>
<layer id="4" name="zlib" width="4" height="4">
<data encoding="base64" compression="zlib">
eJwNw4cNACAMBLEPvYaVGT1nySYpMbOwsrFzcHJx8/DS+WjSDw1EAPo=
</data>
</layer>
</map>

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored fixture for the Ludic Tiled reader (issue #67). Public domain (CC0). -->
<tileset version="1.10" tiledversion="1.10.2" name="handmade" tilewidth="16" tileheight="16" spacing="0" margin="0" tilecount="16" columns="4">
<image source="handmade.png" width="64" height="64"/>
<tile id="4">
<properties>
<property name="solid" type="bool" value="true"/>
</properties>
</tile>
<tile id="6">
<objectgroup draworder="index">
<object id="1" x="0" y="8" width="16" height="8"/>
</objectgroup>
</tile>
</tileset>

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.0" orientation="hexagonal" renderorder="right-down" width="20" height="20" tilewidth="14" tileheight="12" hexsidelength="6" staggeraxis="y" staggerindex="odd" nextobjectid="2">
<tileset firstgid="1" name="hex mini" tilewidth="18" tileheight="18">
<tileoffset x="0" y="1"/>
<image source="hexmini.png" width="106" height="72"/>
</tileset>
<layer name="Ground" width="20" height="20">
<data encoding="base64" compression="zlib">
eJyl1FEKhDAMBNBSt6jVaL3/Za2QwDAkVdiPQda2zyTonimlU1N6Ws+lkZ6l56AUXcPY2qlniv5uL5Z5BdyDvFXXMoX3Rp44axl6nqFejj3LLK6xgmf3Zg06Qs+O+qiaDOZOVgXPs7jfCme8Hkce1+fNlGdlM3myDTzc580fz1htW2Baj15/R/J72wLvcVZN5HnzGnmVPJ5hNH+0dt33j4ex91TARUs+WjNZz/fewKvJfy+/1naR+dX7OfdEnUYefyOeZZ7Vht/b5HjefxJbO1iTE7YWuEpg5hfPzi8D782x3Mg7DV4=
</data>
</layer>
</map>

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored P5 fixture: image + group layers (issue #73). CC0. -->
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="4" height="4" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="1">
<tileset firstgid="1" source="anim_tiles.tsx"/>
<imagelayer id="1" name="bg" offsetx="8" offsety="4" repeatx="1">
<image source="../kenney/tiny-dungeon/Tilemap/tilemap.png" width="203" height="186"/>
</imagelayer>
<group id="2" name="grp" offsetx="16" opacity="0.5" tintcolor="#ff0000">
<layer id="3" name="inner" width="4" height="4">
<data encoding="csv">
2,0,0,0,
0,0,0,0,
0,0,0,0,
0,0,0,0
</data>
</layer>
</group>
</map>

View file

@ -1 +0,0 @@
{"type": "map", "version": "1.10", "orientation": "orthogonal", "renderorder": "right-down", "width": 0, "height": 0, "tilewidth": 16, "tileheight": 16, "infinite": true, "tilesets": [{"firstgid": 1, "source": "collision.tsx"}], "layers": [{"type": "tilelayer", "id": 1, "name": "ground", "width": 0, "height": 0, "startx": 0, "starty": 0, "chunks": [{"x": 0, "y": 0, "width": 16, "height": 16, "data": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]}, {"x": 16, "y": 0, "width": 16, "height": 16, "data": [3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]}]}]}

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored P6 infinite/chunked fixture (issue #74). CC0. -->
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="0" height="0" tilewidth="16" tileheight="16" infinite="1" nextlayerid="2" nextobjectid="1">
<tileset firstgid="1" source="collision.tsx"/>
<layer id="1" name="ground" width="0" height="0">
<data encoding="csv">
<chunk x="0" y="0" width="16" height="16">
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3
</chunk>
<chunk x="16" y="0" width="16" height="16">
3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2
</chunk>
</data>
</layer>
</map>

View file

@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.4" tiledversion="1.4.3" orientation="isometric" renderorder="right-down" width="25" height="25" tilewidth="64" tileheight="32" infinite="0" nextlayerid="2" nextobjectid="1">
<tileset firstgid="1" name="isometric_grass_and_water" tilewidth="64" tileheight="64" tilecount="24" columns="4">
<tileoffset x="0" y="16"/>
<grid orientation="isometric" width="64" height="32"/>
<image source="isometric_grass_and_water.png" width="256" height="384"/>
<wangsets>
<wangset name="Grass and Water" type="corner" tile="15">
<wangcolor name="Grass" color="#8ab022" tile="0" probability="1"/>
<wangcolor name="Water" color="#378dc2" tile="23" probability="1"/>
<wangtile tileid="0" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="1" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="2" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="3" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="4" wangid="0,1,0,2,0,1,0,1"/>
<wangtile tileid="5" wangid="0,1,0,1,0,2,0,1"/>
<wangtile tileid="6" wangid="0,1,0,1,0,1,0,2"/>
<wangtile tileid="7" wangid="0,2,0,1,0,1,0,1"/>
<wangtile tileid="8" wangid="0,2,0,2,0,2,0,1"/>
<wangtile tileid="9" wangid="0,1,0,2,0,2,0,2"/>
<wangtile tileid="10" wangid="0,2,0,1,0,2,0,2"/>
<wangtile tileid="11" wangid="0,2,0,2,0,1,0,2"/>
<wangtile tileid="12" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="13" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="14" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="15" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="16" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="17" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="18" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="19" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="20" wangid="0,2,0,1,0,2,0,1"/>
<wangtile tileid="21" wangid="0,1,0,2,0,1,0,2"/>
<wangtile tileid="22" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="23" wangid="0,2,0,2,0,2,0,2"/>
</wangset>
</wangsets>
</tileset>
<layer id="1" name="Tile Layer 1" width="25" height="25">
<data encoding="base64" compression="zlib">
eJx1lttywjAMROVgyqVtAoFC/v9L68xoh5PFPGhIYktrrVYyS0QszZ7Nvpvd0n7y24L1Q7MhrTSreN/le821HZ7lv9qYa6sdE0cYs/kX7PXYwtfaevYp7WDrd+SnHByjYr/npP1zZ4/elcuM71rjeckdc5KNHX75fMwc9s2uzb6AsYstJzwrv5/Tz89SLIZy8v203llV8xl7yMU+462/v81OqA114/UhrzUxRqwprnh6ZGzp2PNQfPqRu/X9hnMV8F/xLg1L42erDf2oaa2RI2qPtbgbhmw2H69nMUxx/gVccXdC3AW/o/HV60vW59Lhu8arDxmfGIPFUV1qbLVQEIs4PlOeHQxqVjmzr5mLYsmf+5Qj5yM1r3Ne4p1D5VcMh3qWZibLx2fYkBhPYOv81I9wbrGd45zFU7zrndpwDjkHXXfej9zHc3EG+D3AWcCZMJif7hTnVxr6i9edtoBDz8N7kxqbY6sN9gJnsnqIOqCme7Un76579sIV8dccHvHqZefH76BP9wjzkVapM2rL+5/8cR6QS9eh8p2AT12y5oO9+7yh5hzLZypnHX29/pzB9PE7bOg8Mza5KvGu4R7mp/89zqvr7x+TnxEn
</data>
</layer>
</map>

View file

@ -1,285 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.8" tiledversion="1.8.5" orientation="orthogonal" renderorder="right-down" width="45" height="31" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="38">
<properties>
<property name="enemyTint" type="color" value="#ffa33636"/>
</properties>
<tileset firstgid="1" name="outdoor" tilewidth="16" tileheight="16" tilecount="288" columns="24">
<image source="buch-outdoor.png" width="384" height="192"/>
<tile id="6" probability="0.1"/>
<tile id="27" probability="0.05"/>
<tile id="28" probability="0.05"/>
<tile id="30" probability="0.1"/>
<tile id="51" probability="0.05"/>
<tile id="52" probability="0.05"/>
<tile id="54" probability="0.1"/>
<tile id="75" probability="0.05"/>
<tile id="76" probability="0.05"/>
<tile id="78" probability="0.1"/>
<tile id="82" probability="0.1"/>
<tile id="83" probability="0.1"/>
<tile id="99" probability="0.05"/>
<tile id="102" probability="0.1"/>
<tile id="106" probability="0.1"/>
<tile id="107" probability="0.1"/>
<tile id="126" probability="0.1"/>
<wangsets>
<wangset name="Terrains" type="corner" tile="25">
<wangcolor name="Grass" color="#fce94f" tile="150" probability="1"/>
<wangcolor name="Dirt" color="#ef2929" tile="100" probability="1"/>
<wangcolor name="Dark Dirt" color="#f57900" tile="34" probability="1"/>
<wangcolor name="Water" color="#729fcf" tile="171" probability="1"/>
<wangtile tileid="0" wangid="0,1,0,2,0,1,0,1"/>
<wangtile tileid="1" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="2" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="3" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="4" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="5" wangid="0,1,0,1,0,2,0,1"/>
<wangtile tileid="6" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="7" wangid="0,2,0,3,0,2,0,2"/>
<wangtile tileid="8" wangid="0,2,0,3,0,3,0,2"/>
<wangtile tileid="9" wangid="0,2,0,3,0,3,0,2"/>
<wangtile tileid="10" wangid="0,2,0,3,0,3,0,2"/>
<wangtile tileid="11" wangid="0,2,0,3,0,3,0,2"/>
<wangtile tileid="12" wangid="0,2,0,2,0,3,0,2"/>
<wangtile tileid="13" wangid="0,1,0,3,0,1,0,1"/>
<wangtile tileid="14" wangid="0,1,0,3,0,3,0,1"/>
<wangtile tileid="15" wangid="0,1,0,3,0,3,0,1"/>
<wangtile tileid="16" wangid="0,1,0,3,0,3,0,1"/>
<wangtile tileid="17" wangid="0,1,0,3,0,3,0,1"/>
<wangtile tileid="18" wangid="0,1,0,1,0,3,0,1"/>
<wangtile tileid="24" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="25" wangid="0,2,0,1,0,2,0,2"/>
<wangtile tileid="26" wangid="0,2,0,2,0,1,0,2"/>
<wangtile tileid="27" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="28" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="29" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="30" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="31" wangid="0,3,0,3,0,2,0,2"/>
<wangtile tileid="32" wangid="0,3,0,2,0,3,0,3"/>
<wangtile tileid="33" wangid="0,3,0,3,0,2,0,3"/>
<wangtile tileid="34" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="35" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="36" wangid="0,2,0,2,0,3,0,3"/>
<wangtile tileid="37" wangid="0,3,0,3,0,1,0,1"/>
<wangtile tileid="38" wangid="0,3,0,1,0,3,0,3"/>
<wangtile tileid="39" wangid="0,3,0,3,0,1,0,3"/>
<wangtile tileid="40" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="42" wangid="0,1,0,1,0,3,0,3"/>
<wangtile tileid="48" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="49" wangid="0,1,0,2,0,2,0,2"/>
<wangtile tileid="50" wangid="0,2,0,2,0,2,0,1"/>
<wangtile tileid="51" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="52" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="53" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="54" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="55" wangid="0,3,0,3,0,2,0,2"/>
<wangtile tileid="56" wangid="0,2,0,3,0,3,0,3"/>
<wangtile tileid="57" wangid="0,3,0,3,0,3,0,2"/>
<wangtile tileid="58" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="59" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="60" wangid="0,2,0,2,0,3,0,3"/>
<wangtile tileid="61" wangid="0,3,0,3,0,1,0,1"/>
<wangtile tileid="62" wangid="0,1,0,3,0,3,0,3"/>
<wangtile tileid="63" wangid="0,3,0,3,0,3,0,1"/>
<wangtile tileid="66" wangid="0,1,0,1,0,3,0,3"/>
<wangtile tileid="72" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="73" wangid="0,2,0,1,0,2,0,1"/>
<wangtile tileid="74" wangid="0,1,0,2,0,1,0,2"/>
<wangtile tileid="75" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="76" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="77" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="78" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="79" wangid="0,3,0,3,0,2,0,2"/>
<wangtile tileid="80" wangid="0,3,0,2,0,3,0,2"/>
<wangtile tileid="81" wangid="0,2,0,3,0,2,0,3"/>
<wangtile tileid="82" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="83" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="84" wangid="0,2,0,2,0,3,0,3"/>
<wangtile tileid="85" wangid="0,3,0,3,0,1,0,1"/>
<wangtile tileid="86" wangid="0,3,0,1,0,3,0,1"/>
<wangtile tileid="87" wangid="0,1,0,3,0,1,0,3"/>
<wangtile tileid="90" wangid="0,1,0,1,0,3,0,3"/>
<wangtile tileid="96" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="97" wangid="0,1,0,2,0,1,0,2"/>
<wangtile tileid="98" wangid="0,2,0,1,0,2,0,1"/>
<wangtile tileid="99" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="100" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="101" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="102" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="103" wangid="0,3,0,3,0,2,0,2"/>
<wangtile tileid="104" wangid="0,2,0,3,0,2,0,3"/>
<wangtile tileid="105" wangid="0,3,0,2,0,3,0,2"/>
<wangtile tileid="106" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="107" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="108" wangid="0,2,0,2,0,3,0,3"/>
<wangtile tileid="109" wangid="0,3,0,3,0,1,0,1"/>
<wangtile tileid="110" wangid="0,1,0,3,0,1,0,3"/>
<wangtile tileid="111" wangid="0,3,0,1,0,3,0,1"/>
<wangtile tileid="114" wangid="0,1,0,1,0,3,0,3"/>
<wangtile tileid="120" wangid="0,2,0,1,0,1,0,1"/>
<wangtile tileid="121" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="122" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="123" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="124" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="125" wangid="0,1,0,1,0,1,0,2"/>
<wangtile tileid="126" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="127" wangid="0,3,0,2,0,2,0,2"/>
<wangtile tileid="128" wangid="0,3,0,2,0,2,0,3"/>
<wangtile tileid="129" wangid="0,3,0,2,0,2,0,3"/>
<wangtile tileid="130" wangid="0,3,0,2,0,2,0,3"/>
<wangtile tileid="131" wangid="0,3,0,2,0,2,0,3"/>
<wangtile tileid="132" wangid="0,2,0,2,0,2,0,3"/>
<wangtile tileid="133" wangid="0,3,0,1,0,1,0,1"/>
<wangtile tileid="134" wangid="0,3,0,1,0,1,0,3"/>
<wangtile tileid="135" wangid="0,3,0,1,0,1,0,3"/>
<wangtile tileid="136" wangid="0,3,0,1,0,1,0,3"/>
<wangtile tileid="137" wangid="0,3,0,1,0,1,0,3"/>
<wangtile tileid="138" wangid="0,1,0,1,0,1,0,3"/>
<wangtile tileid="144" wangid="0,1,0,4,0,1,0,1"/>
<wangtile tileid="145" wangid="0,1,0,4,0,4,0,1"/>
<wangtile tileid="146" wangid="0,1,0,4,0,4,0,1"/>
<wangtile tileid="147" wangid="0,1,0,4,0,4,0,1"/>
<wangtile tileid="148" wangid="0,1,0,4,0,4,0,1"/>
<wangtile tileid="149" wangid="0,1,0,1,0,4,0,1"/>
<wangtile tileid="150" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="151" wangid="0,2,0,4,0,2,0,2"/>
<wangtile tileid="152" wangid="0,2,0,4,0,4,0,2"/>
<wangtile tileid="153" wangid="0,2,0,4,0,4,0,2"/>
<wangtile tileid="154" wangid="0,2,0,4,0,4,0,2"/>
<wangtile tileid="155" wangid="0,2,0,4,0,4,0,2"/>
<wangtile tileid="156" wangid="0,2,0,2,0,4,0,2"/>
<wangtile tileid="168" wangid="0,4,0,4,0,1,0,1"/>
<wangtile tileid="169" wangid="0,4,0,1,0,4,0,4"/>
<wangtile tileid="170" wangid="0,4,0,4,0,1,0,4"/>
<wangtile tileid="171" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="172" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="173" wangid="0,1,0,1,0,4,0,4"/>
<wangtile tileid="174" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="175" wangid="0,4,0,4,0,2,0,2"/>
<wangtile tileid="176" wangid="0,4,0,2,0,4,0,4"/>
<wangtile tileid="177" wangid="0,4,0,4,0,2,0,4"/>
<wangtile tileid="178" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="179" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="180" wangid="0,2,0,2,0,4,0,4"/>
<wangtile tileid="192" wangid="0,4,0,4,0,1,0,1"/>
<wangtile tileid="193" wangid="0,1,0,4,0,4,0,4"/>
<wangtile tileid="194" wangid="0,4,0,4,0,4,0,1"/>
<wangtile tileid="195" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="196" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="197" wangid="0,1,0,1,0,4,0,4"/>
<wangtile tileid="198" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="199" wangid="0,4,0,4,0,2,0,2"/>
<wangtile tileid="200" wangid="0,2,0,4,0,4,0,4"/>
<wangtile tileid="201" wangid="0,4,0,4,0,4,0,2"/>
<wangtile tileid="202" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="203" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="204" wangid="0,2,0,2,0,4,0,4"/>
<wangtile tileid="216" wangid="0,4,0,4,0,1,0,1"/>
<wangtile tileid="217" wangid="0,4,0,1,0,4,0,1"/>
<wangtile tileid="218" wangid="0,1,0,4,0,1,0,4"/>
<wangtile tileid="219" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="220" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="221" wangid="0,1,0,1,0,4,0,4"/>
<wangtile tileid="222" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="223" wangid="0,4,0,4,0,2,0,2"/>
<wangtile tileid="224" wangid="0,4,0,2,0,4,0,2"/>
<wangtile tileid="225" wangid="0,2,0,4,0,2,0,4"/>
<wangtile tileid="226" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="227" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="228" wangid="0,2,0,2,0,4,0,4"/>
<wangtile tileid="240" wangid="0,4,0,4,0,1,0,1"/>
<wangtile tileid="241" wangid="0,1,0,4,0,1,0,4"/>
<wangtile tileid="242" wangid="0,4,0,1,0,4,0,1"/>
<wangtile tileid="243" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="244" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="245" wangid="0,1,0,1,0,4,0,4"/>
<wangtile tileid="246" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="247" wangid="0,4,0,4,0,2,0,2"/>
<wangtile tileid="248" wangid="0,2,0,4,0,2,0,4"/>
<wangtile tileid="249" wangid="0,4,0,2,0,4,0,2"/>
<wangtile tileid="250" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="251" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="252" wangid="0,2,0,2,0,4,0,4"/>
<wangtile tileid="264" wangid="0,4,0,1,0,1,0,1"/>
<wangtile tileid="265" wangid="0,4,0,1,0,1,0,4"/>
<wangtile tileid="266" wangid="0,4,0,1,0,1,0,4"/>
<wangtile tileid="267" wangid="0,4,0,1,0,1,0,4"/>
<wangtile tileid="268" wangid="0,4,0,1,0,1,0,4"/>
<wangtile tileid="269" wangid="0,1,0,1,0,1,0,4"/>
<wangtile tileid="270" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="271" wangid="0,4,0,2,0,2,0,2"/>
<wangtile tileid="272" wangid="0,4,0,2,0,2,0,4"/>
<wangtile tileid="273" wangid="0,4,0,2,0,2,0,4"/>
<wangtile tileid="274" wangid="0,4,0,2,0,2,0,4"/>
<wangtile tileid="275" wangid="0,4,0,2,0,2,0,4"/>
<wangtile tileid="276" wangid="0,2,0,2,0,2,0,4"/>
</wangset>
</wangsets>
</tileset>
<layer id="1" name="Ground" width="45" height="31">
<data encoding="base64" compression="zlib">
eJyNWE1vVVUU3Y0KQeXL4kBL7QAiEkcopQOIkjgysXagYeLIpE0HGucEtY5U6giw1Bj8AdiWxhZ+ANLS4nv+AJKW1+TVH9DklWfSR+LeeWt51j3eWxmsnPvuPR9rr7P3Pvu8hpkd7DFb8dYb24Pf89bFtOOio8/xquNreZ/jdccxx0nH53j+SN7F81nHOfz+BGs1vP0C706i/1kZ+6asMWRdrvMY+4q3bzmedTwj/FfAO/i2HVcc10s4f+t4QzidcEw6vnd8lz0HfrAu3wOO8xj3seO44AT6cY0GONPWTx1jjp8cvzjO4Fsbfan/luP3XXgrJ+4XtWnjeQX9VzBmErgsdud8lXdwCb94G5w3HE+g+R5ZcwDPDx114U3dY569wkW5UZ8J8I6+AfalPbT7MvhS03nMMV3CNzT+A5zHMt70obvezjrWwfue47HwynUhH3JivwG01E1Be0cs8eW3mOd9x2lwXHVsOx7AP8bgI/PCZ5+PmwHvHceSaK76UZO2cGyg1X2mT/eKrtOwUX2Le/RcicbB4zfHyz6239sPs3VijtB6E33XRPPr0CbXmpwaAvrEPPyBvsDvOn4IbQ+4jkHnDaxfE52POl6zFIe0n1oH12Vw37Hq2HzH8W5o6DgE9FrRn+l7uk8rVvSZi6LvDdF4GaBPU2edS32kDjub1vWrMt6Mq8gJkc/OwQbypq9T54jrKcfVkrl+Fq5ctwn9/hTOZefIgPC+7VhEW9tFb+bwyL2Rw3le5DHGeVuY6xrma0DPJtpYL+Ip/JT+TM7uT9/k649jji3MEWMO+5gF2Py4hLOel8y/A1bMKdRbeW8K99BkzlIOmMVe37GuL2u+y/PYCNbbAkfGwCo0vyX9cq0nwbcsXnP/e97xAjADzutoQ6def3+kJ2lMnCmZL/wl/C7y87bsTXDuOB5Bt//jpbgiflBlQwe6am4bE40Dw6J1PscW9inmiJhlvvzVcaAn7fnT8JyC/ZG77u/SX3Nx8CPnQNRxl6xY05XZPYN5HoBv+F+VL49kccF9on9GG757q2J84KXMD0bBrw/+EHt7yro5umqOh7IW1330FHtNvkvQdg72co8GKtajnn2ib+QJ1p7T2XMgzlDPI//mv+C8Db5NWVO/MeangB/Bt4Y9CnuXs7FVIEfWofy9d5cxPF8mRKtNaDQLvxiy5O/xvgMteQ5si7Y1PLdkf8owjvW+hIan7L85gnXJRLd/wQ+ZD+jPjOU1K9ao1JLfeWatgfdtvGcNsBtnzjcu+/+VdWMubNA6i3lNx9MWahl7qvUHa1RqS79pWjord9C/hedFjLsKaByQ72KJLax1437GmqZX+OV2Mz+FP9y0dI52wKuFd3y/CH3Zh1qz1f7x7n62X/USHm3ggqU7pdYyeZ57EedTxI3W1XXw25T1a+DFs4vn/Bz4d7BHm5bOOe4XvzdLeBBRO0YdwzszeYevjFvymYg1xiv1qFvKtx1o17QUi1EXMHYOgfsR8G9Z8hXWiZyrjvFVnFl/kfOxjDdrmeAZeeuapTOce8x6gHquAVEv6jnL51VozH6shdbAn3VnrBvxdtCS7/LdeUv/NUQNxntlG7ypK/dxHWuw/tbzlTVIrMlaKu4Zeu4egm30I+a/WbTL+KaaHoeWh8GX+sZ/I1HnDlixFr9nqY5dtFQztjK+vMurbsH7hqU7J/X+GxyXMGcNWJI9oy9csOJ/PKEr/0cJe96Dvjdl/3g/2ZZ1uH9/Wbe2GnR8AC7Rb8dSPOV2jaIfc7ae6ew7akX/nQRP+kC8j/OO98vgvA95Yj/q2v2SK7Sm5fx5rC2gT/R/YsXacRT7EeMXLMVdcNa4rYrByMd6B676j44+3QFn1U7jTLkzz6nGw/I93m9Yyot5HcdzhPkrELkrYjD8mnGZc2a9pnUO+Q5mPEO/fivG21Grtktty+8eqjM58b873tk+s25c6t0+cNdS/cAYv1Oxfhn6pR20oh+dzvoOZ/b+A6OnAUo=
</data>
</layer>
<layer id="2" name="Fringe" width="45" height="31">
<data encoding="base64" compression="zlib">
eJzVl9lNw0AURZ8ltgr4YquAjliaYPl8DdAChCVABRB2KkBhpwK2sKQC4FgiShQ8tseMx/aVjmRLY+fO3JnnFxGRGZiVamkBFos2EaOBQGQw6N6PFmcltcbwOx50vU+neKYztsb1OmzAZq4uo9XxbjO2wfUBHMJRjt5c6RyacAlXcF2oGzu9wCu0YCohpwcPftJqFdZ+r9uGMf1nvWjtwX7P/UiEN5vzUiZRW5Qao9QarUHRftKI2qLUGKXWaCOjZ9/zprYoNUapNdrM+Hsu5m0jaotSY5Rao62M73Ax70d4gme4yOgjSb01tH/eu/947xd8izvfS7Ds6F2+VZP8e5oVx+8rY0/D90dN385QPnuapN7DZnxvT1O2HiCNqtYD2GaXt3aot9tQh62K9CJn+DyFEziuiOd7fN7BLdyUxDN5C7kL+Yf74I8+8fkB7/AGQ+zdYU/7t21YI/IWchfyD/dBoibwO1nwmSNvIXch/3AfxCopE18ibyF3If9wH8TKNhOfMq2nTSZ5yXSeTOvZn0nU83Mw78Bb1P/tUKbzlHY9k87jD20Li3Y=
</data>
</layer>
<objectgroup id="3" name="Objects">
<object id="1" name="maggots" type="Location" x="435" y="74" width="155" height="99">
<properties>
<property name="spawncount" type="int" value="5"/>
<property name="spawntype" value="maggot"/>
</properties>
</object>
<object id="2" name="discover chest" type="Trigger" x="201" y="200" width="127" height="127">
<properties>
<property name="script" type="file" value="chest-discovered.lua"/>
</properties>
<ellipse/>
</object>
<object id="3" name="unreachable" type="Fixture" x="2" y="158">
<properties>
<property name="static" type="bool" value="true"/>
</properties>
<polygon points="0,0 55,-23 96,-117 110,-61 104,-42 119,-33 116,6 104,9 100,36 60,43 53,58 43,58 34,74 21,69 18,90 0,89"/>
</object>
<object id="5" name="guard" type="NPC" x="22" y="361">
<polyline points="-3,120 87,91 154,96 181,16 273,-1"/>
</object>
<object id="6" name="guard" type="NPC" x="277" y="18">
<polyline points="0,0 75,78 133,82 176,179 274,183"/>
</object>
<object id="10" gid="282" x="413.333" y="225.333" width="16" height="16"/>
<object id="11" gid="282" x="421.667" y="218" width="16" height="16"/>
<object id="12" gid="2147483930" x="423" y="235.333" width="16" height="16"/>
<object id="13" gid="282" x="5" y="70" width="16" height="16"/>
<object id="14" gid="282" x="-3.66667" y="80.3333" width="16" height="16"/>
<object id="16" gid="283" x="538" y="418.333" width="16" height="16"/>
<object id="17" gid="283" x="407.667" y="462" width="16" height="16"/>
<object id="18" gid="283" x="417" y="473.667" width="16" height="16"/>
<object id="19" gid="283" x="402.667" y="469" width="16" height="16"/>
<object id="21" gid="2147483930" x="683.333" y="260.5" width="16" height="16"/>
<object id="22" gid="282" x="692.167" y="269.167" width="16" height="16"/>
<object id="23" gid="282" x="701.667" y="247.833" width="16" height="16"/>
<object id="24" gid="282" x="688.5" y="242" width="16" height="16"/>
<object id="25" gid="282" x="670.5" y="263.5" width="16" height="16"/>
<object id="26" gid="282" x="680" y="284" width="16" height="16"/>
<object id="27" gid="282" x="643.833" y="283.667" width="16" height="16"/>
<object id="28" gid="282" x="63.4165" y="386" width="16" height="16"/>
<object id="29" gid="282" x="9.0835" y="356.167" width="16" height="16"/>
<object id="30" gid="282" x="11.9165" y="385" width="16" height="16"/>
<object id="31" gid="282" x="54.2495" y="378.5" width="16" height="16"/>
<object id="32" gid="2147483930" x="2.4165" y="364.5" width="16" height="16"/>
<object id="33" gid="2147483930" x="41.5835" y="382.833" width="16" height="16"/>
<object id="34" type="Sign" gid="257" x="670.667" y="87" width="16" height="16">
<properties>
<property name="text" value="East West"/>
</properties>
</object>
<object id="37" name="player-start" type="Location" x="192" y="160">
<point/>
</object>
</objectgroup>
</map>

View file

@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored P2 collision fixture (issue #70). CC0. -->
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="16" height="10" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="1">
<tileset firstgid="1" source="p2_tiles.tsx"/>
<layer id="1" name="collision" width="16" height="10">
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</data>
</layer>
<layer id="2" name="props" width="16" height="10">
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,4,4,4,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,4,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,4,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,4,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</data>
</layer>
<layer id="3" name="floor" width="16" height="10">
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</data>
</layer>
</map>

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored collision-metadata tileset (issue #70). CC0. -->
<tileset version="1.10" tiledversion="1.10.2" name="p2tiles" tilewidth="16" tileheight="16" tilecount="5" columns="5">
<tile id="1">
<objectgroup draworder="index">
<object id="1" x="0" y="0" width="16" height="16"/>
</objectgroup>
</tile>
<tile id="2"><properties><property name="oneway" type="bool" value="true"/></properties></tile>
<tile id="3"><properties><property name="solid" type="bool" value="true"/></properties></tile>
<tile id="4"><properties><property name="trigger" type="bool" value="true"/></properties></tile>
</tileset>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<template>
<object type="Enemy" width="16" height="16">
<properties>
<property name="hp" type="int" value="25"/>
</properties>
</object>
</template>

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored P4 fixture: object shapes, custom types, templates (issue #72). CC0. -->
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="16" height="16" tilewidth="16" tileheight="16" infinite="0" nextlayerid="3" nextobjectid="13">
<objectgroup id="1" name="shapes">
<object id="1" x="10" y="10" width="20" height="30"/>
<object id="2" x="40" y="10" width="20" height="20"><ellipse/></object>
<object id="3" x="70" y="10"><point/></object>
<object id="4" x="90" y="10"><polygon points="0,0 16,0 16,16 0,16"/></object>
<object id="5" x="120" y="10"><polyline points="0,0 10,10 20,0"/></object>
<object id="6" x="10" y="60" width="80" height="20"><text pixelsize="12" halign="center">Hello</text></object>
</objectgroup>
<objectgroup id="2" name="spawns">
<object id="10" type="Enemy" x="32" y="48" width="16" height="16">
<properties><property name="hp" type="int" value="99"/></properties>
</object>
<object id="11" type="Enemy" x="64" y="48" width="16" height="16"/>
<object id="12" template="p4_enemy.tx" x="80" y="48"/>
</objectgroup>
</map>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<objecttypes>
<objecttype name="Enemy" color="#ff0000">
<property name="hp" type="int" default="10"/>
<property name="speed" type="int" default="3"/>
<property name="boss" type="bool" default="false"/>
</objecttype>
</objecttypes>

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<tileset name="perspective_walls" tilewidth="64" tileheight="64">
<tileoffset x="-32" y="0"/>
<image source="perspective_walls.png"/>
<tile id="13">
<properties>
<property name="door" value="true"/>
</properties>
</tile>
<tile id="14">
<properties>
<property name="door" value="true"/>
</properties>
</tile>
<tile id="15">
<properties>
<property name="pickup" value="true"/>
</properties>
</tile>
</tileset>

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored map for the Ludic Tiled demos (issue #69). CC0. -->
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="16" height="10" tilewidth="16" tileheight="16" infinite="0" nextlayerid="2" nextobjectid="1">
<tileset firstgid="1" source="collision.tsx"/>
<layer id="1" name="collision" width="16" height="10">
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,2,2,2,0,0,0,
0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</data>
</layer>
</map>

View file

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.0" orientation="orthogonal" width="50" height="50" tilewidth="24" tileheight="24">
<tileset firstgid="1" name="sewer_tileset" tilewidth="24" tileheight="24">
<image source="sewer_tileset.png" trans="ff00ff" width="192" height="217"/>
</tileset>
<layer name="Bottom" width="50" height="50">
<data encoding="base64" compression="zlib">
eJzt19kKwjAQBdDim0sFqwguL3Vf/sP//ySnkIFhSGrSdEnxPhyQxqJ32kySPMuyJTkZC5KLa2dyNEo1Lsds3wkl/4f+7V/0/f+Y40Je5GpUn2+J5NiQCdmO/HlMyYzMI3JwLUJx7drIsSIFWUfk4FrY3GvGuHb8vr4jcmhNcnAtpIflmmar3VA5nqaWMUKeQ1d9dyht59iRPTmMPEdpua8Put/Frh88P5q84zFkv/NZP2TOlOaH7Hc+64fMmdL8cPVbV9+tcn5MzpTmR0gv12uDbQ4MNT8AIA73Uq3v3hqLe2ldTx4D21k1tf2ubw59Vk1tX+KbQ59VXfuSlMn9SJHV70tS5jqrYu8BAAAAAAAAAABd+wIHfQq1
</data>
</layer>
<layer name="Top" width="50" height="50" opacity="0.49">
<data encoding="base64" compression="zlib">
eJzt1jsKgDAQQEELtVKvYuGvEDvvfya3MBeQQAzMwCPdsum2af5lL71AJv7xL7X+Y46WtzXayq7z2RGd0f2+V6a5bdRFfaZ5pQzRGE2lFwEAoArpDk7Veg+nOzjlHgYAAAAAIIcHvboDlQ==
</data>
</layer>
</map>

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored P6 zstd fixture (issue #74). CC0. -->
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="24" height="16" tilewidth="16" tileheight="16" infinite="0" nextlayerid="3" nextobjectid="1">
<tileset firstgid="1" source="collision.tsx"/>
<layer id="1" name="csv" width="24" height="16">
<data encoding="csv">
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,2,3,3,3,3,3,2,3,3,3,3,3,2,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,2,3,3,3,3,3,2,3,3,3,3,3,2,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,2,3,3,3,3,3,2,3,3,3,3,3,2,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
</data>
</layer>
<layer id="2" name="zstd" width="24" height="16">
<data encoding="base64" compression="zstd">
KLUv/WAABe0AADgBAAAAAwIBBgC4mgT54KqgO9JxR0xFgOm26RIB
</data>
</layer>
</map>

View file

@ -1,7 +1,7 @@
# Changesets # Changesets
A **changeset** is one small Markdown file describing a single user-facing change, A **changeset** is one small Markdown file describing a single user-facing change,
dropped in this directory. `ludic-dev release` consumes every changeset here into a new dropped in this directory. `x release` consumes every changeset here into a new
`CHANGELOG.md` section, bumps `VERSION`, and deletes the consumed files. `CHANGELOG.md` section, bumps `VERSION`, and deletes the consumed files.
## Format ## Format
@ -14,38 +14,12 @@ the changelog. Markdown is fine.
``` ```
- `bump:` — `major`, `minor`, or `patch` (SemVer). The release version is bumped - `bump:` — `major`, `minor`, or `patch` (SemVer). The release version is bumped
by the **highest** level among the pending changesets (unless `ludic-dev release <level>` by the **highest** level among the pending changesets (unless `x release <level>`
overrides it). overrides it).
- `type:` — the Conventional Commit type (`feat`, `fix`, `perf`, `docs`, …). It - `type:` — the Conventional Commit type (`feat`, `fix`, `perf`, `docs`, …); it
decides which group the change lands in: `feat` → **Features**, `fix` → becomes the bold prefix of the changelog bullet.
**Fixes**, `perf` → **Performance**, and so on, in that order. A type with no
known heading gets one named after itself.
## Writing the body
The body is markdown and reaches the changelog as markdown: it becomes one list
item, with continuation lines indented to stay inside it. Nested bullets, blank
lines between paragraphs and inline code all survive.
```
bump: minor
type: feat
**Tiled map support** — load and draw Tiled maps.
- **TMX/TSX** — the XML formats, decoded to the same intermediate as JSON.
- **Collision** — the `collision` layer projects onto the engine tilemap.
```
Lead with the thing that changed, not with the mechanism. A reader scanning the
release should be able to stop after your first clause.
## Adding one ## Adding one
Create a file with a short, unique name, e.g. `changes/regex-namespace.md`. Any Create a file with a short, unique name, e.g. `changes/regex-namespace.md`. Any
filename works except this `README.md`, which the release step always skips. filename works except this `README.md`, which the release step always skips.
Preview how the next release will read before cutting it — this writes nothing:
```bash
ludic-dev release --dry-run
```

View file

@ -1,8 +0,0 @@
bump: patch
type: fix
`Actor.cast_hidden` separates being DRAWN from CASTING. `ac_visible` rejected any hidden
actor from the shadow pass as well as the scene pass, so a game that hides the player's
own body - first person, or a viewfinder held to the eye - lost that player's shadow
entirely. An actor hidden because the camera is inside its head is still standing in the
sun; set this on it and it keeps its shadow. Everything else still stops casting when it
is hidden.

View file

@ -1,17 +0,0 @@
bump: patch
type: fix
A leaf is not matte.
Foliage roughness was pinned to 1.0 and grazing Fresnel switched off, so nothing green in
the game had a highlight anywhere: the glint off waxy leaves and wet needles, which is
most of what makes a real stand look alive rather than painted, was simply absent.
The reason it was switched off is real. A crown is card quads, and at a grazing angle the
card's normal is a lie, so a plain specular lobe frosted whole crowns white against the
sky. So the sheen comes back as its own term gated on exactly that: it fades out as the
card turns edge-on, which is where its normal stops meaning anything. A tight lobe for the
glint, a weak wide one for the waxy rim, and nothing at all at the angles that frosted.
Thin-leaf translucency reaches further with it - a backlit stand glows for as far as you
can see it, not 140 m - and a dense crown passes 0.45 of it rather than 0.3. The distance
cap that stops a two-pixel clump card becoming a lime disc stays.

View file

@ -1,28 +0,0 @@
bump: minor
type: feat
Anti-aliasing that exists, and a lens for the viewfinder.
THE SHIPPING DEFAULT HAD NO ANTI-ALIASING AT ALL. The temporal resolve was removed (for
good reasons - it reprojected water through the surface plane and dragged the mirror image
behind the camera), the setting's first option went on saying "Temporal", and MSAA defaults
to one sample. Every machine without DLSS - which is every Mac - drew a frame full of grass
blades and needle cards with nothing smoothing a single edge.
FXAA now, in the sharpen pass, because that pass already reads this pixel's neighbourhood
and runs last on the LDR image. It has no history, so it cannot drag or smear a reflection.
Measured on edge pixels: 25.5% less single-pixel staircase.
One trap worth recording. The unsharp mask's delta is computed from the RAW image and only
then applied to the anti-aliased colour. Taking the centre from the FXAA result and the
neighbours from the raw texture measures a difference that is half smoothing and half
signal, so the mask sharpens exactly the edges FXAA just softened - measured, that first
version was 29% WORSE than no anti-aliasing at all.
And depth of field, for the photo mode: a disc of taps whose radius is the pixel's circle
of confusion, signed so the two sides of the focal plane differ and normalised by the focus
distance, because a lens focused at two metres throws a background out far harder than one
focused at two hundred. A tap only counts if it is at least as out of focus as the pixel
it is blurring into, which is what keeps a sharp foreground from haloing into a blurred
background. It runs between the scene and the bloom so a blurred highlight still blooms,
and the whole pass is skipped when the aperture is shut - in ordinary play there is no lens
and this never draws.

View file

@ -1,11 +0,0 @@
bump: minor
type: feature
**Actions and reducers.** `action PickUp { item: int }` is a typed record of something that
happened; `reducer Bag on PickUp(b: mut Bag, a: PickUp) { ... }`, in the module that owns the state,
says what it means for that one state - a reducer takes exactly its state and the action, and a
second state is refused; `dispatch PickUp { item: 7 }` queues one from anywhere. The queue is drained
at the end of every phase of the frame loop, after every phase of ludic.base's `core_tick_all`, and
where a program calls `drain_actions()`: in dispatch order, each action's reducers in the order of
their states' names, an action a reducer dispatches queued behind (a queue still growing after 64
rounds stops the program, naming the action). `ludic deps` reports `widest_function` - the most
states any function or entry point of the program takes - and `--check` ratchets it.

View file

@ -1,15 +0,0 @@
bump: minor
type: feat
An aspen leaf hangs on a flattened stalk and turns in air a spruce never feels, and the
kit had no way to say so. `layer_flutter(l, v)` gives a scatter layer a per-leaf tremble:
the vertex stage offsets each leaf by a phase taken from its own place on the card, so
neighbouring leaves are never in step, and writes the result out as a varying the
fragment stage uses to flash the leaf's pale underside as it turns. The flash is the part
that reads - a still frame of a tremble is a still frame of nothing. One uniform, one
varying, no extra pass, and every other layer leaves it at zero.
Also fixes the sway itself, which was measured in METRES: `hgt * hgt * 0.35` is right for
a 40 cm flower and puts ten metres of sideways into a 14 m trunk, so every tall tree in
the valley stood bent over like a fishing rod. It is a fraction of the model's own height
now, so the tip moves a few per cent of the tree whatever the tree is and the base does
not move at all.

View file

@ -1,9 +0,0 @@
bump: minor
type: feature
**`ludic.anim` carries ozz-animation (0.17.0, MIT) as a native library**, the second package to do
so after `ludic.physics`. Its skeleton and each clip are built at LOAD from the numbers the package
already reads - a skin's parents and rest pose, a clip's flattened channels - so there is no bake
step and no new file. `anim_oz_skel(sk)`, `anim_oz_clip(c)`, `anim_oz_ctx(s)` and
`anim_oz_sample(ctx, clip, t, rot, pos, n)` are the first step (Maroon Lake's phase 19.1): a sampled
rotation agrees with `anim_mix` to within 1e-4 a component. `anim_play` is unchanged. The library is
built by `native/build.sh` from the pinned release, and on Windows it imports KERNEL32 alone.

View file

@ -1,6 +0,0 @@
bump: minor
type: feature
**`@Asset(kind, map)`: a path under each map's directory.** A field whose file lives in the map's own folder
(a grass kind's density picture, `ground/blades.png`) says so, and `ludicc --check` looks for it in every
map - a @PerMap row's in its map, a game-wide row's in all of them - refusing a map that lacks it unless the
field is `@Asset(kind, map, optional)`. The schema marks the attribute `"scope": "map"`.

View file

@ -1,7 +0,0 @@
bump: patch
type: fix
**An attribute before `export` is kept.** `@ToClients export event E`, `@Sync export property P`,
`@Owned export model M` and the rest lost the attributes written in front of `export`: the parser read
them, then parsed the declaration afresh and forgot them - so an exported remote event was silently
local; and `export @ToClients event` was refused outright. Attributes and `export` now read in
either order into the same declaration.

View file

@ -1,8 +0,0 @@
bump: minor
type: feature
**A bake's inputs can follow the data.** `bake_expand(inputs, map)` gives a Bakes row's inputs as they are
hashed: `{map}` put as the map's key, and each input with a `*` put as the paths it matches, sorted as whole
paths byte by byte, dot-names left out, a glob matching nothing gone. `bake_maps(first_input)` is the maps a
`{map}` row covers: each directory under assets/maps holding its first input (`bake_maps_in` under another
root). A runner hashes `bake_inputs_hash(bake_expand(row.inputs, map))`, the same path the check takes, so
the two cannot disagree on stale; a game's Python tools are its checked twin.

View file

@ -1,11 +0,0 @@
bump: minor
type: feature
**Bakes you can look at, and three more of the renderer's textures read from one.** `ludic.lab` writes
raw 8-bit pixels (1 to 4 channels) as a PNG (`lab_png_write`, `png_write.ludic`, importable alone with its
own `LabPngState`) and turns float textures into honest previews (`png_convert.ludic`: R32F min..max as
grey, RG16F as red and green x255, HDR RGBA16F as x/(1+x) then sRGB). `ludic.render3d` takes three bakes
the game names (`bake_load.ludic`) and makes each as before when one is missing or stale: an impostor's
atlases as BC7 with their baked mips, through the compressed upload (`impostor_from_baked`,
`impostor_fill_bc7`; a fog opening past its cards reads the bake again instead of painting), the sky's image-based light at the start yaw per prefilter width (`sky_baked_in`; any other
turn of the sky is convolved), and the grass carpet (`carpet_from_baked`, `carpet_bytes`).
`impostor_from_bytes` returns null, keeping nothing, when the bytes are not the impostor's shape.

View file

@ -1,8 +0,0 @@
bump: minor
type: feature
**Textures a game baked at build time are read before the PNG.** `png_decode` first takes
`assets/baked/png/<path>.tex` (a game's `ludic bake` output: the samples, ready to upload), and a cut-out
load (`tex_load_ex` with dilate) first takes `assets/baked/cutouts/<path>.bc7` (padded as `tex_dilate`
pads, then BC7 with its mips) - so a boot decodes, pads and converts nothing it can take ready-made. Both
are ludic.base's baked form, read by hand (baked_tex.ludic: the "LBAK" header, the key and version); a
missing or stale one falls back to the PNG as before. `tex_load_dds_at` reads a .dds at an offset.

View file

@ -1,5 +0,0 @@
bump: minor
type: feature
**`bind Purse { money: g_money }` - a port member bound to a variable.** A member that takes nothing
may name a global instead of a function; the compiler writes the getter in the bind's file, so the
one-line wrapper is gone. A member that takes something is refused a variable.

View file

@ -1,7 +0,0 @@
bump: patch
type: fix
**`ludic build` keeps its LLVM IR out of the project.** The intermediate `.ll` was written beside
the binary (`build/<name>.ll`) and deleted after linking, so a project's tree held one for the
length of every build, and two builds at once deleted each other's - which surfaced as a
`clang: no such file` that read exactly like a compile error. It now goes to the run's own
temporary directory and goes with it. `--save-temps` still keeps it at `build/<name>.ll`.

View file

@ -1,9 +0,0 @@
bump: patch
type: fix
**A function named like a built-in a call always takes is refused.** `function words(st, k)` compiled,
and every call to it became the built-in `words(n)` - n zeroed ints, with a pointer for n - and LLVM
refused the IR far from the cause. A top-level function whose name a call always takes as the
compiler's own (`words`, `keep`, `print`, `save`, `load`, `key`, ...; the table is
`selfhost/check/check_builtins.ludic`, held to `emit_call` by `ludic-dev syntax --check`) is now an
error at its declaration, as `run` already was. Every other built-in (`buffer`, `floats`, `double`,
...) yields to a function the program declares, in the checker as it already did in codegen.

View file

@ -1,7 +0,0 @@
bump: minor
type: feature
**`ludic build --check` / `ludicc --check`: check without building.** The parse, the type checker
and the module rules (`export`, `uses`, layers, ports, registries) run, and nothing is emitted or
linked - about three seconds on Maroon Lake where a build takes about a minute. In this mode the
checker asks the module rules at each reference it resolves, since the emitter that usually asks
them does not run.

View file

@ -1,9 +0,0 @@
bump: minor
type: feat
**Check an unsaved buffer.** `ludic build --check --diagnostics=json --stdin-file <path>` (and
`ludicc --check --stdin-file <path>`) checks the program as usual, but wherever the compiler would open
`<path>` - the entry, an import reached through a barrel, a component's `.xml` / `.lss`, an `.lres` -
it reads the text on stdin instead, so an editor's diagnostics follow typing without a save. Paths are
matched after normalising both (separators, relative to the working directory, `.` / `..` folded).
Diagnostics carry the file's usual name with lines and columns in the buffer; a `<path>` the program
never opens is reported as one warning.

View file

@ -1,7 +0,0 @@
bump: patch
type: fix
**A chunked table's keys are unique across its map, and not interned.** A row's id is `(map, key)`, so a
tree moved into another chunk keeps it, and `ludicc --check` refuses a key written in two of a map's
chunk files, naming both. The keys are no longer interned: interning every key a player walked past would
have filled the bounded intern table and kept them all for good. A chunk slot keeps its keys in its own
buffers, rewritten in place when the slot is refilled; `intern(row.key)` keeps one past `_out`.

View file

@ -1,9 +0,0 @@
bump: patch
type: feat
Things sit ON the ground rather than hovering over it. The screen-space GI pass takes
a second, much tighter set of taps (a 0.40 m radius that grows with distance, with a
range check so a far surface behind a near one cannot darken it) and folds the result
into the ambient occlusion it already had. The wide radius answers "how enclosed is
this", which a trunk meeting grass barely registers; the tight one answers "is
something touching here", which is the shadow the eye looks for to place an object.
It is eight taps on a buffer the pass had already bound.

View file

@ -1,9 +0,0 @@
bump: minor
type: feature
**Namespaces are declared in Ludic.** `alias meth(labels) = target` in a `namespace` block makes
`Ns.meth(...)` a call to `target`, taking named arguments by those labels; with no label list the
target's own parameter names are the labels. The engine's 41 table-driven namespaces - `Http`,
`Udp`, `Process`, `Json`, `Value`, `Screen`, `Input`, `Audio`, `World`, `Tiled` and the rest, 438
methods - moved out of the compiler into `runtime/native/namespaces.ludic`, and a package owns an
API the same way. The code a program compiles to is unchanged byte for byte, and the checker now
checks an alias call's arguments against its target.

View file

@ -1,7 +0,0 @@
bump: minor
type: feature
**`def Recipes from "recipes.lres"` - a game fills a package's open registry from its own resource
file.** The entries are checked against the registry's record as the file is read, with errors at
the resource file's line, and they are defs of the module that wrote the line: the registry must be
open to it, and they sit in the stable order (the declaring module's entries, then other modules'
by name, and file order within a file).

View file

@ -1,134 +0,0 @@
bump: minor
type: feature
**Default parameters, components, views and templates.** A parameter can have a default (`pad: float = 8.0`).
A call leaves out what it does not change, and may pass its first arguments by position and the
rest by name.
A `view` declaration is the one bridge between a program and its UI. It names the fields a
template may read, the functions it may ask and the `on` events it may send, and it writes
`view_<name>() -> UiView`.
A `component Name { prop, state, fields, functions, on events }` declaration beside `Name.xml` and
`Name.lss` is a UI component. Its template and styles are compiled in (with `@import` inlined), each
mounted instance keeps its own props and state, styles are scoped to it, and a parent's `class`,
`style` and `id` land on its root.
`ludic.ui` is now a template runtime. Screens and components are XML files loaded at run time,
with:
- `{expression}` bindings;
- `<if>`, `<else>` and `<each>`;
- props, `<slot/>` and per-instance `<state>`;
- `on-press` actions that send events, `set` state or `emit` to the component's user;
- component libraries (`export="true"`, `<import src as>`);
- HTML's elements (`div`, `p`, `h1`-`h6`, `ul`/`li`, `img`, `hr`, ...), with a default stylesheet;
- HTML's attributes: `id`, `class`, `style`, `hidden`, `disabled` and `onclick`, with any other
attribute kept for selectors;
- the CSS box model (padding and margin in 1-4 values, borders, `px` and `%`) and flex layout
(`flex-grow`, `justify-content`, `align-items`/`align-self`, `flex-wrap`, min and max sizes)
under CSS's property names;
- stylesheets, in a `<style>` or an `.lss` file (a Ludic StyleSheet) that others import and that
can `@import` more;
- CSS's selectors: `#id`, compound classes, `[attr=value]`, descendant and `>` combinators,
`:hover`, `:disabled`, `:first-child`, `:last-child`, `:nth-child`, `:not` and more, weighed by
specificity.
- more CSS: custom properties and `var()`, `position` with insets and `z-index`, `em`/`rem`/`vw`/`vh`,
`@media`, wrapping text and ellipsis, `overflow`, `+`/`~`, `:nth-child(an+b)`, `:checked`, `:active`;
- more React: keyed lists, `<let>`, `<provide>` context, `<fragment>`, named slots, `on-mount` and
`on-unmount`;
- native elements a program draws itself (`ui_native`, `ui_fire`), and form controls;
- errors with file and line, hot reload (`ui_reload`), and an inspector-style dump.
The runtime is a UI framework, not only a template engine:
- it takes input itself: focus and keyboard navigation, the pointer, scroll boxes, `autofocus`;
- it has built-in controls (button, checkbox, radio, range, select, text, key), styled as CSS
parts;
- `ludic.ui/render3d.ludic` is a render3d backend, with textures, atlases, nine-slices, clipping
and scale;
- more CSS: `rgba()`/`#rrggbbaa`, `border-radius`, `outline`, `box-shadow`, `background-image`,
`border-image`, group `opacity`, `@keyframes` / `animation` / `transition`;
- HTML mixed content, and boolean attributes;
- `popover` (a top layer that keeps the pointer and keys, with light dismissal), `title` tooltips,
and `<progress>` / `<meter>`;
- importing `ludic.ui/render3d.ludic` installs the backend, and atlases take rows;
- hooks for the program's language, sounds and clock.
What a game's screens found missing, now in `ludic.ui`:
- `<input type="number" min max step>`: typed digits, Enter or leaving it commits them clamped, the
arrows step it;
- `<input type="key">` listens for any key (Tab and the arrows included) once Enter or a click starts
it; Esc stops it, Backspace clears it, `shown` names the value, and `ui_capturing()` tells the host;
- `note="..."` under any control's label (`.ui-note`); a range's `decimals`, `format="percent"` and
`unit`; a track laid out as a row, with the range's fill as tall as it;
- popovers anchored beside an element (`anchor="id"`, or a bare `anchor` for the element before it,
`placement`), flipped to the other side and kept on the screen;
- `flex-shrink` (a scroll box in a column takes the room its siblings leave), `flex: grow shrink`,
`order`, and text in a row wrapping in the room its siblings leave;
- `calc()` over px, %, em, rem, vw, vh and `var()`; `width: 0` and `height: 0` mean 0;
- `text-shadow`; tooltips of several lines; `ui_opacity()` for a native's draw;
- `border-image` drawn as painted with no background colour, tinted by one, and not at all under
`transparent`; a picture file drawn untinted (an atlas cell still takes `color`);
- the render3d backend loads a picture again when its file changes (`ui_image_reload`), draws a path
with a drive letter as a path, and slices a nine-slice by its texture's own width and height;
- a component root that is itself a component takes every user's class, style and id, and the
sheets that style it are weighed together by specificity;
- a component's event may be called `set`; a `string` prop given a number reads it as text; two
components of one name are an error naming both files;
- the scrollbar is `.ui-scrollbar` and `.ui-thumb`: a press on the thumb holds it where it was taken,
a press on the track jumps the thumb's middle there, and neither presses what is under the bar;
- a popover's own controls take its presses whatever lies under it, a press outside only closes it,
and while one is up the scroll boxes outside it do not take the pointer;
- the first gamepad moves the focus (d-pad, left stick), steps ranges and selects, and presses (A)
and goes back (B); a held direction, on the pad or the arrow keys, repeats after 0.42 s and then
every 0.11 s on the ui clock (`UiInput.held_*`, `pad_a`, `pad_b` for a host);
- pointer events: `on-pointerdown` / `pointermove` / `pointerup` / `drag` / `wheel` with `event.x`,
`y`, `dx`, `dy`, `button` and `wheel`, and `ui_native_input(tag, fn)` for a native; a press captures
the pointer until release; the pointer hits the topmost element in painting order, and
`pointer-events: none` lets it through;
- `on-down` and `on-up` on a button (the pointer, Enter or A), with `:active` true while it is held
there rather than whenever the pointer is down over it;
- an anchored popover's `align="start|center|end"`, and `within="id"` (by default the nearest
scroll box around it) for the bounds it is flipped against and kept inside;
- `text-fit: shrink MIN` shrinks a line to its box, then cuts it with an ellipsis; `line-height`;
an `em` reads the font size the element ends with (a `font-size` later in the rule, or in a later
rule), not the one it had so far;
- `min()`, `max()` and `clamp()`, in `calc()` or on their own; `top` / `right` / `bottom` / `left`
as a percentage or a `calc()` of one, of the containing block;
- a nine-slice's corners are clamped to half the box in each direction on its own and cut on whole
pixels (`ui_nine_cuts`), so a small key cap has no seam;
- `scroll-top="{px}"` holds a scroll box at an offset, with `on-scroll` when the player moves it;
`ui_scroll_set(id, px)` moves one once;
- `linear-gradient(...)` backgrounds; `aspect-ratio`; `object-fit` for pictures (the renderer's
`image_w` / `image_h`) and `ui_object_fit` for natives;
- `translate="no"` keeps an element's text as written; a title of several lines is translated whole,
else line by line;
- `<input type="key">` takes a mouse button (`UI_MOUSE_LEFT` / `RIGHT` / `MIDDLE`, 256-258) and is
`:capturing` while it listens;
- a `title` shows for the keyboard's focus too, after the same half second; a focus ring drawn
through a renderer with no `rect` no longer crashes;
- a component with no stylesheet of its own reads a theme's `:root` variables from around it (it
did; now a test says so);
- `ui_scale()` and `ui_box("id")`, the scale and an element's laid-out box, for a host;
- `on-submit` on a text field (Enter or A; the focus and text stay unless `clear-on-submit`);
- `zoom` on any element, and a length over a length in `calc()` is a plain number;
- `on-hold` every frame a button is held, with `event.dt` and `event.t`;
- a transition lands exactly on its end value (it had stopped a rounding error short of it, at every
frame rate).
render3d gains `tex_width` / `tex_height`, and the XML reader keeps text runs among elements in
order (`mixed`).
A `view` field set to a literal or a named function's result needs no type.
Screens are drawn through a registered renderer. `Value` gains a float kind.
Also:
- A program's function named like one of the runtime's is refused; it had been silently taking the
runtime's own calls. So is one named like a compiler built-in (`run`, `exit`, `free`, `fill`,
...): every call to a program's own `run` compiled into C's `system()`, and clang failed on the IR.
- An index is evaluated before the slice's elements are read. A `xs[f()]` whose `f` grew `xs`
read stale memory.
- A runtime error names the file its expression is in, not the program's.
Two declarations with one name (a package's private global and a program's, say) are reported as
such before type checking. They used to surface as a page of type errors about the wrong type.

View file

@ -1,6 +0,0 @@
bump: patch
type: feature
**`ludic deps --writes` warns about a write through a local alias.** `let t = thing_cur` and then
`t.used = 1` writes another module's record just as `thing_cur.used = 1` does; a local bound straight
from another module's global (or from such a local) is now followed within its function and each
write through it listed as a warning. A reference that arrives from a function's result is not.

View file

@ -1,9 +0,0 @@
bump: patch
type: fix
**`ludic deps`: a reach counts every state apart, however the states are numbered.** The reach and
write-reach bitsets packed 60 states to a word, but an `int` is 32 bits, so `1 << 45` came back as bit
13 and states 32 apart shared a bit: a function taking both counted one, fewer than it takes, and the
counts (`widest_reach`, `widest_write_reach`, `--reach`, `--wreach`) rose and fell with how a program's
states happened to be numbered. The sets now hold 30 to a word. On Maroon Lake `widest_reach` goes
58 -> 76 and `widest_write_reach` 54 -> 64 - the real numbers, which the old count hid.
`examples/state/reach_wide.ludic` (40 states, S00 and S32 taken together) holds it.

View file

@ -1,10 +0,0 @@
bump: minor
type: feat
**`ludic deps` sees through fn values, and lists the widest functions.** A step list or a registry of
fn values takes no state and still reaches every state its steps take; `widest_reach` is the most
states any function can come to - by a call, a `fn f` it writes, or a global holding fn values it
reads - reported beside `widest_function` with how many of them it does not take itself
(`the widest reach: app_boot (src/app/boot.ludic:30), 72 states (72 through calls and fn values it
does not take)`). `--widest N` lists the N functions that take the most states with what each
reaches; `--reach N` orders them by reach. A baseline written before this has no `widest_reach` and
does not hold it until it is rewritten.

View file

@ -1,7 +0,0 @@
bump: minor
type: feat
**`ludic deps` says what a function can come to CHANGE** (`widest_write_reach`, and `--wreach N`
lists the functions by it): the states it reaches as `mut`, through calls, `fn` values and step
lists. Reach itself is sharper: `Port.member()` reaches that member's binding only, and
`Registry[i].field` (or a local holding `Registry[i]`) reaches that field only - a question asked of
a port or a table that also holds verbs no longer reaches the verbs.

View file

@ -1,7 +0,0 @@
bump: minor
type: feature
**`ludic.devlink`: the interface's verbs.** A `DevlinkUi` port, every member defaulting to "not offered":
`ui_screen` (the screen's root class and its components), `ui_model "<Class>" "<out>"` and `ui_tree "<out>"`
(the game writes a component's model or the whole tree to a file, no `..`, and the answer names it, so a
datagram stays small) and `ui_override "<path>" "<file>"` (a template or stylesheet read from another file
and reloaded keeping state; `""` clears one, `"" ""` all). Answered at once; nothing made per frame.

View file

@ -1,9 +0,0 @@
bump: minor
type: feature
**`ludic.devlink`: an editor's live link into a running dev build** (protocol v1, frozen with Ludic
Studio). Loopback UDP through the `DevlinkNet` port, one request a frame parsed in place from one fixed
8 KB buffer and answered into another; every verb a `DevlinkWorld` member defaulting to "not offered":
`ping`, `hello` (the build's schema hash as 16 hex digits), `cam_get` / `cam_set` / `cam_release`, `goto`,
`map_load`, `time`, `weather`, `shot`, `pause` / `resume` / `step`, the slow three answered later by id.
A socket is opened only when `enabled()` says so (a game binds `dev_tools`), a sender off this machine is
dropped, and a connected co-op session refuses everything but `ping` and `hello`.

View file

@ -1,8 +0,0 @@
bump: patch
type: performance
**Cut-out edge padding runs on every core.** `tex_dilate`'s passes hand their rows, sixteen at a time, to
`Job.parallel_for`: within a pass a row writes only its own still-masked texels and reads only
neighbours the mask already let go, so the bytes are the ones the single-threaded loop made. The worker
is handed plain buffers in a `DilateJob` and makes nothing. `tex_dilate_bytes` is the slice-taking
form (safe_api.ludic), and `examples/rendering/dilate.ludic` holds the result against the old loop
(prints DILATE OK). It was 206 ms of the main thread in a Maroon Lake boot.

View file

@ -1,10 +0,0 @@
bump: patch
type: fix
**A dispatched action no longer allocates a record each time.** `dispatch A { ... }` made a fresh
record for the queue, and Ludic frees nothing, so a system dispatching every frame (an input's
`Move`, a frame's time) grew the program by a record a frame. The queue now keeps a list per
action: a dispatch takes the next one (making one only when all are queued), fills every field
as `new` would - given, or its default - and `drain_actions()` hands them all back once the queue
is empty. A reducer reads its action only during the drain, so nothing sees a record after it is
reused; keep what must last in the state, not the action. `ludic.base`'s `actions_test` holds a
reused record getting its defaults back.

View file

@ -1,8 +0,0 @@
bump: minor
type: feat
**A name is defined once, for every kind of declaration.** Two functions with one name were
already an error; two `var`s or `const`s (or an enum and a const), or two `property` / `event`
records, kept the first definition silently. A game lost months to it: two files both said
`KEY_LEFT`, one meaning an arrow key's code and one a binding slot, and the menus read the slot.
They are now an error that names both files and lines. `examples/rejected/` holds the two cases,
checked by a new `reject_case` in the test runner (an example the compiler must refuse).

View file

@ -1,9 +0,0 @@
bump: minor
type: feature
**The built-in ECS grows.** Every component was a fixed array of 1024 slots, so a game past 1024
entities could not have them (and until the last release silently corrupted memory trying). The
per-entity stores are heap blocks now, doubled by `L_grow` as entities outgrow them, the new slots
zero: 100 000 entities spawn and query. `Prop.has` bounds against the live capacity and
`Pool.capacity` answers it. A snapshot (`save`/`load`, `world_save`/`world_load`) records its slot
count first and a load grows to it before reading the stores back, so a snapshot's size follows the
world's instead of a fixed 1024. A mod's registered components grow with the rest.

View file

@ -1,31 +0,0 @@
bump: minor
type: feat
**A schema for editors, and every error as JSON.** `ludicc --emit-schema out.json` (and `ludic
schema [file] [-o FILE]`) writes what the compiler resolved once the program type-checks: every
record with its fields' types, defaults, doc comments and places; every registry with its record,
prefix, resource file, openness and its entries in their final order after the open-registry merge
(key, constant, index, file:line:col of the entry and of each field value, and which file brought
which entries in); every const; and every function a `fn` value can name, with the `fn_type` a field sees (its states stripped).
Deterministic, `"schema_version": 1`. Fields and registries carry editor attributes on the existing
`@` syntax - `@Ref(Registry)`, `@OneOf(PREFIX_)`, `@Range(lo, hi)`, `@Unit("m/s")`, `@Asset("gltf")`,
`@Color`, `@Node(field)`, `@Clip(field)`, `@Material(field)`, `@Tint(SLOT)`, `@Derived`, `@Text`, `@Multiline`, `@Key`, and `@AppendOnly` / `@ByKey` on
a registry - which change nothing but go into the schema; `@Ref` naming no registry is an error, and
so is `@Node` / `@Clip` naming a field that is not a glTF (`@Asset("gltf")`, or an `@Ref` to one), and
a listing `@OneOf` (`@OneOf(A, B)`, not a prefix `@OneOf(P_)`) naming a constant that does not exist, and `@Tint` naming no constant. A field may now carry several attributes. `ludicc --check
--diagnostics=json` (`ludic build --check --diagnostics=json`) prints every error as one JSON array
of `{file, line, col, severity, message}` on stdout; tokens and nodes now know their column.
`Build.schema_hash()` answers FNV-1a 64 of the program's own schema (the bytes `ludic schema` prints),
computed only when a program names it, and 0 under `ludicc --release`, which `ludic bundle` now passes.
A target no part of the program declares (an `@Ref` registry, an `@Tint` or listed `@OneOf` constant) is
a warning and `"unresolved": true` in the schema, so a package can name the game's registry; a name of
another kind is an error. `@OneOf` on a string field takes words, and every registry row's value is
checked against them.
The schema has a `components` list: each UI component's module, place, doc, template and stylesheet
paths, its `props` and `state` (type, default as written, place, doc), `states_read` (the states its
header names, apart from its model), `derived` fields with their types, and the `functions` and
`events` its template calls with their parameters (states and instance stripped), and the
registered native tags its template uses. A `natives` list gives every `ui_native` /
`ui_native_input` call with a literal tag: the tag, the function called, its handler and its place.

View file

@ -1,6 +0,0 @@
bump: patch
type: fix
**A function named like an engine namespace method's target is refused where that method is
called.** `Random.range` is `rng_range`, so a package's own `rng_range(a, b, c)` silently took
every `Random.range(1, 6)` (and the checker then asked for its third argument). It is now an error
naming the function, the namespace method and the call.

View file

@ -1,5 +0,0 @@
bump: patch
type: fix
**`expect_eq` on strings compares their text.** It lowered to an integer compare of two pointers,
which the IR refused; now two strings with the same text are equal (a null only to a null), and a
failure prints both: `expect_eq failed (got "camp", want "lake")`.

View file

@ -1,5 +0,0 @@
bump: patch
type: fix
**`expect_eq` and `expect_near` take floats.** On a float or a double they compared with an integer
instruction, and the build failed in clang ("defined with type 'float' but expected 'i32'"); they
compare as floats now (the wider kind of the two) and a failure prints the numbers.

View file

@ -1,12 +0,0 @@
bump: minor
type: feat
**`ludic fmt` for editors.** `ludic fmt --lint --json` prints the violations `--lint` reports as one JSON
array on stdout, `[{"file", "line", "col", "rule", "message"}]` ordered by file, line and column (the
summary on stderr, `--lint`'s exit status, and the baseline never rewritten). `ludic fmt -` formats
stdin to stdout under the project found from the working directory (the nearest `package.ludic`
upwards), and refuses a buffer that does not read as Ludic - an open string, a bracket never closed or
closed by the wrong one - with exit 2 and `<name>:<line>:<col>: error: ...` on stderr.
`--stdin-name <path>` makes the buffer that file: the project is found from its directory, and
`ludic fmt - --lint --json --stdin-name <path>` judges it against that file's baseline and `lint
paths`, reporting it under the name given. Hooks around `fmt` and `get` read nothing from stdin and
write to stderr when the command's stdout is a program's (`--json`, `-`).

View file

@ -1,5 +0,0 @@
bump: minor
type: feature
**`friend module lab of fishing, data` - a friend of some modules, not all.** A scoped friend sees
the private names of the modules it names and only the exports of every other; `friend module lab`
alone still sees everything.

View file

@ -1,9 +0,0 @@
bump: minor
type: feat
**Functions are values (L2).** `fn(int, float) -> bool` is a type, `fn name` is any top-level
function's value (it used to be only a thread worker's address), and a call through a local, a
global, a record field, a slice element, a parameter or a result of a function type is an
indirect call. Two different function types do not mix, a call through one checks its argument
count, and a value may be `null`. A registry can hold behaviour and a package can take
callbacks. `Job.parallel_for` still checks that its worker takes (int, pointer) and returns
nothing. `ludic-dev selfhost-build` now says why it failed instead of exiting 1 silently.

View file

@ -1,5 +0,0 @@
bump: patch
type: feature
**The blades' density window can be filled without a GPU.** grass_density.ludic's gb_frame is split: gb_window_fill
fills the GB_TILES x GB_TILES window of density tiles round the camera's (zeros off the map) and sets its corner,
and gb_frame sends it. The same bytes as before; a test reads gb_win after gb_window_fill.

View file

@ -1,8 +0,0 @@
bump: minor
type: feature
**Generic records and functions.** `property Pool<T> { items: []T }`, `function first<T>(xs: []T)
-> T` and `function map<T, U>(xs: []T, f: fn(T) -> U) -> []U`; a type writes an instance as
`Pool<Thing>`, nested as deep as needed. A call's type arguments come from its arguments, or from
the declared type its result is written into, and are refused with the parameter named when
neither says. Each instance is compiled once as an ordinary record or function. `ludic-fmt` keeps
`Pool<Thing>` together while still spacing `a < b`.

View file

@ -1,13 +0,0 @@
bump: minor
type: feat
**`ludic.render3d`: painted ground layers grow solid things, with ids, and the trample is data.**
`ground_fill`'s candidate is its own function, `ground_candidate` (pure `gf_*` steps with the density read
between them, into a caller-held `GroundCand`), and `ground_fill` draws exactly its answers - the same
operations in the same order as before, so every cover layer grows bit for bit what it did. A layer with
`solid: true` is filled at `step0` with band 0's hashes whatever the camera, a far band drawing a stable
subset; each thing has an int id from (layer, chunk, cell) (`ground_solid_id`), and `ground_solid_list` /
`ground_solid_at` answer a chunk's things or one by id for physics, the nav bake and saves.
`r3d_ground_clearing(x, z, r_in, r_out, floor)` hands the trample over as discs the editor can see, beside
the `r3d_on_ground_trample` callback, which still works. `tests/ground_fill_test.ludic` holds all of it to
`tests/ground_fill_golden.json` (written by `tests/gen/ground_fill_golden.ludic`), the file the studio's
TypeScript generator is tested against.

View file

@ -1,6 +0,0 @@
bump: patch
type: fix
**`Http.text` and `Http.header` return copies.** They handed back the handle's own buffer (and on macOS the
response object's string), which `Http.free` then released: a text read before the free and used after it
was garbage or empty - maroon-lake's map list wrote a 0-byte maps.json. Each call now returns a string that
is the caller's to keep. Read a body once per response.

View file

@ -1,8 +0,0 @@
bump: minor
type: feature
**English left is an error (phase 26.9).** Under a `lang` line, a template's own words, a text
attribute's, a quoted choice that reads as words and a `@Text` row still holding English now refuse
the build, where they were warnings; `ludic deps` still counts them as `english_left`. Hole counts and
undescribed splits stay warnings. ludic.ui's own words - the key field's "Right click", "Middle
click", "Left click" and "press a key..." - are keys (`ui_tk(ui_st, k"ui.right_click", plain)`,
`ui.*` in the program's `.po`), with their plain English for a program that binds no translator.

View file

@ -1,9 +0,0 @@
bump: patch
type: fix
**Text keys below a row, padding, and `tr`'s cast.** A `@Text Key` in a record nested in a registry
row (and in each item of a list of them) is filled with its derived key, `<registry>.<row>.<field>.<i>.<field>`,
as a top-level one is, and a `@Text []Key` a row leaves out takes `<...>.0`, `.1`, ... for as many as
the source `.po` has - so no `.lres` spells a key. `field: null` is no text. `trf` / `trn`'s trailing
`""` arguments are padding and not counted against the English's holes. And `string(x)` of a string or
a `Key` is no allocation to the escape analysis: it is `x` itself, so a `tr(key)` that returns it
passes `arena strict` (a template's lone hole still copies).

View file

@ -1,9 +0,0 @@
bump: minor
type: change
**ludic.i18n draws plain text as it is: the English path is gone (phase 26.9).** `L` makes a key, a
key glued into text, or a line bracketed inside another; anything else - a player's name, a chat
line, a number - is drawn as it is, in every language, so a player named "Settings" stays
"Settings". Removed with it: the lookup of English words (exact lines, patterns with holes, a
paragraph a sentence at a time, padding), `Ln` (use `trn(kn"...")`), `i18n_pattern_count`, and an
English argument's own lookup inside a key's hole. A language `.po` is read for its keys and
plurals only. A game still on English msgids draws them untranslated until they are keys.

View file

@ -1,10 +0,0 @@
bump: minor
type: feature
**`ludic.i18n`: keys (phase 26).** A key names what a text is for, and `en.po` says it in English like any
other language. A key is a string with a marker byte (`I18N_KEY`, `I18N_PLURAL`), its arguments after
byte 31, so the code that makes text never takes `I18nState`: `tr(k)`, `trf(k, a, b, c, d)` and
`trn(k, n, a, b, c)` build it, and `L` makes it into text where it is drawn - the language in use, else
en.po (read the first time a key is asked for), else the key itself, `[[key]]` in a developer's build
(`i18n_loud`). Holes take their arguments in the language's order, a key argument made first; plural
keys go by each language's rule. A string with no marker takes the old English path, so a game can
move over a file at a time.

Some files were not shown because too many files have changed in this diff Show more