Compare commits

..

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

2367 changed files with 84830 additions and 806747 deletions

View file

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

View file

@ -24,7 +24,7 @@ labels:
## Environment
- Command used (e.g. `bin/ludic build foo.ludic --headless`):
- Command used (e.g. `bin/x app foo.ludic --headless`):
- Target (native macOS / headless / web-wasm):
- Commit (`git rev-parse --short HEAD`):
- OS / arch:

View file

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

View file

@ -25,17 +25,14 @@ jobs:
clang-16 --version | head -1
- name: Check out the triggering commit
env:
# the repository that triggered the run, so a fork or a mirror tests itself
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
set -eu
git config --global --add safe.directory '*'
git clone "$REPO_URL" .
git clone https://git.workshopsoft.io/workshopsoft/ludic.git .
git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}"
git log --oneline -1
# See ci.yml for why the Linux build injects the stdio shim via LUDIC_CC.
echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll -lm" >> "$GITHUB_ENV"
echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV"
echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV"
- name: Bootstrap x from the seed
@ -43,11 +40,11 @@ jobs:
set -eu
mkdir -p bin
clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
bin/ludicc tools/x/main.ludic -o bin/x
- name: Rebuild the compiler from the seed and assert byte-identity
# `ludic-dev bootstrap-cfree` assembles the seed with clang, has that seed
# `x bootstrap-cfree` assembles the seed with clang, has that seed
# compiler recompile selfhost.ludic to out.ll, and `cmp`s out.ll against
# the checked-in seed. It returns non-zero if they differ — i.e. if the
# seed is stale relative to the compiler source.
run: bin/ludic-dev bootstrap-cfree
run: bin/x bootstrap-cfree

View file

@ -2,7 +2,7 @@ name: ci
# Build the language toolchain from its IR seed and run the regression suites on
# every push to main and every pull request. Until this landed the only workflow
# was docs.yml, so nothing gated a change on `ludic-dev test` / `ludic-dev test-tools` or on the
# was docs.yml, so nothing gated a change on `x test` / `x test-tools` or on the
# compiler even building from the seed. See also bootstrap.yml, which proves the
# C-free self-rebuild reproduces the seed byte-for-byte.
on:
@ -33,22 +33,19 @@ jobs:
clang-16 --version | head -1
- name: Check out the triggering commit
env:
# the repository that triggered the run, so a fork or a mirror tests itself
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
set -eu
git config --global --add safe.directory '*'
git clone "$REPO_URL" .
git clone https://git.workshopsoft.io/workshopsoft/ludic.git .
git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}"
git log --oneline -1
# The toolchain is macOS-first; on this Linux runner it links against a
# tiny C-free IR shim that supplies the Darwin standard-stream globals
# (__stdoutp/__stderrp) over glibc's stdout/stderr. Injected through
# LUDIC_CC so every clang invocation — the seed bootstrap, `ludic-dev build`,
# LUDIC_CC so every clang invocation — the seed bootstrap, `x build`,
# and each compiled test program — picks it up. Absolute path so it
# still resolves if a step changes directory.
echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll -lm" >> "$GITHUB_ENV"
echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV"
echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV"
- name: Bootstrap the toolchain from the IR seed (clang only)
@ -60,20 +57,20 @@ jobs:
# pre-built binaries: the language builds itself from source + seed.
mkdir -p bin
clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
bin/ludic-dev build
bin/ludicc tools/x/main.ludic -o bin/x
bin/x build
- name: Regression suite (ludic-dev test)
run: bin/ludic-dev test
- name: Regression suite (x test)
run: bin/x test
- name: Editor-toolchain suite (ludic-dev test-tools)
- name: Editor-toolchain suite (x test-tools)
# Grammar/lexer/vocabulary sync, ludic-fmt idempotence (the project's
# formatting contract — hand alignment is deliberately preserved, so the
# gate is fmt(fmt(x)) == fmt(x), not fmt(x) == x), and the JSON/XML editor
# assets. Cross-file LSP behaviour and the golden renders are macOS-ABI
# bound and skip here — visibly — until the runtime's directory walk and
# windowing are portable.
run: bin/ludic-dev test-tools
run: bin/x test-tools
- name: Docs cover the implementation
run: |
@ -81,9 +78,9 @@ jobs:
# The whole docs toolchain is written in Ludic and runs through x —
# no Python anywhere. check-impl / check-vocabulary / check-docs guard
# the sources; docs-gen builds the site and docs-check is its coverage
# + integrity guard. (check-vocabulary also runs in `ludic-dev test-tools`.)
bin/ludic-dev check-impl
bin/ludic-dev check-vocabulary
bin/ludic-dev check-docs
bin/ludic-dev docs-gen --out build/pages
bin/ludic-dev docs-check build/pages
# + integrity guard. (check-vocabulary also runs in `x test-tools`.)
bin/x check-impl
bin/x check-vocabulary
bin/x check-docs
bin/x docs-gen --out build/pages
bin/x docs-check build/pages

View file

@ -17,12 +17,13 @@ jobs:
steps:
- name: Check out with history
env:
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
BEFORE: ${{ github.event.before }}
BASE: ${{ github.base_ref }}
run: |
set -eu
git config --global --add safe.directory '*'
# Full clone so both endpoints of the range are present.
git clone "$REPO_URL" .
git clone https://git.workshopsoft.io/workshopsoft/ludic.git .
git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}"
- name: Lint the new commits
@ -35,15 +36,10 @@ jobs:
# - pull_request: base branch .. this commit
# - push: the pushed range (event.before .. this commit)
# - new branch / unknown: just the tip commit
# `event.before` is only usable if it still resolves: a force-push
# rewrites (and a gc can remove) the commit it names, which made this
# job fail with "Invalid revision range" on an otherwise clean push.
# Fall back to the tip commit in that case.
if [ -n "${BASE:-}" ]; then
git fetch --quiet origin "${BASE}" 2>/dev/null || true
RANGE="origin/${BASE}..${GITHUB_SHA}"
elif [ -n "${BEFORE:-}" ] && ! printf '%s' "$BEFORE" | grep -qE '^0+$' \
&& git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
elif [ -n "${BEFORE:-}" ] && ! printf '%s' "$BEFORE" | grep -qE '^0+$'; then
RANGE="${BEFORE}..${GITHUB_SHA}"
else
RANGE="${GITHUB_SHA}~1..${GITHUB_SHA}"

View file

@ -10,22 +10,10 @@ on:
paths:
- 'docs/**'
- 'tools/docgen/**'
- 'tools/ludic-cli/**'
# the site publishes the installer, so a change to it has to redeploy the
# site — otherwise a fixed install.sh sits in main while the old one is
# still what `curl … | sh` fetches
- 'install.sh'
- 'tools/x/**'
- '.forgejo/workflows/docs.yml'
workflow_dispatch: {}
# Deploying is a force-push of an orphan branch, so two runs racing can land out
# of order and leave `pages` holding the older build — the site would silently
# go backwards with both runs green. Serialise them, and let a newer push cancel
# an older one that is still building rather than queue behind it.
concurrency:
group: pages-deploy
cancel-in-progress: true
permissions:
contents: write
@ -37,8 +25,8 @@ jobs:
# "Waiting" forever with "no online runner found matching this label".
runs-on: docker
# The generator is now Ludic, so this builds the toolchain from its IR seed
# (clang assembles the seed into bin/ludicc, which compiles bin/ludic) exactly
# like the ci workflow, then runs `ludic-dev docs-gen`. node:20-bookworm carries git
# (clang assembles the seed into bin/ludicc, which compiles bin/x) exactly
# like the ci workflow, then runs `x docs-gen`. node:20-bookworm carries git
# for the clone + publish; clang-16 is the only extra the bootstrap needs.
container: node:20-bookworm
steps:
@ -53,23 +41,23 @@ jobs:
- name: Generate the documentation site
env:
SOURCE_REF: ${{ github.ref_name }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
set -eu
git config --global --add safe.directory '*'
git clone --depth 1 --branch "${SOURCE_REF:-main}" "$REPO_URL" src
git clone --depth 1 --branch "${SOURCE_REF:-main}" \
https://git.workshopsoft.io/workshopsoft/ludic.git src
cd src
# The toolchain is macOS-first; on this Linux runner it links against a
# tiny C-free IR shim supplying the Darwin stdout/stderr globals over
# glibc's, injected through LUDIC_CC. docs-gen is a pure CLI (no
# windowing), so the C-free bootstrap is all it needs.
export LUDIC_CC="clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll -lm"
export LUDIC_CC="clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll"
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
bin/ludicc tools/x/main.ludic -o bin/x
bin/x docs-gen --out ../public
bin/x docs-check ../public
cd ..
echo "--- generated files ---"
ls -la public
@ -79,8 +67,6 @@ jobs:
PAGES_TOKEN: ${{ secrets.PAGES_TOKEN }}
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOURCE_SHA: ${{ github.sha }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
run: |
set -eu
TOKEN="${PAGES_TOKEN:-${AUTO_TOKEN:-}}"
@ -94,6 +80,5 @@ jobs:
git config user.email "docs@workshopsoft.io"
git add -A
git commit -q -m "docs: regenerate site from ${SOURCE_SHA}"
# the same server and repository the run came from, with the token spliced in
git push -f "${SERVER_URL%%://*}://ludic-docs-bot:${TOKEN}@${SERVER_URL#*://}/${REPO}.git" pages
git push -f "https://ludic-docs-bot:${TOKEN}@git.workshopsoft.io/workshopsoft/ludic.git" pages
echo "published $(git rev-parse --short HEAD) to pages"

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

33
.gitignore vendored
View file

@ -1,6 +1,6 @@
# Generated build tree: LLVM IR, objects, compiled apps, the headless render
# (build/out.ppm) and the docs site all land under build/ (see `bin/ludic-dev build` /
# `bin/ludic clean`). Root-anchored so a source dir named "build" elsewhere is never
# (build/out.ppm) and the docs site all land under build/ (see `bin/x build` /
# `bin/x clean`). Root-anchored so a source dir named "build" elsewhere is never
# accidentally ignored. Nothing is written to the repo root any more.
/build/
@ -9,14 +9,14 @@
# packaged plugin .zip are local-only build inputs/outputs.
*.zip
# the toolchain binaries (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp) — all built
# into bin/ by the one-line bootstrap + `bin/ludic-dev build`; never checked in. The
# the toolchain binaries (ludicc, ludic, x, ludic-fmt, ludic-lsp) — all built
# into bin/ by the one-line bootstrap + `bin/x build`; never checked in. The
# only thing published is the source and the LLVM-IR seed (selfhost/ludicc.seed.ll).
/bin/
# package manager (issue #63): the per-project linked view into the global
# content-addressed store, and the optional hermetic copy from `ludic vendor`. Both
# are regenerated by `ludic get` / `ludic vendor` — package.ludic + package.lock.ludic
# content-addressed store, and the optional hermetic copy from `x vendor`. Both
# are regenerated by `x get` / `x vendor` — package.ludic + package.lock.ludic
# are the tracked source of truth, so these stay out of the tree.
ludic_modules/
vendor/
@ -26,7 +26,6 @@ tools/editors/vscode/node_modules/
tools/editors/vscode/*.vsix
tools/editors/jetbrains/.gradle/
tools/editors/jetbrains/build/
tools/editors/jetbrains/.kotlin/
# IntelliJ plugin SDK sandbox (tools/editors/jetbrains)
.intellijPlatform/
@ -40,23 +39,5 @@ tools/editors/jetbrains/.kotlin/
__pycache__/
*.pyc
# Release artifacts produced by `ludic-dev release`
# Release artifacts produced by `x 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/

File diff suppressed because it is too large Load diff

View file

@ -1,46 +1,37 @@
# Compiling Ludic
> **Note:** `ludicc` is **written in Ludic** (`selfhost/*.ludic`) and built from a
> checked-in IR seed — the C compiler this document once described has been
> deleted. The native pipeline below (Ludic → LLVM IR → object → binary) is
> unchanged. `ludicc` drives clang itself (via an `os_system` intrinsic), so
> `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly. `--fmt` is
> reimplemented as a lex+parse gate (the doc-check hook). The `--target`/
> cross-compile and `--shared` paths are still features of the old C driver not
> yet re-implemented on the self-hosted toolchain. See the
> [Bootstrap deep-dive](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Bootstrap) §5.7 on the wiki.
> **Note (2026-08-27):** `ludicc` is now **written in Ludic** (`selfhost/*.ludic`)
> and built from a checked-in IR seed — the C compiler this document describes has
> been deleted. The native pipeline below (Ludic → LLVM IR → object → binary) is
> unchanged. `ludicc` now drives clang itself (via an `os_system` intrinsic), so
> `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly, and a sibling
> command `ludic app.ludic` compiles to a temporary binary and runs it in one
> step. The whole toolchain is built by `bin/x build`; `bin/x app` remains as a
> convenience wrapper over the compiler. `--fmt` is reimplemented as a lex+parse
> gate (the doc-check hook). The `--target`/cross-compile and `--shared` paths are
> still features of the old C driver not yet re-implemented on the self-hosted
> toolchain. See the [Bootstrap deep-dive](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Bootstrap) §5.7 on the wiki.
>
> Most people never invoke `ludicc` directly: the `ludic` CLI drives it.
> From a clean checkout, build the compiler and the task-runner in one line, then
> let `bin/x` do the rest (run it from the repository root):
>
> ```bash
> curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh # the toolchain, into ~/.ludic
> ludic new mygame && cd mygame
> ludic run # compile + run
> ludic build --headless # compile, deterministic render
> # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/x
> clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x
> bin/x build # rebuild the whole toolchain into bin/
> # (ludicc, ludic, x, ludic-fmt, ludic-lsp)
> bin/ludicc examples/games/snake.ludic -o bin/snake # compile
> bin/ludic examples/games/snake.ludic # compile + run
> bin/x help # list every command
> ```
>
> From a clean checkout, the compiler and the CLI come up in two lines and the
> CLI does the rest (run it from the repository root):
>
> ```bash
> # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/ludic
> mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc
> bin/ludicc --unsafe --globals tools/ludic-cli/dev.ludic -o bin/ludic-dev
> bin/ludic-dev build # the whole toolchain into bin/
> # (ludicc, ludic, ludic-fmt, ludic-lsp)
> bin/ludicc examples/games/snake.ludic -o bin/snake # the compiler, directly
> bin/ludic build examples/games/snake.ludic # or through the CLI
> bin/ludic help # every command
> ```
>
> A `.ludic` file with handlers is a game and links windowed by default;
> `--headless` and `--windowed` force the mode. The engine runtime
> (`runtime/native/cocoa.ll`, the spliced `runtime/native/*.ludic`) and the
> bundled `ludic.*` packages are found under the **install root**: `$LUDIC_HOME`
> if set, otherwise derived from the binary's own location — the parent of its
> `bin/` directory, which is both `~/.ludic` for an install and the repository
> root for a checkout. `$LUDIC_CC` overrides the assembler/linker (default
> `clang`).
> The binaries are multi-call (one native binary under two names): invoked as
> `ludicc` it compiles, as `ludic` it compiles-and-runs. A `.ludic` file with
> systems is a game and links windowed by default; `--headless` and `--windowed`
> force the mode. The runtime (`runtime/native/cocoa.ll`) is found via
> `$LUDIC_HOME`, defaulting to the directory the binary sits in — keep them in
> `bin/`, or set `LUDIC_HOME` and put them on `PATH`. `$LUDIC_CC` overrides the
> assembler/linker (default `clang`).
`ludicc` is a compiler, not a translator. It lexes, parses, checks and lowers
@ -51,10 +42,10 @@ and find your program rewritten in another language.
```
app.ludic
│ ludicc — lex, parse, lower (selfhost/frontend/*.ludic,
▼ selfhost/backend/*.ludic)
app.ll LLVM IR: your handlers, your properties, your runtime
│ IR assembler (selfhost/main.ludic drives $LUDIC_CC)
│ ludicc — lex, parse, check, lower (compiler/ludicc.c,
▼ compiler/native.c)
app.ll LLVM IR: your systems, your properties, your runtime
│ IR assembler (compiler/driver.c)
▼
app.o Mach-O / ELF / COFF object code
│ system linker
@ -73,9 +64,6 @@ point at a different LLVM toolchain if you have one.
| a windowed native executable | `ludicc game.ludic -o build/game` |
| a headless executable | `ludicc game.ludic --headless -o build/game` |
| the IR, to read | `ludicc src.ludic --emit-llvm -o src.ll` |
| the schema an editor reads (records, registries and their entries, consts) | `ludicc src.ludic --emit-schema schema.json` |
| every error, as a JSON array on stdout | `ludicc src.ludic --check --diagnostics=json` |
| the same, with an unsaved buffer on stdin standing for one of its files | `ludicc src.ludic --check --diagnostics=json --stdin-file lib/a.ludic < buf` |
| a shared library † | `ludicc lib.ludic --shared -o build/liblib.dylib` |
| a game that runs in a browser † | `ludicc game.ludic --target wasm32-unknown-unknown -o build/web/game.wasm` |
| an object file † | `ludicc src.ludic -c -o src.o` |
@ -85,33 +73,32 @@ the old C driver and are **not yet re-implemented** on the self-hosted toolchain
(see the note at the top). The rows above the line work today via the
self-hosted `ludicc`.
`bin/ludic build` wraps the common cases:
`bin/x app` wraps the common cases:
```bash
bin/ludic build examples/games/snake.ludic # -> build/snake (native)
bin/ludic build examples/library/combat.ludic --lib # -> build/libcombat.* (library)
bin/ludic build examples/games/snake.ludic --headless # -> build/snake_headless (out.ppm)
bin/ludic build examples/games/snake.ludic --web # -> build/web/ (browser)
bin/x app examples/games/snake.ludic # -> build/snake (native)
bin/x app examples/library/combat.ludic --lib # -> build/libcombat.* (library)
bin/x app examples/games/snake.ludic --headless # -> build/snake_headless (out.ppm)
bin/x app examples/games/snake.ludic --web # -> build/web/ (browser)
```
The `--lib` and `--web` targets were part of the old C driver and are **not yet
re-implemented** on the self-hosted toolchain — `bin/ludic build` supports the native
re-implemented** on the self-hosted toolchain — `bin/x app` supports the native
windowed and `--headless` builds today.
## Programs and libraries
> **Not yet on the self-hosted toolchain.** `--shared` and the `nm`/library
> workflow below describe the old C driver's behavior; the self-hosted `ludicc`
> builds executables only for now. The `@export function` semantics are
> builds executables only for now. The `module`/`@export fn` semantics are
> unchanged — only the packaging step is pending.
A source file opens with `program Name { … }`.
A source file opens with `game Name { … }` or `module Name { … }`.
* A program with **handlers** is a game: it gets the phase-ordered frame loop
* A **game** gets an entry point and the phase-ordered frame loop
(`Start`, then `Input → FixedUpdate → Update → LateUpdate → Render` each tick).
* A program with only an **`entry`** block is a tool: it runs `entry` and exits.
* Either kind can be a library: only its `@export function`s become public
symbols; everything else stays private.
* A **module** gets neither. It is a library, and only its `@export fn`s become
public symbols; everything else stays private to the library.
```ludic
# doc-check: skip — illustrative: elided body
@ -209,11 +196,8 @@ intrinsics compile to nothing there, so a headless binary never references a
symbol the window would have provided.
Other platforms build headless today. A Win32 or X11 port is another `.ll` file
with the same entry points — the window (`win_open`, `win_poll`, `win_present`,
`win_running`, `win_close`), keys (`win_held`, `win_held_bit`), the mouse and
cursor (`win_mouse`, `win_cursor_mode`, `win_cursor_confine`,
`win_cursor_maintain`), gamepad (`win_pad`) and touch (`win_touch`) — and no
compiler change.
with the same five entry points — `win_open`, `win_poll`, `win_present`,
`win_running`, `win_close` — and no compiler change.
## The web
@ -235,7 +219,7 @@ only the triple changes.
```
```bash
bin/ludic build examples/games/chronorift.ludic --web
bin/x app examples/games/chronorift.ludic --web
python3 -m http.server -d build/web 8000 # then open http://localhost:8000/
```
@ -323,7 +307,7 @@ node tools/ludic-web/run.mjs build/web/snake_headless.wasm --stdin=ddss
```
Because Ludic is fixed-point and its RNG is seeded, the native headless binary
and the wasm one must render byte-identical frames from the same input. `bin/ludic-dev test`
and the wasm one must render byte-identical frames from the same input. `bin/x test`
asserts exactly that, which is a much stronger check on the backend than
"it started".
@ -337,13 +321,13 @@ entity allocator, save/load snapshots, the frame loop, the window, and the whole
graphics stack — framebuffer, PNG decoding, sprites, 9-slice, TrueType text and
the retained UI.
None of it goes through C. `bin/ludic-dev test` asserts that directly: no C source
None of it goes through C. `bin/x test` asserts that directly: no C source
survives in `runtime/`, no C emitter survives in `ludicc`, and the examples all
build, run and render from IR alone.
## Every flag
The self-hosted `ludicc`/`ludic` (built with `bin/ludic-dev build-cli`) accept:
The self-hosted `ludicc`/`ludic` (built with `bin/x build-cli`) accept:
```
<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
--headless force a headless build (stdin input, out.ppm output)
--emit-llvm stop at LLVM IR — write it and exit, no clang
--check every check a build makes (types, modules, uses, layers, ports, binds); write nothing
--fmt lex + parse only; exit 0 if it parses, 1 on a parse error
(the check-docs gate; canonical formatting not yet restored)
--save-temps keep the intermediate .ll
--run compile then run (what `ludic run` uses)
--run compile then run (implicit when invoked as `ludic`)
(unknown -flags are ignored with a warning, never taken as the input file)
environment:
LUDIC_CC the LLVM that assembles IR and drives the linker (clang)
LUDIC_HOME the install root — runtime/, packages/, VERSION
(default: the parent of the binary's bin/ directory)
LUDIC_MODULES the project's fetched packages (default: ./ludic_modules)
LUDIC_HOME where runtime/native/ lives (default: the binary's dir)
```
Mode is automatic when neither `--windowed` nor `--headless` is given: a program

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:
```bash
mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc --unsafe --globals tools/ludic-cli/dev.ludic -o bin/ludic-dev
clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x
```
That gives you `bin/ludic-dev`, the contributor tool: it replaces every
build/test shell script in the repo and builds everything, including itself and
`bin/ludic`. It is deliberately a separate binary from the `ludic` users install
— that one carries none of these tasks and is never asked to.
That gives you `bin/x`, the Ludic task runner that replaces every build/test
shell script in the repo. From then on it builds everything — including itself:
```bash
bin/ludic-dev build # the whole toolchain into bin/ (ludicc, ludic, ludic-dev, ludic-fmt, ludic-lsp)
bin/ludic-dev help # every contributor task
bin/ludic help # what a user of the language sees
bin/x build # rebuild the whole toolchain into bin/ (ludicc, ludic, x, ludic-fmt, ludic-lsp)
bin/x help # list every command
```
Always run `ludic-dev` from the repository root, so `assets/` and `selfhost/`
resolve. (A checkout is also an install root: `bin/` beside `runtime/` and
`packages/`, exactly the shape `install.sh` lays down under `~/.ludic`, which is
why `bin/ludic` behaves there exactly as an installed one does.)
Always run `x` from the repository root, so `assets/` and `selfhost/` resolve.
## The development loop
@ -44,18 +37,18 @@ When you change the compiler or runtime, prove the self-hosting fixpoint still
holds before you push:
```bash
bin/ludic-dev reseed # regenerate selfhost/ludicc.seed.ll after a compiler change
bin/ludic-dev bootstrap-cfree # rebuild the compiler from the seed with NO C compiler in the loop
bin/ludic-dev test # the full regression suite
bin/x reseed # regenerate selfhost/ludicc.seed.ll after a compiler change
bin/x bootstrap-cfree # rebuild the compiler from the seed with NO C compiler in the loop
bin/x test # the full regression suite
```
Other useful targets:
```bash
bin/ludic build <file.ludic> [--headless] # compile a program to a native app in build/
bin/ludic-dev selfhost-test # correctness + bootstrap fixpoints
bin/ludic-dev test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp)
bin/ludic clean # remove build/, out.ppm and stray artifacts
bin/x app <file.ludic> [--headless] # compile a program to a native app in build/
bin/x selfhost-test # correctness + bootstrap fixpoints
bin/x test-tools # the editor-toolchain suite (ludic-fmt, ludic-lsp)
bin/x clean # remove build/, out.ppm and stray artifacts
```
## Adding to the standard library
@ -68,7 +61,7 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces
and register its id in `tools/docgen/inventory.json`. Each documented
namespace gets exactly **one** directory (the docs check enforces this).
3. Add or extend an example under `examples/` and a case in the test suite.
4. Run `bin/ludic-dev docs-gen --out build/pages && bin/ludic-dev docs-check build/pages` — the
4. Run `python3 tools/docgen/gen.py && python3 tools/docgen/check.py` — the
check fails if any inventory symbol lacks a page or is still seed text.
5. Add a **changeset** for the user-facing change: a small file under
[`changes/`](changes/README.md) with a `bump:` level and a one-line summary.
@ -77,105 +70,20 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces
## Versioning & releases
The toolchain is versioned with [SemVer](https://semver.org); `VERSION` is the
single source of truth and `ludicc --version` (or `ludic version`) reports it.
single source of truth and `ludicc --version` (or `x version`) reports it.
Releases are changeset-driven. Every user-facing change ships with a changeset
(step 5 above). Read the next release before cutting it:
(step 5 above). To cut a release:
```bash
ludic-dev release --dry-run # render the CHANGELOG section, write nothing
x release [major|minor|patch] # omit the level to derive it from the changesets
```
Then cut it:
```bash
ludic-dev release [major|minor|patch] # omit the level to derive it from the changesets
git push origin main --follow-tags
```
`ludic-dev release` aggregates the pending changesets into a new `CHANGELOG.md` section
— grouped by change type, with each changeset's markdown kept intact — bumps
`VERSION`, commits `chore(release): vX.Y.Z`, and tags it.
**Pushing the tag is what publishes.** The `release` workflow builds the
toolchain from the IR seed, runs `ludic-dev test`, `ludic-dev test-tools` and `ludic-dev bootstrap-cfree`
against the tagged tree, and only then creates the Forgejo release — with the
source tarball, a Linux toolchain build, a `.sha256` beside each, and that version's
`CHANGELOG.md` section as the notes. It refuses to publish if the tag and
`VERSION` disagree or the changelog has no section for it.
Each toolchain artifact is a complete install root — `bin/` beside `runtime/`,
`packages/` and `VERSION` — which is exactly what `install.sh` unpacks into
`~/.ludic`. A release with no artifact for a platform is not a broken install
there: the installer falls back to bootstrapping from the source tarball's IR
seed. But the macOS artifacts are the ones most people get, so attach them.
macOS artifacts cannot be produced on the Linux runner — a `darwin-arm64` build
needs a macOS host, and there is no cross-compile path (it would need the Xcode
SDK and a Mach-O linker). Attaching one therefore means either registering a
macOS runner and giving it a job, or running the same command CI runs from a
Mac. Either way it is `ludic-dev publish`, which only adds assets the release is missing:
```bash
FORGEJO_TOKEN=… ludic-dev publish v0.4.0
```
Checksums are one `.sha256` file per artifact rather than a single `SHA256SUMS`,
precisely because a release can be assembled from more than one host and an
asset that already exists is never overwritten. Verify one with:
```bash
shasum -a 256 -c ludic-0.4.0-src.tar.gz.sha256
```
The tag doubles as the reproducible bootstrap point: the source archive plus its
checked-in seed rebuild that exact toolchain.
## Where the name and the URLs live
The language may yet be renamed and the project may yet move hosts, so the
things that carry a name are kept few and listed here rather than discovered one
broken link at a time. Everything host-shaped has an environment override, so a
move can be rehearsed before it is committed.
**Hosts and URLs.** The install one-liner is served from the documentation site,
which publishes `install.sh` beside the pages that quote it (`ludic-dev docs-gen`
copies it in; `docs-check` fails without it). Change the host in:
| Where | What |
|---|---|
| `install.sh` | `REPO_API`, `REPO_URL`, `INSTALL_URL` — each `${LUDIC_…:-default}`, so `LUDIC_REPO_URL=… sh install.sh` tests a move without editing anything |
| `tools/ludic-cli/project.ludic` | `install_url()` (`$LUDIC_INSTALL_URL`), used by `ludic upgrade` and `ludic doctor` |
| `tools/ludic-cli/forgejo.ludic` | `FORGEJO_API_DEFAULT` (`$LUDIC_FORGEJO_API`), used by `ludic-dev publish` |
| `docs/site/site.json` | `repo_url`, the `start.terminal` one-liner, and the doc links in `nav_links` |
| Prose | `README.md`, `COMPILING.md`, `tools/editors/README.md`, and the two editor plugins' "server not found" messages |
**The name itself.** A rename touches, in rough order of blast radius:
- **The file extension** `.ludic` — the compiler (`strip_ludic`, `do_import`,
`is_ludic_file`), every editor asset (`tools/editors/shared/*.json`,
`vscode/package.json`, the JetBrains `LudicFileType`), and every source file
in the tree.
- **The binaries** `ludic`, `ludicc`, `ludic-dev`, `ludic-fmt`, `ludic-lsp` —
`cmd_dev_build` in `toolchain.ludic`, the release staging in `release.ludic`,
`install.sh`, the editors' executable-name lists. Only the first, third and
fourth of those ship: `ludic-dev` is built from a checkout and stays there.
- **The install root** `~/.ludic` and the source directories `tools/ludic-cli/`,
`tools/ludic-tools/`, `packages/ludic.*`.
- **The environment variables** `LUDIC_HOME`, `LUDIC_CC`, `LUDIC_MODULES`,
`LUDIC_STORE`, `LUDIC_PKG_PROXY`, `LUDIC_INSTALL_URL`, `LUDIC_KEEP_TMP`,
`LUDIC_COVERAGE` — keep the old names working for a release if anyone has them
in a script.
- **Identifiers that are contracts with other software**: the TextMate scope
`source.ludic`, the VS Code language id `ludic`, the JetBrains plugin id
`io.ludic.ide`, and the `ludic` code-fence tag understood by the Markdown
injection and by `ludic-dev check-docs`.
- **The prose**: `README.md`, `LANGUAGE.md`, `COMPILING.md`, `docs/**`, and
`docs/site/site.json`'s `brand`/`meta`.
`ludic-dev test` is the safety net for the mechanical part — it builds the
toolchain, stages an install, and runs `new` → `build` → `test` through it, so a
half-finished rename fails there rather than in someone's terminal.
That aggregates the pending changesets into a new `CHANGELOG.md` section, bumps
`VERSION`, commits `chore(release): vX.Y.Z`, and tags it. Add `--publish` (with
`FORGEJO_TOKEN` set) to also push and create the Forgejo release with source and
toolchain tarballs. The tag doubles as the reproducible bootstrap point: the
source archive plus its checked-in seed rebuild that exact toolchain.
## Conventions
@ -191,7 +99,7 @@ half-finished rename fails there rather than in someone's terminal.
| `perf` | a performance improvement |
| `docs` | documentation only (`docs/`, README, comments) |
| `test` | tests only |
| `build` | the build/bootstrap machinery (seed, `bin/ludic`, linking) |
| `build` | the build/bootstrap machinery (seed, `bin/x`, linking) |
| `ci` | CI workflows under `.forgejo/` |
| `style` | formatting/whitespace, no behaviour change |
| `chore` | routine housekeeping with no other bucket |
@ -213,7 +121,7 @@ half-finished rename fails there rather than in someone's terminal.
so a green local commit is a green CI run.
- **Formatting:** `ludic-fmt` is the source of truth (2-space indent, LF, UTF-8);
the repo `.editorconfig` mirrors it. Run `bin/ludic fmt` on files you touch.
the repo `.editorconfig` mirrors it. Run `bin/ludic-fmt -w` on files you touch.
The contract CI enforces is *idempotence* — `ludic-fmt` re-run on its own output
is a no-op — which leaves deliberate hand alignment in place; it is not a
blanket `fmt(x) == x`.
@ -241,44 +149,11 @@ non-destructive version of "tidy the history" without touching a single commit.
## Pull requests
- Base your branch on `main`.
- Ensure `bin/ludic-dev test` (and `bin/ludic-dev bootstrap-cfree` for compiler/runtime changes)
- Ensure `bin/x test` (and `bin/x bootstrap-cfree` for compiler/runtime changes)
pass, and that `ludic-fmt` leaves your files unchanged.
- Fill in the PR template checklist. Reference the issue you close with
`Closes #NN` in the description or a commit message.
## CI (self-hosted runners)
Every workflow starts by cloning `${{ github.server_url }}/${{ github.repository }}`.
On a self-hosted Forgejo runner that URL is usually the instance's *internal*
address (e.g. `http://forgejo:3000`), so **the job container must be able to
resolve it**. The runner puts each job on a fresh per-job network by default,
which the Forgejo container is not attached to — so the clone fails with:
```
fatal: unable to access 'http://forgejo:3000/…': Could not resolve host: forgejo
```
Give the runner a config that pins job containers to a network Forgejo is also
on. A dedicated network is better than the general application network, so a CI
job cannot reach unrelated services:
```yaml
# the runner's config.yml, passed with: forgejo-runner daemon --config …
container:
network: forgejo-ci
```
with `forgejo-ci` attached to the Forgejo container as well. Verify it without
running a workflow:
```bash
docker run --rm --network forgejo-ci alpine:3 getent hosts forgejo
```
This failure mode is intermittent if left unfixed: Docker forwards names it
cannot resolve to the host's resolver, which may answer for the container name
often enough that CI looks healthy for a while.
## Reporting issues
Use the templates under [`.forgejo/issue_template/`](.forgejo/issue_template):

File diff suppressed because it is too large Load diff

294
README.md
View file

@ -1,200 +1,188 @@
# Ludic
A compiled language for 2D games. The entity-component system is part of the
syntax, the runtime is deterministic fixed-point, and `ludicc` lowers Ludic
straight to LLVM IR — **no C is generated, compiled or linked in a build.**
Ludic is an **ahead-of-time compiled** language for 2D games with an
entity-component core, a deterministic fixed-point runtime, and its graphics
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
that on every push.
- **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
}
}
```
.ludic ──► ludicc ──► LLVM IR ──► object ──► native binary
(in Ludic)
```
## 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
formatter and the language server — with one command:
## Backends
| 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
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
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:
Then build the whole toolchain and run a game:
```bash
ludic new mygame
cd mygame
ludic run # compiles src/main.ludic and opens a native window
bin/x build # -> bin/{ludicc,ludic,x,ludic-fmt,ludic-lsp}
bin/x app examples/games/snake.ludic # compile + open a native window
./build/snake
```
`ludic new` writes a manifest, a program that already moves something on screen,
and a test. `ludic build` stops at the binary; `ludic bundle` goes on to the
thing you can actually give someone. Rendering is deterministic, so a frame can
be produced without a window, which is what CI diffs:
Render a frame headlessly (what CI checks) — output lands in `build/`, never the
repo root:
```bash
ludic test
ludic build --headless
printf 'ddddwww' | ./build/mygame_headless # writes build/out.ppm
bin/x app examples/games/chronorift.ludic --headless
mkdir -p build && printf 'ddddwww' | ./build/chronorift_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.
[`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:
Run the suites:
```bash
mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
bin/ludic-dev build # -> bin/{ludicc,ludic,ludic-dev,ludic-fmt,ludic-lsp}
bin/ludic-dev test # the regression suite
bin/x test # full regression: compiler builds from seed, every example, golden renders
bin/x selfhost-test # correctness + the self-hosting / C-free bootstrap fixpoints
bin/x help # every command
```
## The language
- **ECS in the syntax.** `property`, `model` and `handler` are keywords. Query
with `for (a, b) in query [A, B, {Tag}] where <expr> { … }`; `spawn` and
`despawn` recycle entity slots; `@`-annotations drive lifecycle hooks.
- **Deterministic by construction.** Q16.16 `fixed` arithmetic and a seeded RNG
give the same frame byte-for-byte on every run — the basis for replays,
lockstep netcode and golden-image tests.
- **Scenes and state machines.** `scene` / `layer` / `become` model
mutually-exclusive game states with enter and exit hooks; `match` / `machine`
/ `state` handle dispatch and per-entity FSMs.
- **Events and networking.** A cancellable event bus (`event` / `emit` / `@On`)
and networking primitives (`@Sync`, ownership, RPCs) over a built-in transport.
- **Batteries in the language.** Framebuffer primitives, PNG sprites, TrueType
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
[API reference](https://workshopsoft.pages.workshopsoft.io/ludic/api.html)
documents every symbol on its own page.
## Shipping
A built binary is a program, not an application: it opens its assets by a path
relative to the working directory, so it runs from the project root and nowhere
else, and it wears the generic executable icon.
Add a dependency (URL-as-identity, MVS resolution, content-addressed store):
```bash
ludic pack # every asset the game opens, into one .lpak
ludic bundle # ...and that, the binary, an icon and the metadata, as a .app
bin/x add git.workshopsoft.io/user/pkg # resolve + fetch + link into ludic_modules/
bin/x get # install everything in package.ludic, write the lock
bin/x verify # check the locked packages against the store
```
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.
See [`docs/PACKAGES.md`](docs/PACKAGES.md) for the manifest, lockfile and package model.
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).
## Layout
## Packages
| Path | What it is |
|------|-----------|
| [`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. |
| [`selfhost/golden/renders.sha256`](selfhost/golden/renders.sha256) | text baseline of render-output hashes (replaces binary `.ppm` fixtures); regenerate with `bin/x golden`. |
| [`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. |
| [`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. |
| [`runtime/web/`](runtime/web/) | the browser platform layer: `platform.js` (the `<canvas>` window), `wasm.ll` (the libc-free floor), `index.html`. |
| [`examples/`](examples/README.md) | the example tour, grouped by intent — `games/`, `rendering/`, `ecs/`, `events/`, `networking/`, `lang/`, `library/`. See [examples/README.md](examples/README.md). |
| [`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. |
| [`tools/editors/`](tools/editors/README.md) | plugins for VS Code and JetBrains, plus config for Neovim, Helix, Emacs, Sublime and Zed. |
| [`docs/`](docs/) | the per-symbol API reference, regenerated into the docs site. |
| [`COMPILING.md`](COMPILING.md) | the native pipeline: `ludicc → LLVM IR → exe`, the `rt_*` runtime protocol, and the (pending) wasm/cross-compile/shared-library paths. |
Dependencies are identified by URL, resolved with minimal version selection, and
cached in a content-addressed store:
The design and roadmap material lives on the **[wiki](https://git.workshopsoft.io/workshopsoft/ludic/wiki)**:
the [Events](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Events),
[Networking](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Networking),
[Scenes](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Scenes),
[Lifecycle](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Lifecycle),
[Mobile](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Mobile) and
[Syntax-redesign](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Syntax-Redesign)
design records, the [Bootstrap deep-dive](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Bootstrap),
and the [Luanti roadmap](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Roadmap/Luanti).
The root keeps only this README plus the two user-facing references,
[`LANGUAGE.md`](LANGUAGE.md) and [`COMPILING.md`](COMPILING.md).
```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
```
## Language at a glance
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.
- `program` / `property` (typed fields + defaults) / `model` (named entity kinds)
/ `system` (`phase`, `@annotations`, `reads`/`writes`).
- 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.
See [`docs/PACKAGES.md`](docs/PACKAGES.md) for the manifest and lockfile model.
See [LANGUAGE.md](LANGUAGE.md) for the full reference, and
[examples/README.md](examples/README.md) for runnable demos of each feature.
## Editor support
Editors spawn `ludic lsp`; the server ships with the toolchain, so there is
nothing extra to install. It speaks LSP 3.17 over stdio, so one binary serves
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).
```bash
bin/x tools # -> bin/ludic-fmt, bin/ludic-lsp
```
## 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
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.
## Chrono Rift — the flagship game
The **web/wasm32 backend is not currently available.** The browser platform
layer is in-tree under [`runtime/web/`](runtime/web/), but emitting wasm was a
capability of the retired C compiler and has not been re-wired on the
self-hosted toolchain. `--target` cross-compilation and `--shared` libraries are
in the same position. See [COMPILING.md](COMPILING.md).
[`examples/games/chronorift.ludic`](examples/games/chronorift.ludic) is a
playable co-op JRPG — overworld, dungeon, random encounters, a turn-based co-op
battle, a boss, an item shop and snapshot save/load — split across modules under
[`games/chronorift/`](examples/games/chronorift/). Its art is CC0
[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
and published by CI from the tag; see [CHANGELOG.md](CHANGELOG.md).
- **Overworld:** `WASD` move, `K` save, `L` load.
- **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`. Upgrading from 0.2? See the
[0.2 → 0.3 migration guide](docs/MIGRATION-0.2-to-0.3.md).
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.md](CONTRIBUTING.md) covers the development loop, the commit and
code conventions, how the bootstrap fixpoint works, and what a self-hosted CI
runner needs. Issue and pull-request templates are under
[`.forgejo/`](.forgejo/).
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development loop
(`bin/x reseed` → `bin/x bootstrap-cfree` → `bin/x test`), the code and commit
conventions, and how the bootstrap fixpoint works. Issue and pull-request
templates live under [`.forgejo/`](.forgejo/).
## License
The compiler and runtime are licensed under the
[Apache License 2.0](LICENSE) (`SPDX-License-Identifier: Apache-2.0`).
The Ludic compiler and runtime source are licensed under the
[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
third-party and released under **CC0 1.0**; each pack keeps its own
`License.txt`. Code and assets are licensed separately — Apache-2.0 covers the
source, not the art.
third-party and released under **CC0 1.0** (public domain); each pack keeps its
own `License.txt`. Code and assets are licensed separately: Apache-2.0 covers
the source, not the art.

View file

@ -1 +1 @@
0.22.0
0.3.0

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,7 +1,7 @@
# Changesets
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.
## Format
@ -14,38 +14,12 @@ the changelog. Markdown is fine.
```
- `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).
- `type:` — the Conventional Commit type (`feat`, `fix`, `perf`, `docs`, …). It
decides which group the change lands in: `feat` → **Features**, `fix` →
**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.
- `type:` — the Conventional Commit type (`feat`, `fix`, `perf`, `docs`, …); it
becomes the bold prefix of the changelog bullet.
## Adding one
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.
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.

View file

@ -1,6 +0,0 @@
bump: patch
type: fix
**A key used only in a listener counts as used.** The key check walked the program's declarations but
not the bodies kept beside them - `@On` listeners, the lifecycle hooks, tests, computed fields and
scenes - so a key used only there was never checked against en.po and `ludic schema` listed it
unused. They are walked now, as every other pass walks them.

View file

@ -1,8 +0,0 @@
bump: minor
type: feature
**Inflate in a caller-owned context.** `ZInflate` holds everything one inflate works in - the bit reader,
the RFC's length and distance tables, and every Huffman table and scratch list a block builds - made once
by `z_inflate_new()` and rebuilt in place, so an inflate allocates nothing and each thread that inflates
holds a context of its own. `z_inflate_in(z, src, len, out, cap)`, `z_uncompress_in` (zlib) and
`z_gunzip_in` (gzip) take the context; `z_inflate` / `z_uncompress` / `z_gunzip` keep their signatures,
working in the one context `RtInflateState` holds, so no caller changes.

View file

@ -1,6 +0,0 @@
bump: patch
type: fix
**The most negative int is emitted as itself.** The compiler's `itoa` negated a negative value before
taking its digits, and `-(-2147483648)` overflows back to itself, so a literal such as `0x80000000` in an
int (`b & 0x80000000`) was written into the IR as a bare `-` and the build failed in LLVM. The digits are
now taken off the value as it is. Needs a reseed to reach `bin/ludicc`.

View file

@ -1,6 +0,0 @@
bump: patch
type: fix
**`Json.parse` decodes an escaped string in one pass.** A string with escapes in it was joined a character
at a time, and every shorter copy was kept: a long escaped text took gigabytes (ui-preview's story model
reached 6.5 GB in three seconds). It is now decoded into one buffer sized from the text. ui-preview's
protocol-v1.md states how an `@import` path is resolved and which key a `file` override answers to.

View file

@ -1,5 +0,0 @@
bump: patch
type: fix
**`Json.parse` decodes `\uXXXX`.** An escaped code point is UTF-8 now - a surrogate pair joined into one, a
lone surrogate or bad hex as U+FFFD - where the backslash was dropped and the hex kept as text ("iu015f"),
and `\t`, `\r`, `\b` and `\f` are the characters they name. Python's `json.dump` writes non-ASCII this way.

View file

@ -1,7 +0,0 @@
bump: minor
type: feature
**`Json.write_file(value, path)` saves without keeping anything.** It writes the text
`Json.encode` would give straight to the file (through `path.tmp` and a rename), built in a buffer
the runtime keeps, so a game that saves often holds nothing more for it. `Json.encode` itself is
built the same way and makes only the string it returns: it used to join its text with `+`, keeping
every piece and every number's text. `examples/lang/json_saves.ludic`: 1000 saves, 0 bytes.

View file

@ -1,3 +0,0 @@
bump: patch
type: feature
**ludic.lab: 16-bit PNGs** — `lab_png_write16_from(st, path, w, h, channels, px, at)` writes 16-bit samples (two bytes each, most significant first) the way `lab_png_write_from` writes 8-bit ones, so a test can write a height map render3d's decoder reads back as R16; `lp_png_bytes(stride, h)` is the size of one.

View file

@ -1,39 +0,0 @@
bump: minor
type: feat
Three things a crown and a hand light were getting wrong.
**An aspen's trunk quaked with its leaves.** `layer_flutter`'s mask was the vertex's
HEIGHT alone — anything more than 1.2 m up trembled — so on a ten metre aspen the whole
bole moved, and a tree that is supposed to shiver read as a column of cloth. What
separates a leaf from a bole is not height, it is distance from the model's own centre
line: the leaves are out at the edge of the crown and the trunk is on the axis. The mask
is radial now, and small plants keep the old one, because a flower is all leaf.
**Foliage in the camera's face.** A third-person camera walks into a crown and the branch
between it and the body is an opaque wall a metre from the lens; in first person it is the
whole screen. `#define NEAR_FADE` dithers tree foliage out inside arm's length of the
camera — in the depth PREPASS, so the lit pass never sees those pixels either and the
equal-depth optimisation is untouched. `gl_Position.w` is the view depth for a perspective
projection, so it costs one varying and no uniform. Blades, cards and flowers are left
alone on purpose: they live at the player's feet, they are always that close, and fading
them opens a hole in the meadow. Nor is the BOLE ever faded: a trunk and its needle cards
are one mesh drawn by one program, so the first cut dissolved the trunk into a dither
pattern as you walked up to it - a solid tree you can see through reads as a fault, where
a branch getting out of your way reads as the camera being polite. The mask is the same
signal the quake uses. The shadow pass keeps every leaf, or a tree would stop shading the
ground it stands on as you walked up to it.
**A hand light had the reach of a candle, whatever it was meant to be.** `handLight`'s
falloff was an inverse square windowed off between 26 and 6 metres, with both numbers
hard-coded: two per cent of its own near field by ten metres out, so carrying a torch at
night lit your boots and nothing else. `daylight_hand` takes a `reach` in metres now and
the falloff is a gentle power out to it — about nine tenths at a metre, half at half the
reach, a tenth at nine tenths of it, nothing past it. A pool of light with a gradient in
it rather than a hotspot with a cliff.
**And the wheel on macOS did nothing, or everything.** `scrollingDeltaY` is a double, and
the Cocoa event pump truncated it to an int PER EVENT before accumulating: a trackpad or a
Magic Mouse sends a stream of fractions of a line, every one of which truncated to zero, so
the wheel was dead; a notched mouse sends three to ten lines at once, so it jumped. The
fraction is accumulated now, a precise delta is scaled from points to notches, and the
remainder carries to the next frame - one gesture, one step, on both kinds of mouse.

View file

@ -1,8 +0,0 @@
bump: patch
type: fix
**A template literal inside another's `{…}` hole, and integer literals past 2^31 - 1.** The first
crashed the compiler (the outer literal ended at the inner one's backtick); a hole is now read as
code, so strings, chars and templates inside it are taken whole. The second was read as a wrapped
negative int; a decimal literal past `2147483647`, or a hex one of more than eight digits, is now a
`long` with its value, and giving one to an `int` is refused. Eight hex digits or fewer are still a
32-bit pattern.

View file

@ -1,35 +0,0 @@
bump: minor
type: feat
The air and the light are the hour's, not one constant apiece.
Aerial perspective is a curve now. A low sun shines through far more air than a high
one and shines ALONG the ground rather than down onto it, so density, height falloff
and forward scatter all ride the sun's own elevation, and overcast thickens the air
while flattening the scatter. The term that was missing entirely is distance
DESATURATION: a surface is pulled toward its own luminance faster than the fog itself
arrives. Without it, blending a saturated ridge toward a saturated blue noon sky left
a saturated ridge, and a midday frame had a mountain three kilometres off reading as
vividly as a bench two metres from the camera.
The grade is the hour's too. White balance, the shadows' floor, contrast and
saturation were nine literals bound at the draw; daylight.ludic writes them now.
Noon is the case worth naming: direct sun is warm-white and the only thing filling a
midday shadow is a blue sky, so noon gets a cool balance over a blue-lifted shadow
with hard contrast between it and the lit ground, and dawn and dusk get the reverse.
Gain is deliberately left alone - the grade is `c * gain + lift * (1 - c)`, so it
warms the whole frame rather than the highlights.
The visible sky is relit. It is one HDRI turned on its axis so its sun sits where the
hour wants it, and turning a photograph does not change what colour it was taken at -
so a sunset had a mid-morning blue overhead. An analytic sky supplies the chroma while
the photograph keeps the luminance, so the cloud stays where it is and goes orange at
dusk, and the zenith goes deep blue at noon, with no second sky shipped. It fades out
under the horizon, where the night's own tint, stars and moon take over, and eases off
under heavy cloud.
And the ground bounce follows the ground: the colour a surface is filled with from
below is the map's now, crossing from meadow to rock at its treeline, instead of one
green constant everywhere including above the scree.
R3D_NOAIR=1 restores all of it to what it was, so a before-and-after comes from one
binary at one hour. Measured at 400 frames: no cost on either backend.

View file

@ -1,32 +0,0 @@
bump: minor
type: feat
Light you can see: sun shafts, and mist that lies in the valley.
Everything before this made the air a COLOUR APPLIED TO A SURFACE. Nothing put light in
the space between surfaces, so a basin at dawn had no shafts, no pooled mist and no rays
off a ridge at any hour, however the fog was tuned.
A half-resolution march from the camera to the depth buffer, asking the SAME shadow the
rest of the frame asks - the cascades, the baked height-field shadow and the cloud mask -
so a shaft is cast by the actual trees and the actual ridge, and a passing cloud dims its
own rays. Henyey-Greenstein scattering, because real air throws light forward, which is
why a low sun fills a valley when you look into it and does almost nothing when you look
away. Density and a separate ground-hugging MIST layer ride the sun's elevation from
daylight.ludic, so the mist forms in the cold at either end of the day and burns off by
mid-morning.
It composites into the HDR scene with the bloom pyramid's own tent upsample under ONE/ONE
blending - which is exactly what was wanted and already existed - and before bloom, so a
shaft blooms like the bright thing in the air it is. Into post_hdr and not post_scene:
post_scene is the copy the water refracts, and shafts added there would sit under the lake.
The tuning that mattered was the SKY term. It is added at every step, so at 0.06 it
accumulated into a flat grey wash that lifted lit and shadowed air by the same amount -
which is the contrast a shaft is made of. The valley came out as one pale sheet with no
rays in it. At 0.012 the sun's term dominates and there is light to see rather than fog.
R3D_NOVOL=1 switches it off for an A/B. Off in Settings skips the pass whole rather than
marching once, because a feature is off when it costs nothing.
Measured, 400 frames at 07:00: OpenGL 7.1 -> 7.3 s, Vulkan 7.2 -> 7.4 s. Backends agree
to 0.08/255.

View file

@ -1,7 +0,0 @@
bump: minor
type: feature
**`ludic-fmt` enforces a project's style.** `lint` lines in `package.ludic` state it - one statement
per line, file, function and comment-block limits (a file's opening comment its own) - and
`ludic-fmt --check <files>` and `ludic-fmt --lint` (the project's paths) fail what breaks them, at the
line. A baseline file is the ratchet that lets a rule arrive in an existing codebase: the counts a
file had may stay but not grow, and they are lowered as they are fixed.

View file

@ -1,7 +0,0 @@
bump: patch
type: fix
**A `return` inside an `@On` listener ends that listener, not the whole dispatch.** Listeners are
compiled into one `@ev_<E>` function, and `return` branched to its exit: every listener declared
after the one that returned, and every foreign listener, never heard the event. The shape it
broke is the common one - a listener per kind that returns early for every kind but its own -
where only the first-declared kind was ever answered. `examples/events/answer.ludic` holds it.

View file

@ -1,7 +0,0 @@
bump: minor
type: feature
**`ludic deps` - the module graph as the compiler resolved it.** It compiles the program with
`LUDIC_DEPS` set, and the compiler records every reference its visibility pass sees (from module,
to module) and every assignment to another module's global. It prints the modules, dependencies,
largest cycle, cross-module writes and globals written from outside; `--graph`, `--dot`, `--writes`,
`--uses MOD`, `--check FILE` and `--baseline FILE`.

View file

@ -1,9 +0,0 @@
bump: minor
type: feature
**ludic.lab - the visual lab as a package.** A scene is an entry in the open registry `LabScenes`,
shown on a plate: a flat lit disc, a small sky of the package's own with the sun at one hour, and a
frame clock. Headless, each of its cameras (`lab_shot`, `lab_shot_at`) settles and is written as
`build/lab/<scene>/<shot>.png`; in a window N and P step through them. `tools/lab/run.sh` and
`sheet.py` make a contact sheet. ludic.render3d gains `r3d_plate_mode(true)` - no terrain, grass or
water is made - and `r3d_sky_path`; the example scene takes about 120 MB resident. `quit()` in a
program with no frame loop no longer fails to link.

View file

@ -1,35 +0,0 @@
bump: minor
type: feat
**A state machine as data: `@Machine(Record.field)`** (phase 27.1). A registry marked
`@Machine(Deer.mood)` is the transitions of a machine over that enum field of the records a state's
`Table<Deer>` holds: its record has `from` and `to` (the enum's variants), `on: string` (an action's
name, `""` for a transition the tick asks), `guard: fn(Row<Deer>, reads...) -> bool` and
`enter: fn(Row<Deer>, reads...) -> void`; the states are the enum's variants and the start is the
field's default. Its rows are data - an `.lres` or `def`s - so the studio edits the machine as a graph.
- **The compiler writes the machine.** For each action an `on` names, a row reducer in the
registry's file (`reducer Deer in Herd.deer on Spook`, beside any the program writes, after them):
the row's state, the first transition from it on that action whose guard passes, the field set,
`enter` run. When a row leaves a state on a guard alone, `state DeerStepsMachine` (the kept row
view) and `deer_steps_tick(m: mut DeerStepsMachine, s: mut Herd, reads...)`, one transition a row
a tick, called from the program's system. Guards and enters are called by name; nothing allocates.
- **The table is the whole machine.** The field is written by nothing else: an assignment to it or
a `machine` block's `become` over it outside the written code is a type error (`Deer.mood is the
machine DeerSteps's (@Machine(Deer.mood)) - it changes only by a transition in its table`); a new
row takes its state in its `new`. A guard and an enter take the row first, are the record's
module's, and keep a row reducer's rules (`r.rec` and `r.h` only, a `@Column` field only through a
`@RowVerb`); a guard writes nothing through its row.
- **The graph is checked**, each an error naming its row: a state never reached from the start, a
state with no way out, an `on` naming no action or an action with no `@Target`, a self-transition
with no guard, and two ways out of a state on one trigger behind an unguarded first. Also refused:
`@Machine` on anything but a registry, a field that is not a plain enum with a default, a
`@Column` field, no table (or two) of the record, a transitions record of another shape, and a
machine outside its table's state's module.
- **`ludic schema`'s code section** gains `machines` (`registry`, `record`, `field`, `enum`, `table`,
`start`, `states`, `actions`, `tick`, `module`, `at`); the registry carries `@Machine` among its
attributes. `ludic deps` names a machine's reducer `reducer Deer in Herd.deer on Spook (machine
DeerSteps)`. vocab: `@Machine`; docs `annot-machine`; LANGUAGE.md "A machine as data".
- Examples `actions/machine` (with `deer_steps.lres`); rejects for an unreachable state, no way out,
an unknown action, an action with no row, a self-transition with no guard, an ambiguous trigger, a
guard that writes, the field written by hand, `@Machine` off a registry and a field that is no
enum. The `machine` block stays for a machine that is only code.

View file

@ -1,5 +0,0 @@
bump: minor
type: feature
**Map-scoped tables: `@PerMap` and `@Chunked(n)` registries** — a registry whose rows live in each map's directory and are read when the map loads, or a chunk at a time.
`@PerMap @ByKey registry Props of PropRow from "props.lres"` reads `<maps root>/<map>/props.lres` (the root is `package.ludic`'s new `maps "assets/maps"` line), and `@PerMap @Chunked(64) registry Instances of InstRow from "instances/{cx}_{cz}.lres"` one file per chunk. The compiler writes the table's `state` and its verbs in the declaring module (`props_load`, `props_clear`, `props_find`, `props_path`; `instances_in`, `_out`, `_slot`, `_find`, `_clear`, `_path`) and a typed fill over a small runtime `.lres` reader (`runtime/native/lres.ludic`, spliced on demand): a row names any int or float constant by name (resolved at load), a `fn` value by name, nested records and lists. Rows, their lists and the records in them are pooled and refilled in place, and `_find` is an allocation-free hash, so a load allocates nothing past the table's high water and a frame may bring a chunk in. `@Ref(T)` into a map table is a string key, checked against the same map. `ludicc --check` type-checks every map directory's tables against their records with file:line:col diagnostics (`--no-maps` to skip). The schema gives each registry a `"scope"` (`"map"` / `"game"`) and `"chunk"`, and a map `@Ref` `"scope": "map"`; `@Unit` now has canonical ASCII spellings (`deg`, `m/s2`, `N.m`, ...), listed as the schema's `"units"`, and any other spelling is a warning naming the right one.

View file

@ -1,7 +0,0 @@
bump: patch
type: fix
**`ludic build --check` reads the maps only for the package's entry.** A partial program - a unit test, a
molecule, a bake's runner - lacks the game's constants, so checking every map's tables against it refused
rows the game reads fine (`TH_*`, `NPCK_*` unknown). The maps are now checked with the `entry` package.ludic
names (as `ludic build --check` with no file, and tests/pack.sh, do); `--maps` checks them anyway, `--no-maps`
never. `ludicc` itself is unchanged: it reads them unless told `--no-maps`.

View file

@ -1,10 +0,0 @@
bump: patch
type: fix
**A `Math.*` call has a type the checker knows.** `Math.max`, `Math.sqrt` and the rest are computed
inline by the emitter, and the checker gave every one of them the unknown type, which agrees with
everything - so `dispatch Notify { a1: Math.max(5, n) }` put an int into a string field, passed L4
and failed in LLVM (`%t63 defined with type i32 but expected ptr`). The checker now gives each the
type the emitter does: a float or double first argument is that type (`sign` an int); otherwise
`min`, `max`, `abs` and `clamp` keep the first argument's type, `sign`, `floor`, `ceil`, `round`,
`posmod`, `wrap` and `ping_pong` are ints and the rest fixed. The bare `min` / `max` / `abs` /
`clamp` builtins the same. Where the arguments cannot be told, the result stays unknown.

View file

@ -1,10 +0,0 @@
bump: minor
type: feature
**A loaded model can be let go of: `model_release(model)`.** Its meshes are freed at once, and
each texture once no other model uses it - the glTF loader shares textures between models (one
path loaded once, a LOD chain borrowing its LOD0's material), so every cached texture now counts
the primitives using it, and the last one frees it and forgets it, with any remembered material
that named it. A game can unload a species' kit or a map's models when nothing of them is left,
and loading one again afterwards is a fresh load. A scatter layer takes room for what it holds
instead of its whole capacity up front (it doubles as it fills, to its cap), which in Maroon Lake
was 0.4 GB of never-filled instance arrays. `examples/rendering/release.ludic` is the check.

View file

@ -1,7 +0,0 @@
bump: minor
type: feature
**Layers: `module flow in layer app uses base, items`.** The modules of one layer use each other
freely and may go round; anything outside the layer is still held to the module's `uses` (a
layered module with no `uses` reaches nothing outside its layer), and a cycle is refused unless it
stays inside one layer. `ludic deps` shows the layers and counts the largest cycle without the edges
inside them, printing the count with them beside it.

View file

@ -1,8 +0,0 @@
bump: minor
type: feature
**Modules.** `module NAME` in a barrel makes its directory a module, and a module's
declarations are private to it unless they say `export`: a function, global, record or event of
another module used without one is an error naming the module and where to mark it. `friend
module NAME` sees everything (a test harness), a file in no module is public, and a package keeps
its own module. `LUDIC_VIS_REPORT=1` lists every violation instead of stopping, so an existing
program can be given its exports by a script before the rule applies to it.

View file

@ -1,8 +0,0 @@
bump: minor
type: feature
**`module fishing uses base, data` - a module declares what it may reach, and the compiler holds it
to that.** A reference from a module that says `uses` into a module it does not name is refused,
exported or not, with the use and the fix in the message ("fishing uses items.inv_add (...): add
'uses items' to fishing's module line, or take it through a port"). A module with no `uses` keeps
the old rule, a package's module is always usable, a friend is not held to it, a cycle in the
declared graph is refused, and `LUDIC_VIS_REPORT=1` lists the violations as `uses:` lines.

View file

@ -1,10 +0,0 @@
bump: patch
type: fix
**A frame that draws more than any before no longer grows the heap on the Mac.** MoltenVK's command
pooling kept every command object a frame had ever recorded - about 650 bytes for each draw beyond the
busiest frame so far, for as long as the game ran. render3d turns it off
(`MVK_CONFIG_USE_COMMAND_POOLING=0`, unless the environment already says otherwise) before the first
Vulkan call; the objects are made and freed with their command buffer, at no measured cost.
`examples/rendering/steady.ludic` ramps a frame from 20 to 200 actors and fails on what pooling left.
A buffer written through its mapping is also read once at the start of the next frame's commands,
so MoltenVK makes its Metal buffer then rather than at its first draw, however much later that is.

View file

@ -1,10 +0,0 @@
bump: minor
type: feat
**A package can carry a native library.** `native "<target>" "<path>"` in a package's
`package.ludic` names a C/C++ library per target (`macos-arm64`, `windows-x64`, ...). The
compiler records the libraries of every package a program imports, and every link - `ludicc -o`,
`ludic build`, `ludic test`, `ludic bundle` - links them: on macOS with an rpath to the package
and to `Contents/Frameworks`, where `ludic bundle` places and signs them; on Windows through the
import library, the `.dll` copied beside the executable. `tools/native/lib.sh` builds a library
from a pinned, checksummed source; `ludic.nativeecho` is the worked example, and
`packages/README.md` says what a shim may pass across.

View file

@ -1,6 +0,0 @@
bump: patch
type: feat
**A native library's licence ships with it.** `ludic bundle` copies each linked package's
`native/LICENSE*` files beside the `.exe` on Windows and into `Contents/Resources` on macOS, so
Jolt Physics' MIT notice travels with every copy of the game. `tools/native/lib.sh` passes lld-link
`-implib`, which Git Bash leaves alone (it rewrote `/implib:` into a Windows path).

View file

@ -1,4 +0,0 @@
bump: patch
type: fix
**The foliage's near-fade programs have SPIR-V.** The three `NEAR_FADE` variants were missing
from `variants.list`, so on Vulkan the trees' and cards' programs failed to build.

View file

@ -1,13 +0,0 @@
bump: minor
type: feat
**A `numbers float` file is strict about where its numbers come from.** Two rules, both found
converting a renderer and a game from float bits in an `int` to real floats:
- A decimal literal takes the kind of what it meets. `factor == 0.0` against a `fixed` parameter,
`half(3.0)` into a `fixed` one and `var z: fixed = -1.5` are `fixed` literals there, rather
than a `fixed` and a `float` that do not mix.
- A computed integer never becomes a float implicitly. `count < limit` with an `int` on one side
and a `float` on the other is an error that asks for `float(x)`: in a file whose numbers are
floats, an `int` meeting one is almost always raw bits, and promoting it compares the bits as a
number. It caught a uniform fed an integer as float bits, and a test comparing a tint's bits
with its value. Integer constants still promote, and `float(x)` is always allowed.

View file

@ -1,9 +0,0 @@
bump: minor
type: feat
**`numbers float` — a module whose decimal literals are `float`.** A bare `1.5` is `fixed`
unless a float is in sight, which is the right default for deterministic code and the wrong
one for a renderer or a game whose numbers are all IEEE: `Math.sqrt(2.0)` came out in `fixed`,
and every literal needed a `float(...)` round it. A file that says `numbers float` (at the top,
or inside a `program` block) gets float literals, and passes the mode on to every non-runtime
file it imports, so one line in a barrel or an entry file covers a whole package. Runtime files
under `runtime/` are never switched, so the engine's own fixed-point code keeps its meaning.

View file

@ -1,11 +0,0 @@
bump: patch
type: fix
**One MoltenVK in a process.** On a Mac with the Vulkan SDK installed, the runtime opened the SDK's loader
(/usr/local/lib/libvulkan.1.dylib) before MoltenVK, and the loader loaded the SDK's own MoltenVK as its
driver - beside ludic.render3d's, which every program is linked against: two copies, each with its pools,
and the program drew through the SDK's ("MVKBlockObserver is implemented in both"). MoltenVK is opened
first now (dlopen hands back the linked copy), and the loader only when a layer is asked for
(VK_INSTANCE_LAYERS, VK_LOADER_LAYERS_ENABLE, R3D_VK_LOADER) - then pinned to ludic.render3d's MoltenVK
through VK_DRIVER_FILES and lib/macos-arm64/MoltenVK_icd.json, unless the caller named a driver.
examples/rendering/steady.ludic's stream round warms up over 300 cells and takes the least of three
windows of 150, as the frame round does: one sample of it read +75 KB on a run whose twin read -5 KB.

View file

@ -1,25 +0,0 @@
bump: minor
type: feat
One wind, and everything in the valley is moved by it.
The grass had a gust built from two sines and the trees had NO gust term at all - just a
per-instance wobble - so the meadow rippled and the canopy above it swayed to an unrelated
rhythm and nothing ever crossed the valley. A real gust is a WAVE: you watch it come over
the grass, it reaches you, and it goes on into the trees behind you.
wind.glsl is prepended to EVERY stage, so the grass's vertex shader, the crown's vertex
shader and the water's fragment shader read the same field at the same world position and
cannot drift apart. No uniforms are declared in it on purpose - u_time and u_wind already
exist in several of those files and redeclaring them is a compile error in whichever stage
includes both - so the caller passes what it already has.
Two numbers mattered. The gust's WAVELENGTH has to fit inside a view or nothing is ever
seen to travel: the first cut was 209 m crest to crest, longer than the meadow you can see,
so the whole frame sat in one phase and read as everything breathing together. It is 74 m
now and a front crosses a normal view in a couple of seconds. And `stiff` lets one field
move a blade a long way and a bole hardly at all.
R3D_DEBUG_WIND=1 paints the field on the ground, which is the only way to SEE a gust in a
still: two shots a moment apart show the fronts and show them moving.
Measured: no cost. 400 frames, GL 6.9 s, VK 7.2 s.

View file

@ -1,7 +0,0 @@
bump: minor
type: feature
**`export open registry Items of Item` - a registry other modules may add to, in a stable order.**
A `def` from another module into a registry that is not open is refused, and a def now goes
through visibility like any reference (the registry exported, its module in the definer's `uses`).
An open registry's index order is the declaring module's entries first, then every other module's
by module name, each in the order it is read - independent of the order a barrel imports them in.

View file

@ -1,5 +0,0 @@
bump: patch
type: fix
**`Os.platform()` and `Os.arch()` allocate nothing after the first call.** Each call malloc'd an
8 KB `uname` buffer and let it go, and a game asks the platform every frame in places (a launcher's
wait, an update panel, a renderer's present). The buffer is made once and kept.

View file

@ -1,8 +0,0 @@
bump: patch
type: fix
**A name that meets a package's export says whose it is; ludic.ui's `UiAct` is its own.** A game's
`UiAct` collided with ludic.ui's, which no program uses - it is private to ludic.ui now, so the game
may have one. A real clash - a type named like one a package exports - is still refused, and the
message names the package and the way out: `'UiNode' is defined twice (...): ludic_ui exports it, and
exported names are one namespace - rename this one, or declare it without export inside a module of
your own`.

View file

@ -1,8 +0,0 @@
bump: minor
type: feat
**`ludic.physics`: Jolt Physics as a package.** Shapes (box, sphere, capsule, cylinder, a
boulder's dome, a heightfield, a mesh), still, kinematic and dynamic bodies that can be removed
for real, rays, a foot's landing height, a push out of what a body stands in, overlaps, buoyancy
against a `PhysWater` port with its current, and hard contacts and splashes as facts - stepped at a
fixed 1/60 s, deterministic across machines. Jolt v5.6.0 is built here from its pinned tag
(`native/build.sh`) through the phase-15 route.

View file

@ -1,7 +0,0 @@
bump: patch
type: fix
**A validation run sees the game's MoltenVK.** With a layer asked for, the loader was pinned to
ludic.render3d's MoltenVK only when the shell named no driver - but the SDK's setup-env.sh exports both
VK_DRIVER_FILES and VK_ICD_FILENAMES at the SDK's own MoltenVK, so a shell with one left set loaded that
one beside ours. The pin now overwrites VK_DRIVER_FILES and clears VK_ICD_FILENAMES; R3D_VK_ANY_DRIVER=1
keeps the shell's driver for a run that means to try another.

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