Compare commits

..

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

2198 changed files with 87846 additions and 790364 deletions

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/ludic dev test` passes.
- [ ] For compiler/runtime changes: `bin/ludic dev reseed && bin/ludic dev 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/ludic dev docs-gen && bin/ludic dev 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

@ -35,7 +35,7 @@ jobs:
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 +43,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/ludic-cli/main.ludic -o bin/ludic
- name: Rebuild the compiler from the seed and assert byte-identity
# `ludic-dev bootstrap-cfree` assembles the seed with clang, has that seed
# `ludic dev 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/ludic dev 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 `ludic dev test` / `ludic dev 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:
@ -45,10 +45,10 @@ jobs:
# 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, `ludic dev 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 +60,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/ludic-cli/main.ludic -o bin/ludic
bin/ludic dev build
- name: Regression suite (ludic-dev test)
run: bin/ludic-dev test
- name: Regression suite (ludic dev test)
run: bin/ludic dev test
- name: Editor-toolchain suite (ludic-dev test-tools)
- name: Editor-toolchain suite (ludic dev 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/ludic dev test-tools
- name: Docs cover the implementation
run: |
@ -81,9 +81,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 `ludic dev test-tools`.)
bin/ludic dev check-impl
bin/ludic dev check-vocabulary
bin/ludic dev check-docs
bin/ludic dev docs-gen --out build/pages
bin/ludic dev docs-check build/pages

View file

@ -11,10 +11,6 @@ on:
- 'docs/**'
- 'tools/docgen/**'
- 'tools/ludic-cli/**'
# the site publishes the installer, so a change to it has to redeploy the
# site — otherwise a fixed install.sh sits in main while the old one is
# still what `curl … | sh` fetches
- 'install.sh'
- '.forgejo/workflows/docs.yml'
workflow_dispatch: {}
@ -38,7 +34,7 @@ jobs:
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
# like the ci workflow, then runs `ludic dev docs-gen`. node:20-bookworm carries git
# for the clone + publish; clang-16 is the only extra the bootstrap needs.
container: node:20-bookworm
steps:
@ -63,13 +59,13 @@ jobs:
# 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/ludic-cli/main.ludic -o bin/ludic
bin/ludic dev docs-gen --out ../public
bin/ludic dev docs-check ../public
cd ..
echo "--- generated files ---"
ls -la public

View file

@ -1,6 +1,6 @@
name: release
# Cutting a release is `ludic-dev release` + `git push --tags`; everything after that
# 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
@ -49,7 +49,7 @@ jobs:
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_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV"
echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV"
- name: The tag, VERSION and CHANGELOG must agree
@ -71,15 +71,15 @@ 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/ludic-dev build
bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
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
bin/ludic dev test
bin/ludic dev test-tools
bin/ludic dev bootstrap-cfree
- name: Publish the release
env:
@ -91,9 +91,9 @@ jobs:
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
# 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"
bin/ludic dev publish "$TAG"

1
.gitattributes vendored
View file

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

21
.gitignore vendored
View file

@ -1,5 +1,5 @@
# Generated build tree: LLVM IR, objects, compiled apps, the headless render
# (build/out.ppm) and the docs site all land under build/ (see `bin/ludic-dev build` /
# (build/out.ppm) and the docs site all land under build/ (see `bin/ludic dev build` /
# `bin/ludic 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,8 +9,8 @@
# 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, ludic-fmt, ludic-lsp) — all built
# into bin/ by the one-line bootstrap + `bin/ludic dev build`; never checked in. The
# only thing published is the source and the LLVM-IR seed (selfhost/ludicc.seed.ll).
/bin/
@ -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,7 +39,7 @@ tools/editors/jetbrains/.kotlin/
__pycache__/
*.pyc
# Release artifacts produced by `ludic-dev release`
# Release artifacts produced by `ludic dev release`
/dist/
# Build/release tarballs anywhere in the tree. `git -C <repo> archive -o foo.tgz`
@ -48,15 +47,3 @@ __pycache__/
# 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/

View file

@ -7,938 +7,6 @@ change type. Preview the next one with `ludic dev release --dry-run`.
A released section may carry a hand-written summary paragraph above its groups
(v0.2.0 has one); the generated bullets below it are not edited by hand.
## v0.22.0 — 2026-09-18
### Features
- **Every key on the keyboard is bindable** — the F-row, Home/End/PageUp/PageDown,
Insert/Delete, Caps Lock and the numpad reach a game as codes 132-166.
Until now the platform gave them no code at all, so a game's rebinding screen could
not take one and nothing said why. Windows asked the active layout what they type and
got nothing back (`w_vk_char` answers 0 for a key with no character); macOS let them
fall through to `charactersIgnoringModifiers`, which reports `NSF1FunctionKey` and its
neighbours at 0xF704 and up - outside the 256-bit held set either way.
- **The codes are the same on both platforms**: 132-143 are F1-F12, 144-149 are Home,
End, PageUp, PageDown, Insert and Delete, 150 is Caps Lock, 152-161 are the numpad
digits and 162-166 its `*`, `+`, `-`, `.` and `/`. The numpad's Enter is Enter.
- **`Key.F1`, `Key.Home`, `Key.Numpad0`** and the rest fold at compile time like the
other named keys.
- **`Input.key_label`** names them - "F1", "Home", "CapsLock", "Num 0", "Num /" - and
does *not* ask the layout, because a key that types nothing is called the same thing
on every layout.
## v0.21.1 — 2026-09-17
### Fixes
- A windowed game opens under its package's `app name`. `ludic build`, `ludic run` and
`ludic bundle` pass it to the compiler (`ludicc --title <name>`), so `game_title()` - the
window's first title - is the name the player knows rather than the `program` name, which
showed for up to two seconds before the renderer retitled the window. Without an `app name`
it is the `program` name, as before.
- `Crypto.random_bytes`, `Crypto.random_hex`, `Crypto.random_u32` and `Uuid.*` draw from the
system CSPRNG on Windows (`RtlGenRandom`, advapi32). They read `/dev/urandom`, which Windows
does not have, and every byte came back zero.
## v0.21.0 — 2026-09-17
### Features
- **A launcher's runtime** — start a program, step aside while it runs, and download large
files with a progress bar.
- **`Process.*`** — `Process.spawn(path, args)` starts a program directly (no shell) with this
process's environment and working directory and returns at once; `Process.poll(h)` is `-1`
while it runs, then its exit code (`128 + signal` for a signal death, `137` after
`Process.kill`); `Process.free` lets a handle go. `posix_spawn` on macOS
(`runtime/native/process.ll`); `CreateProcessW` on Windows (`process_win.ll`), with each
argument quoted by the MSVC rules and no console window. Linked only into a program that
uses `Process.*`.
- **`Http.save_to(h, path)`** — before `Http.send`, streams the response body straight into a
file (written at `path` itself, created on send) instead of memory; `Http.text` of such a
request is empty and `Http.body_len` is the bytes written. **`Http.received(h)`** and
**`Http.expected(h)`** (the `Content-Length`, or `-1`) are live while it is pending. macOS
streams through an `NSURLSession` with a delegate built at run time, Windows through the
WinHTTP read loop. Freeing a pending request now cancels it and parks its slot until the
worker has let go, rather than freeing what the worker is still writing.
- **`App.window_hide()` / `App.window_show()`** — take the game's window off the screen and
bring it back without closing it or its GL / Vulkan surface; the run goes on while it is
hidden. No-ops headless and before there is a window.
### Fixes
- A game's window takes the title it asks for. The runtime opens a game's window before `main`,
titled with the `program` name, and `Gl.open` / render3d's `gl_open` and `gvk_open` attached
to it without passing their title on, so a `program Valley` that opened "Maroon Lake" showed
"Valley". `win_set_title` (cocoa.ll `setTitle:`, win32.ll `SetWindowTextW`, UTF-8 to UTF-16)
now retitles it, and a second `win_open` on macOS retitles the open window instead of making
another, as it already did on Windows.
## v0.20.0 — 2026-09-17
### Features
- The JetBrains plugin (1.6.0) has a Ludic tool window for the package, like the Gradle one:
- an overview of the package (what it is, entry, version, dependencies, scripts, hooks, app);
- its commands, scripts and hooks, run by double-click, with each command's hooks shown;
- its dependencies against package.lock.ludic: the locked version, whether it is fetched
and linked, the indirect ones, and Fetch, Update, Verify, Vendor, Add and Remove;
- the app preview the `app` lines describe, and the asset roots with missing ones marked.
The bar over package.ludic now appears only when something needs doing (fetch, or no
toolchain), instead of carrying every command.
## v0.19.0 — 2026-09-17
### Features
- `Udp.*`: polled IPv4 datagrams - the transport under a game's own netcode. `Udp.open(port)`
binds a non-blocking socket (port 0 picks one, `Udp.port` says which), `Udp.send` sends one
datagram to an address and port, and `Udp.recv` drains what arrived without ever blocking;
`Udp.from_ip` / `Udp.from_port` name the sender. `Udp.ip` / `Udp.ip_text` convert addresses,
`Udp.resolve` looks a host name up and `Udp.local_ip` is the address a player on the same
network would dial. BSD sockets on macOS (`runtime/native/udp.ll`), Winsock on Windows
(`udp_win.ll`, linked with ws2_32), linked only into a program that uses `Udp.*`.
## v0.18.1 — 2026-09-16
### Fixes
- The release build links libm on Linux, which `float`/`double` code needs (glibc keeps
`sinf`, `sqrtf` and the rest there). 0.18.0's release job stopped on this before
publishing, so 0.18.1 is the first published build with float types, barrel imports,
package scripts and hooks, and the JetBrains 1.5.0 support.
## v0.18.0 — 2026-09-16
### Features
- Run a single test by name: a compiled test program takes the test's exact name as its
first argument (`./tests "adds"`), and `ludic test` gains `--test NAME` to pass it through.
A name that matches no test is reported and fails the run. `ludic test --verbose` (`-v`)
prints every test's `ok`/`FAIL` line, framed by `RUN <file>` … `PASS|FAIL <file>`, which is
what editor test views parse.
- The JetBrains plugin covers much more of the IDE:
- Run configurations for `ludic run`/`build`/`test` (plus any other `ludic` command), with
gutter run buttons on `program` and on every `test "name"` block.
- Test results appear in the IDE's test tree, with navigation back to each test block.
- `file.ludic:line` locations are clickable in every console.
- Smart indentation on Enter and when typing a closing bracket; quotes pair up.
- A Color Scheme page and a Code Style page.
- Live templates, New Ludic File templates, and a Ludic project in File | New | Project.
- A Tools | Ludic menu, and a warning when the toolchain is missing.
- `package.ludic` is treated as a package manifest:
- a banner offers Fetch, Update, Verify, Run, Test, Build and Bundle, and warns when the lock is
missing or older than the manifest;
- gutter actions on `package` and `require` lines;
- completion and quick documentation for directives and `app` keys;
- its own icon.
- `script` and `hook` lines complete and are validated; each script has a run button, the
package banner has script shortcuts, and Tools | Ludic | Run Script… lists them all.
- The toolchain's and the project's packages are listed under External Libraries.
- The Structure view, parameter info and code-block navigation now come from `ludic-lsp`.
- The plugin is now 1.5.0 and requires IntelliJ Platform 2024.2 or newer and LSP4IJ 0.21.
- Fixed: Enter no longer inserts an extra `}`, and Comment Line no longer writes two spaces.
- `float` and `double`: IEEE floating point with ordinary operators, so renderer math reads
`a * b + c` instead of `f_add(f_mul(a, b), c)`.
- Decimal literals take their type from context (`let s: float = 0.1` is exactly 0.1), and
stay `fixed` elsewhere, so existing programs keep their meaning.
- `int`/`long` promote; `float(x)`, `double(x)`, `int(x)`, `long(x)` and `fixed(x)` convert.
- `floats(n)` / `doubles(n)` buffers; float fields, globals, constants and parameters.
- `Math.*` computes in float when given one; `string`/`print`/interpolation write the
shortest round-tripping decimal; `float_bits` / `float_from_bits` expose the IEEE pattern.
- `@deterministic` code may not use floats (it is now checked).
- `import "camp"` imports a directory through its barrel, `camp/index.ludic` (a fragment
listing the directory's own imports). A directory without one is a clear error. Packages
resolve the same way: `import "ludic.render3d"` reads `ludic.render3d/index.ludic`.
- `ludic-lsp` navigates like the compiler resolves:
- Package imports (`import "ludic.render3d/r3d.ludic"`) resolve from `ludic_modules/` and the
toolchain's `packages/`, so their names hover, complete and jump. Import strings are links.
- `var` locals are tracked (declaration, usages, rename).
- Hover shows the declared or inferred type of locals, parameters and globals.
- Hover and go-to-definition on built-ins, built-in types and namespaces show the reference
page, with a link to the online docs. Release archives now ship `docs/language` for this.
- Go to type declaration (`textDocument/typeDefinition`) is supported.
- Signature help carries per-parameter ranges and follows named arguments.
- Completion inside a call offers the parameter names not given yet, `Namespace.` offers its
members, and results are ranked: locals first, then globals, built-ins and keywords.
- `package.ludic` gains `entry`, scripts and lifecycle hooks:
- `entry "src/game.ludic"` names the program `ludic run`/`build`/`bundle` compile. Without
it, the one file under `src/` that declares a `program` is used.
- `script "dev" "ludic run --headless"` defines a command: `ludic dev` (or `ludic script dev`)
runs it with any extra arguments, and `ludic scripts` lists them.
- `hook before|after <command> "…"` wraps `build`, `run`, `test`, `bundle`, `pack`, `get`, …
or a script. A failing `before` hook stops the command; `after` hooks run only on success.
- Quoted manifest values accept `\"` and `\\` escapes.
### Fixes
- **`==` / `!=` on records is identity again** — only two strings compare by content.
Every pointer-typed comparison used to be lowered to a C-string content compare,
so two distinct records (properties, slices, enums) compared their bytes up to
the first zero byte: `a == b` could be true for different objects that shared a
leading field, depending on layout. References now compare by identity
(`icmp eq ptr`); `string` (and untyped `pointer`/`pointers` text) still compares by content,
and comparing a `string` with a non-string reference is a compile error.
## v0.17.0 — 2026-09-16
### Features
- **Renderer switches stay out of shipped games** — every `R3D_*` environment switch in
`ludic.render3d` (debug views, feature kills, file writers, hardware fakes, the Windows
feature overrides) now goes through one gate: a headless build honours them as before, a
windowed build only when `R3D_DEV` is set to anything but `0`. See `docs/SHIPPING.md`.
### Fixes
- **A lake can be the water that mirrors the world** — a reflecting body is clipped to its
ellipse like any other unless it is an unbounded sea, so a map whose reflection belongs to
its lake no longer floods every hollow in the survey with that lake's level; and the
terrain's wet shore, forest and scree gates read the carved lake's line inside its outline
(`u_lake`), the rule the grass already used, so a lake above the sea keeps its wet bank and
no forest is painted down its bed.
`terrain_lake_carve(false)` keeps a lake's line, outline and shore but skips carving its
bed, for a height map that already carries a shaped one.
## v0.16.2 — 2026-09-15
### Fixes
- **Keys are physical positions on Windows and macOS, whatever the layout** - `Input.key_down('w')`
is the key above S on every keyboard.
- **Windows** keyed the held set by the character the active layout gave a virtual key, so
a game's WASD belonged to whatever the layout put there: on AZERTY W and A were other
keys, and with an input method on (Chinese, Japanese, Korean) every letter arrived as
VK_PROCESSKEY and no letter key worked at all. The typing block is now read from the
scancode; the arrows, the numpad and the F-keys still go by virtual key, and the frame
key (`Input.key`, typing) keeps the layout's character.
- **macOS** did the same through `charactersIgnoringModifiers`; it now reads `keyCode`.
- **`Input.key_label(key)`** names a key for the player in their own layout - `'w'` shows
as "W" on QWERTY and "Z" on AZERTY - so a game that shows its bindings never shows a
key the player cannot find. Windows reads the layout; headless, macOS and the browser
give the US character.
## v0.16.1 — 2026-09-15
### Features
- **HDR calibration, and two HDR fixes** — the HDR10 picture follows the player's display instead of one fixed curve.
- **`r3d_hdr_calibrate(peak, paper, black)`** — nits as float bits: the brightest the display shows (100-10000),
where the picture's and the interface's white sit (80-1000, never above the peak) and how far the darkest shade is
lifted (0-5, fading out by paper white). The tonemap's and the overlay's HDR10 variants read them, and the display's
HDR metadata is sent again with the new peak. The defaults are the old constants: 1000, 200 and 0.
- **`ov_hdr_nits(nits)` / `ov_hdr_paper()`** — what the overlay draws next is at that many nits on an HDR10 frame,
for a calibration screen's test patches; it closes the batch so far. No effect on an SDR frame.
- **Fixed: a crash toggling HDR on the Vulkan renderer.** `gpu_caps_probe()` made and destroyed a second Vulkan
instance under NVIDIA Streamline's interposer; the next swapchain rebuild then called through a pointer that
instance had left behind, at address 0. With the Vulkan renderer running, the probe asks its instance instead.
- **Fixed: yellow read as red in HDR.** The overlay drew sRGB values straight into the HDR10 swapchain (it now has an
HDR10 variant, chosen while `gpu_hdr_active()`), and the tonemap extended highlights per channel, which boosted a
bright yellow's red far more than its green; it now brightens the graded colour by one factor.
## v0.16.0 — 2026-09-15
### Features
- **A loading screen can show the renderer's start-up as it happens.** `r3d_open(w, h, title)` opens
the window and the graphics backend with nothing baked, and `r3d_load_count()` / `r3d_load_step(i)`
run the rest - the sky and its light, the terrain, the shadow and screen targets, the scattered cover
and actors, the grass - one step at a time, so a game can draw and present a frame between them.
`r3d_init` is those same calls in a row and is unchanged for everything that uses it.
- **HDR10 output on the Vulkan renderer** — `r3d_hdr(on)` asks for an ST 2084 / BT.2020 swapchain where the
display offers one.
The tonemap's HDR10 variant keeps the SDR picture up to a 200-nit paper white and rolls highlights on to
1000 nits; the overlay converts the interface to the same white; HDR metadata is set where the loader has
the command. The screen and LDR images are 10-bit while it is on, and screenshots refuse rather than write
a PQ image as SDR. OpenGL and the Vulkan SDR frame are unchanged. `R3D_HDR` overrides the setting.
- **Mesh-shader grass** — on a card with `VK_EXT_mesh_shader`, `r3d_mesh_grass(on)` draws each grass tile as one
mesh-shader dispatch sized to that tile's blades, and a blade that is culled emits no vertices at all.
- **`ludic-dev shaders`** — a variant whose first file is `*.mesh` compiles that stage as a Vulkan 1.3 mesh
shader; its SPIR-V keeps the `.vert` name, so the manifest is unchanged.
- **The seam** — `gpu_has_mesh()` and `gpu_draw_mesh_tasks(x, y, z)`; a mesh program's pipeline has no
vertex input and its bindings the mesh stage. The device enables `meshShader` and `maintenance4`.
Not yet faster: at 4K on an RTX 3070 Ti the grass pass takes 5.0 ms against the chunked path's 1.7, so
`gpu_feature_implemented(GF_MESH_GRASS)` stays false and a game should not offer it as a finished setting.
- **More of the renderer's quality is a switch a game can offer.**
- `sky_set_quality(width)`: the prefiltered sky light at 256, 512 or 1024 wide (half as tall),
baked again at once.
- `post_set_msaa(samples)`: 1, 2 or 4 samples for the scene on OpenGL, remaking the screen targets in
place (`post_msaa_live()` is false on Vulkan, which has no sample counts yet); `post_free` now
frees the multisampled framebuffer too.
- `STREAM_BUDGET_US` is a variable: the microseconds a frame may spend generating streamed cover.
- `r3d_fog_scale`: a multiplier over the fog density the daylight and the weather set.
- **NVIDIA DLSS super resolution and Reflex on the Vulkan renderer** — through NVIDIA Streamline 2.14.1.
- **The loader** — on Windows `Vk.open()` takes `sl.interposer.dll` beside the executable in place of
`vulkan-1.dll` when a program asks (`Vk.sl_prefer`), falling back to plain Vulkan without it; the `sl*`
exports and feature functions are reachable from Ludic.
- **render3d** — `r3d_dlss(mode)` upscales the lit HDR frame before bloom and the tonemap, jittered on a
Halton cycle, with preset K in every mode (the default M cost 18 ms a frame at 4K on an RTX 3070 Ti);
`r3d_dlss_render_w/h` give the size to render at. `r3d_reflex(mode)` sleeps each frame and marks
simulation, submit and present. `R3D_DLSS`, `R3D_REFLEX`, `R3D_DLSS_PRESET`, `R3D_SL_LOG` for tests.
- **Commands the interposer lacks** — it exports no `vkSetHdrMetadataEXT`, `vkCmdDrawMeshTasksEXT` or
acceleration-structure command; `Vk.has` checks one and `vkGetDeviceProcAddr` reaches it.
- **Real OS threads behind `Job.*` and `Sync.*`** — `Job.parallel_for` runs work across every core.
- **`fn name`** — an expression naming a top-level function as a value, for a worker's entry point.
No closures: the data a worker needs is passed in.
- **`Job.parallel_for(count, fn work, ctx)`** — `work(i, ctx)` for every `i` in `[0, count)`, split
into chunks across a worker pool (one thread per core but one, pthreads or Win32) and the calling
thread; returns when all are done. `Job.is_worker()` says whether code is on a pool thread.
- **`Sync.*` is thread-safe** — mutexes are real mutexes, atomics are compare-and-swap, channels are
guarded, and `Sync.cpu_count()` reports the machine's cores.
- **A worker must not change the world** — `spawn` and `despawn` on a pool thread stop the program with
a located message.
- **Texture filtering and shadow resolution as settings a game can change while it runs.**
- `r3d_set_anisotropy(level)` sets the anisotropic filtering of every mipmapped texture already
loaded, not only of later uploads, on OpenGL and Vulkan.
- `shadow_set_res(size)` remakes the cascades' depth layers at 1024, 2048 or 4096 (`shadow_res`
replaces the `SHADOW_RES` constant); the lighting reads the texel size from the map.
- **`app native "<dir>"` in `package.ludic`** — `ludic bundle` on Windows copies that directory's files beside
the executable: native libraries loaded at run time (NVIDIA Streamline's DLLs, say) and their licence texts,
which cannot live in the pack.
### Fixes
- **The meadow's grass draws on the Vulkan renderer again** — the chunked grass path uploaded its `vec4` tile
table with `u_fv`, which on Vulkan copies one float per array element, so every tile had no blades.
`u_f4v` uploads arrays of `vec4`.
### Performance
- **The sky's light is baked once at start, not twice.** A game that turns the sky at boot sets
`sky_start_yaw` before `r3d_init`, and `sky_load` bakes the image-based light at that yaw; turning to
a yaw the light is already baked at (`sky_set_yaw`) no longer bakes it again. Without it, nothing
changes.
## v0.15.0 — 2026-09-15
### Features
- Vulkan for Ludic, and the start of a second renderer beside OpenGL.
- **`Vk.*`**: every command of Vulkan 1.0-1.4 and of the extensions a modern renderer is
built around (swapchain, HDR colour spaces, ray query and acceleration structures, opacity
micromaps, mesh shaders, variable rate shading, memory budget, pipeline libraries, NVIDIA
low latency, portability for MoltenVK), with every constant and every struct's size
(`<Struct>_sizeof`) and field offsets (`<Struct>_<field>`). Generated from the Vulkan
registry by **`ludic-dev vkgen`** into `runtime/native/vk_api.ludic` and `vk_thunks.ll`;
structs are plain memory filled by name with `Vk.put_i32` / `put_i64` / `put_ptr`. Every
size and offset was checked against the SDK's C headers (3128 facts). A C float is float
bits in an int; 64-bit values and non-dispatchable handles are `long`.
- **The loader is opened at run time**, never linked: `vk_win.ll` (`vulkan-1.dll`) and
`vk_mac.ll` (`libvulkan.1.dylib`, MoltenVK). `Vk.open()` returning 0 means no Vulkan, and
the program carries on. `ludicc` and `ludic build` link both files for any program that
uses `Vk.*`.
- `examples/rendering/vk_probe.ludic` reports what a machine's Vulkan can do;
`vk_compute.ludic` runs a Slang compute shader (`vk_compute.slang`) and reads the picture
back - on an RTX 3070 Ti and on an M4 Pro through MoltenVK, clean under the validation layer.
- **render3d `gpu.ludic`**: the seam between the renderer and a graphics API. Render state
and uniforms go through `gpu_*` / `u_*` and nowhere else (OpenGL frames unchanged);
`R3D_GFX=gl|vk` or `gpu_request` chooses a backend, falling back to OpenGL with a reason;
`gpu_caps_probe()` detects, on Windows, the Vulkan 1.3 floor, ray tracing, mesh shaders,
the NVIDIA RTX generation, Reflex and HDR, for a settings screen to grey out what a machine
cannot use (`R3D_CAPS=rtx50|rtx40|rtx30|amd|intel|none` pretends, for tests).
## v0.14.2 — 2026-09-14
### Fixes
- **`Input.mouse_dx` / `mouse_dy` no longer jump on the first frame or when the cursor mode changes.**
The delta was the position minus the previous frame's, and the previous position started at 0,0,
so the first frame reported the cursor's whole distance from the corner as motion, and switching
between a locked cursor's virtual reticle and the real cursor did the same. A camera that adds
`mouse_dy` to its pitch came up pointing at the ground with nobody touching the mouse. Both frames
now report no motion; every other frame is unchanged, windowed or headless, on macOS and Windows.
## v0.14.1 — 2026-09-13
### Fixes
- **A rim from `outline_model` no longer stays on after the caller stops asking** — the queue
was only emptied by the next `outline_model` call, so a game that highlights what the
crosshair is on left the last highlighted tree outlined for good once the player looked
away. `r3d_frame` now drops a batch nobody reopened this frame (`outline_frame`).
## v0.14.0 — 2026-09-13
### Features
- **Overlay text is UTF-8, and a font atlas can carry any script** — `ludic.render3d`'s `ov_text`,
`ov_text_w` and `ov_text_wrap` decode UTF-8 instead of drawing bytes 32–126, so a game can show
Turkish, German, Polish, Greek, Cyrillic or whatever its atlas holds.
- `font.json` may list the atlas's code points in `"codes"` (atlas order). An atlas without it is
read as before: consecutive code points from `"first"`, so existing ASCII fonts keep working.
- A code point the atlas lacks draws as `?`; a control character as a space; a malformed or
cut-off sequence as one `?`, so a string sliced mid-character still draws.
- `overlay_font(dir)` loads or swaps the atlas at run time, for a language that brings its own font.
## v0.13.3 — 2026-09-13
### Performance
- **Dense forest renders faster** — a depth prepass for the near tree foliage in `ludic.render3d`.
A crown of needle cards is many cut-out quads deep, and a shader that can `discard` turns
early depth rejection off, so every card behind the front one ran the full lighting shader.
In a dense stand at 3840x2160 that made the vegetation pass the largest in the frame. The near
tree LODs now write depth first with `depth.frag` (the same alpha coverage test), the terrain
beneath them is rejected before shading, and the lit pass draws them with no `discard` and an equal depth test. `R3D_NOPREPASS=1` restores the old path for comparison.
## v0.13.2 — 2026-09-13
### Fixes
- **`ludic build` links `http.ll` only into a program that uses `Http.*`.**
The check that added it grepped the IR for `@hs_`, which every program's header declares,
so the HTTP transport and Foundation were linked into everything. That cost nothing visible
on macOS and failed the link on Linux, where `ludic build`, `ludic run` and a new project all
broke in CI. It now looks for a call to `hs_send`.
## v0.13.1 — 2026-09-13
### Fixes
- **The toolchain links on Linux again** — CI builds, tests and publishes releases.
The asset-pack boot finds the directory beside the executable with `_NSGetExecutablePath`,
which Darwin's libc has and glibc does not, so every Linux link of the compiler seed failed
from the release that added packs onward: CI's build, suite and C-free bootstrap jobs, and
every `publish` run, so no release after v0.7.0 carried a Linux toolchain or was created by
CI at all. `tools/ci/linux_stdio_shim.ll` now supplies it, over `/proc/self/exe`.
## v0.13.0 — 2026-09-13
### Features
- **Windows target** — `ludicc` builds and runs headless programs on Windows, and builds itself there.
- **`--target <triple>`** — a triple naming `windows` selects the Windows runtime; without
the flag the target is the host, read at run time from `OS=Windows_NT`. The IR still
carries no triple, so clang assembles it for the machine it runs on.
- **The libc surface, in IR** — on Windows the header emits `emit_win.ludic`, which defines
the POSIX names the backend calls (`fopen`, `ftell`, `rename`, `opendir`, `mmap`,
`fmemopen`, `uname`, …) over the UCRT and Win32. Three of them fixed silent breakage
rather than link errors: `rename` onto an existing file (every `Fs.write_text` after the
first), a 32-bit `ftell`, and text-mode `fopen` rewriting `\n` as `\r\n`.
- **Known folders** — `Os.save_dir` / `config_dir` are `%APPDATA%\<app>`, `cache_dir` is
`%LOCALAPPDATA%\<app>`, `temp_dir` is `%TEMP%`, all with forward slashes; a bundle's
`home` line points at `%APPDATA%\<name>`.
- **The driver** — finds `C:\Program Files\LLVM\bin\clang.exe` when clang is not on
`%PATH%`, writes `.exe` outputs, speaks cmd.exe for its directory and cleanup commands,
and reads `argv[0]`, `%PATH%` and `$LUDIC_HOME` with either separator.
- **A window** — `win32.ll` is `cocoa.ll`'s contract over user32: the held-key set and frame
key in Ludic codes, the mouse in framebuffer pixels with raw input for cursor mode 2,
cursor hide/lock/confine that lets go when the window loses the foreground, XInput pads
in SDL order with rescaled deadzones, and the software `win_present`. `win32_gl.ll` puts
the WGL context on that window with vsync and borderless full screen; a headless build
links `gl_win_nowin.ll` instead. The process is per-monitor DPI aware.
- **Sound** — `audio_win.ll` is `audio.ll`'s contract over XAudio2: one source voice per
clip, loops, volume, rate and a balance pan through the output matrix, RIFF WAVE in PCM or
float. It reads a clip through the asset pack, so a bundled game's first `Audio.load`
succeeds - AVAudioPlayer takes a filesystem path, which is why macOS cannot.
- **The CLI on Windows** — `ludic build`, `ludic run` and `ludic-dev build` work from a Windows
checkout. Every shell command the CLI issues runs through Git for Windows' bash
(`$LUDIC_BASH` names another), `compile_app` links through `ludicc -o`, and a checkout
bootstraps from the new `selfhost/ludicc.win.seed.ll`, which `ludic-dev reseed` now
writes beside the macOS seed.
- **`ludic bundle` on Windows** — `build/<name>/` holding a GUI-subsystem `<name>.exe`, the
`game.lpak` and `packs.index`; an `.ico` beside `app icon` is compiled with `llvm-rc` and
linked in. `ludicc` gains `--gui` (no console behind the window) and `--link <file>`.
- **`Http.*` on Windows** — `http_win.ll` is `http.ll`'s contract over WinHTTP: a request
built on the game thread, the exchange on a worker thread, TLS with the system's
certificate checks, and response headers answered from the kept request handle.
- **The splash and the window icon** — decoded by WIC from the bytes in the pack. The splash
is a topmost borderless window at the artwork's own size in points times the display's
scale; `App.set_icon` sets the title bar and taskbar icon of a build with no icon resource.
- **`Gl.*` on Windows** — `gl_win.ll` makes a WGL 4.1 core context on a hidden window's DC,
and `gl_thunks_win.ll` (generated by `ludic-dev glgen` beside `gl_thunks.ll`) calls
every entry point through a table `@lgl_win_load` fills from `wglGetProcAddress`, falling
back to `opengl32.dll` for the 1.1 functions it will not return. A headless GL program
renders on the GPU there: `gl_triangle` matches the macOS frame to within one level per
channel.
### Fixes
- **Backspace fires on macOS** — the Delete key reports `Key.Backspace` (8).
AppKit gives the key marked delete (kVK_Delete, 51) the character `NSDeleteCharacter`, 127,
which is what both the held-key set and the per-frame key received, so `Key.Backspace` never
matched. `cocoa.ll` maps key code 51 to 8 in both.
- **Held arrow keys register** — `Key.Up`, `Key.Down`, `Key.Left` and `Key.Right` are 128-131.
They folded to the codes of w, s, a and d, which is what the single per-frame key
(`Input.key`) reports for an arrow. The held-key set has always stored an arrow under
128-131, so `Input.key_down(Key.Up)` tested the W bit and `Input.move_i`'s arrow half never
moved anything. `Input.key` keeps its WASD alias: a game comparing it with `'w'` still
takes the arrows.
- **`ludic build` links `Http.*`** — a program that uses the HTTP client builds through the CLI.
`compile_app` linked the OpenGL backend when a program named `@lgl_*` but never
`runtime/native/http.ll` and Foundation for `@hs_*`, so a `Http.*` program failed to link
under `ludic build` / `ludic run` while `ludicc -o` built it. It now links them the same way,
windowed and headless.
## v0.12.1 — 2026-09-13
### Features
- **ludic.render3d: `r3d_fog_base`** — the height the height fog's density is measured from (default 0, the world's y = 0). Two maps sharing one height datum, one far lower than the other, no longer give the lower one many times thicker air.
### Fixes
- **ludic.render3d: lakes are ellipses** — a water body other than the reflecting one was drawn as the whole rectangle of its bounds, so the corners between that rectangle and the lake's carved ellipse showed sheets of water over dry ground. Those bodies are now clipped to the ellipse; the reflecting body (the sea) still fills its rectangle.
## v0.12.0 — 2026-09-13
### Features
- **ludic.render3d: replace the world at run time** — a game can swap its terrain, scatter, colliders, actors and water for another map's without restarting.
- `terrain_reload(dem, emin, emax, base, ox, oz, ortho, half)` releases the current map's height field, survey, photograph and patch bounds and generates another at any `TERRAIN_HALF`; `terrain_unload()` is the release on its own.
- `scatter_clear_all()` (with `stream_clear_all()`), `actor_clear_all()` and `col_reset()` empty the scene for the next map; `mesh_free()` releases a mesh's GL objects.
- **Water bodies** — `water_body_add(level, cx, cz, ex, ez, reflect)` and `water_bodies_clear()` draw several still-water planes at their own levels; the first that reflects gets the planar reflection. `water_init` still means one reflecting plane.
- **Fix:** the terrain's patch quadtree placed patches at a fixed 32 m leaf, so any `TERRAIN_HALF` other than 4096 put patch bounds in the wrong place; leaves now scale with the map.
- **Fix:** `water_init` built a new mesh and compiled a new program on every call.
- `terrain_sea(level)` separates the sea's level (the coast, the strand, the shoreline, forest and scree shading) from the carved lake's, so a lake can sit above the sea. Unset, the sea is the lake's level as before.
## v0.11.1 — 2026-09-12
### Fixes
- **`col_resolve` no longer throws a body across the map when it starts dead centre on a
collider.** The degenerate branch — a point exactly on a circle's axis, where there is
no direction to push it — chose a unit normal `(1, 0)` and then divided it by the
clamped `d = 0.001` anyway, along with the real normals. The push came out a thousand
times too large: a body standing exactly on a 0.5 m trunk was moved about 800 m instead
of the 0.85 m that clears it.
Off-centre the arithmetic was correct, and off-centre is how anything arrives at a
trunk while walking, so it never showed up in play — only a teleport, a spawn or a
world generator placing something on an existing collider could land on the axis.
`(ex, ez) / d` is always a unit vector for `d > 0`, because `d` is its own length: there
was never anything to clamp. The normal is now built once, explicitly, and the clamp is
gone.
## v0.11.0 — 2026-09-12
### Features
- **`App.set_icon(path)`** — an icon for a binary that has no bundle to take one from.
`ludic bundle` builds an `AppIcon.icns` and macOS reads it from the `.app`, so a
shipped game has an icon. `ludic build` produces a bare executable, which has no
bundle, no `CFBundleIconFile` and therefore no icon at all — macOS draws the generic
green "exec" tile in the Dock. That is the build a developer runs all day and the one
they see every session, so "my game has no icon" is true long before it ships.
```ludic
App.set_icon("assets/app/icon.png")
```
The path resolves through the pack first and the filesystem second, like every other
asset, so the same call works packed and unpacked — it does not repeat `Audio.load`'s
trick of taking a filesystem path only. An image that cannot be found or decoded
leaves the current icon alone rather than clearing it, calling it in a bundled app is
a harmless no-op, and a headless build compiles it away to nothing.
## v0.10.1 — 2026-09-11
### Fixes
- **`Os.save_dir` / `Os.config_dir` / `Os.cache_dir` follow the platform.** They were the
macOS layout everywhere, so a game built on Linux wrote its saves to
`~/Library/Application Support` — a directory that means nothing there. Naming the
right directory is the entire reason a program calls these instead of building a path.
| | macOS | Linux |
| --- | --- | --- |
| `save_dir` | `~/Library/Application Support/<app>` | `$XDG_DATA_HOME` or `~/.local/share/<app>` |
| `config_dir` | `~/Library/Application Support/<app>` | `$XDG_CONFIG_HOME` or `~/.config/<app>` |
| `cache_dir` | `~/Library/Caches/<app>` | `$XDG_CACHE_HOME` or `~/.cache/<app>` |
An XDG variable that is set but empty falls back to the default, as the spec requires.
macOS is unchanged to the byte — `save_dir` and `config_dir` stay the same directory
there, because Apple's home for a config file that is not an `NSUserDefaults` plist is
Application Support too, and a shipped game's settings must not move out from under it.
On Linux XDG separates the two and so does this.
One consequence worth stating plainly: a game already shipped on Linux was writing to
the old macOS-shaped path, and this moves it. Those files are not migrated for you —
they are wherever `~/Library/Application Support/<app>` ended up on that machine, and a
game that has Linux players should move them once on startup.
## v0.10.0 — 2026-09-11
### Features
- **`.packignore`** — keep build artefacts out of the shipped asset pack.
A pack root is packed wholesale, so everything a model or texture pipeline leaves
beside its output ships too: preview renders, bake intermediates, the `.blend` a
`.gltf` came from. Nothing errors and nothing looks wrong — the app is just bigger
than the game. The only way out was naming every file in `package.ludic`, a list
that goes stale the day someone adds a texture.
Put a `.packignore` beside the assets instead. The rules are **gitignore's**, down
to the parts people rely on without thinking about them: patterns anchored by a
slash or floating without one, `preview/` for directories only, `*` and `?` stopping
at a separator where `**` crosses it, `[a-z]` classes, `!` re-includes with the last
line winning, a deeper file beating a shallower one, and no re-including out of an
ignored directory.
`ludic pack` reports what it left out, and `--no-ignore` packs everything so you can
see what a rule costs. `ludic bundle` gathers the same way. `.packignore` itself is
never packed, and the file only governs your own roots — a package's resources, such
as the renderer's shaders, are added afterwards and cannot be excluded by accident.
## v0.9.1 — 2026-09-11
### Fixes
- **`outline_model`'s batch survives the whole frame.** A frame is drawn by more than one
pass — a shadow map, a water reflection, the scene — and `actor_draw` runs in each of
them. An Actor's rim survives that because it is a field on the actor; the queue did not,
because the first pass to run emptied it, so a queued outline was drawn into whichever
target happened to come first and was gone by the time the scene was drawn. Nothing
appeared, with every uniform, matrix and mesh correct.
A flush now *closes* the batch rather than clearing it, and the next `outline_model`
opens a new one: every pass in a frame sees the same requests, and a caller still needs
no frame hook.
## v0.9.0 — 2026-09-11
### Features
- **`Audio.play_at(id, gain:, pitch:, pan:)`** — fire a one-shot with its own gain, pitch
and stereo position, leaving the master settings alone.
`Audio.volume` and `Audio.pitch` are global: they are there so a player can turn the game
down, and a game that used them to place a sound in the world would be fighting its own
options screen. This is the per-voice version, and it is what distance attenuation is made
of — a 3D game works out how far away a sound is and which side it is on, and says so.
`gain` rides on top of the master volume, so the options screen still wins; `pan` runs
-1 (hard left) to 1 (hard right).
A loaded sound is still one player, so firing the same handle again restarts it rather
than layering a second copy. Load a handle per variant when several need to overlap.
- **`ludic.render3d`: `outline_model(model, mat, width, r, g, b)`** — a rim around something
that is not an Actor.
An Actor has had an `outline` since the outline pass landed, and everything else in a
scene had nothing: the pass walked the actor list and stopped. Instanced scatter was the
gap that mattered, because a game that highlights whatever the crosshair is on could
highlight every object in the world *except* the twenty thousand most common ones — the
trees.
`outline_model` queues a model at a transform from anywhere in the frame and the outline
pass flushes it alongside the actors', which is what gets the depth test right: the rim has
to be drawn after the scene it is tested against, and a caller does not control pass order.
A layer whose vertex shader moves its instances — wind, or standing them on the drawn
terrain — should be handed the transform that shader arrives at.
- **`ludic.render3d`: `terrain_coast(cx, cz, margin, fall)`** — the other way to make an
island, and the one a real survey wants: the sea goes around the survey's **own edge**
rather than being cut out of the middle of it.
`terrain_island` measures a radius out from a centre, which suits a made-up map and
drowns most of a real one — an 8 km mountain survey loses two thirds of itself to make an
island of the rest. `terrain_coast` measures inward from the boundary instead: everything
the data covers stays land, the outer `margin` metres go under water, and the `fall` metres
inside that are scaled down into it. The band is wobbled by low-frequency noise, so what
comes out is headlands and bays rather than the square the data arrived in.
Both modes also gained a **strand**. Scaling alone hands a 500 m mountainside a 40-degree
plunge into the sea, which is a cliff coast and nothing else; the last few metres of height
either side of the water line are now compressed, which stretches them out horizontally
into beach and shallows. Inland lakes are untouched — they have their own bed.
## v0.8.0 — 2026-09-10
### Features
- **A boot splash**, customised by the game. `app splash` in `package.ludic` names
the artwork and `app splash_bg` the colour behind it; the runtime raises a
borderless window from a constructor that runs before `main`, reading the image
out of the asset pack, so it is on screen while the process is still starting
rather than after the slow part it exists to cover. Nothing hides it
automatically - only the game knows when its first real frame is ready, so the
game calls `App.splash_hide()`.
- **Asset packs.** `ludic pack` writes every asset a game opens into one `.lpak`
beside the binary, and the runtime mounts it before `main`. Nothing about how a
game is written changes: the pack is spliced in at `file_open`, the one place
every asset comes through, so `gltf_load`, `tex_load`, `Audio.load`,
`Fs.read_text` and the renderer's own shader loads all find it without knowing
it exists. `Fs.exists` and `Fs.size` consult the packs too. Entries are stored
rather than compressed and the pack is `mmap`'d, so 165 MB of terrain costs one
syscall at startup and pages in only what is touched. Several packs can be
mounted in order, and a later one shadows an earlier one - which is how a patch
replaces individual files without rewriting the base pack. See `docs/SHIPPING.md`.
- **`ludic bundle`** turns a built game into a real macOS `.app`: `Info.plist` and
`PkgInfo` from the manifest, an `.icns` built from one source PNG at every size
macOS asks for, the asset pack in `Contents/Resources`, and an ad-hoc signature
so it launches on Apple silicon. Everything comes from `app` lines in
`package.ludic`, so the command takes no arguments. A bundled game is also moved
to `~/Library/Application Support/<name>` at startup, because Finder starts a
`.app` with its working directory at `/` where no save could ever be written.
- **`ludic.render3d`: `terrain_island(cx, cz, r, fall)`** keeps land out to `r` and then
scales the terrain down into the water over `fall` metres. Scaling rather than blending
to a fixed bed is what makes the coastline come out of the terrain already there: low
ground becomes beach and shallows, high ground becomes cliff. `r = 0` leaves the survey
untouched.
- **`ludic.render3d`: a rim outline on an actor.** An actor gains `outline` (metres of
rim) and `ocol` (its colour). The pass draws the model again with its vertices pushed
along their normals and its front faces culled, so only the far side of the swollen
shell survives - a silhouette exactly `outline` wide, depth-tested against the scene, so
anything standing in front of the actor hides its rim too.
- **`ludic.render3d`: the moon has a phase.** One number, 0 new .. 0.5 full .. 1 new
again, decides the disc's terminator, how much of its light reaches the ground, and
where in the sky it rides - a full moon rises as the sun sets, a new moon travels with
the sun and is never seen. A new moon is now a properly dark night, which is what makes
a carried light worth having.
### Fixes
- **`ludic.render3d`: water read the window's size, not the frame it drew into.** The
water shader's `u_screen` and the reflection target were sized from the drawable, but
`gl_FragCoord` there runs over the scene target. They match only at a render scale of 1;
below that the refraction and depth reads landed in the wrong corner of the frame and the
lake showed a squashed copy of it instead of its own bed.
## v0.7.0 — 2026-09-10
### Features
- **A game can use the renderer from outside this repository.** `ludic.render3d` reads two
things from disk at run time — its GLSL, and the scanned CC0 materials — and both were
found only by a path relative to the working directory, so the renderer worked in a
Ludic checkout and nowhere else. A game living in its own repository now needs to copy
neither.
**The shaders come from the package**, wherever the package is. They belong to
`ludic.render3d` and ship with it, so the renderer looks for them beside the project
first (a Ludic checkout, where they are under `packages/`) and then under the install
root, `$LUDIC_HOME/packages/ludic.render3d` — the same place the compiler already
resolves `import "ludic.render3d/r3d.ludic"` from. Nothing to vendor, and no version of
the shaders that can drift from the version of the code that compiles them.
**`ludic assets [--force]`** fetches the scanned materials and the HDRI sky into
`assets/polyhaven/` of whatever project you run it in. The list of what to fetch is the
renderer's own — the renderer decides which materials it wants — so it moved out of the
repository's `assets/` and into the package as
`packages/ludic.render3d/assets.manifest`, where it ships with the toolchain. A game
does not keep its own copy of that list and so cannot fall out of step with the
renderer's material set. `ludic dev fetch-assets` is the same command from a checkout.
**A URL-shaped module built its binary into directories.** `project_name` took
everything after the last dot of the manifest's module path, which for a package
identified the way the package manager identifies them —
`git.host.io/user/name` — is inside the *host*: the build wrote
`build/io/user/name` instead of `build/name`. The last path segment comes first now, and
a dot inside that segment still separates namespace from package, so `ludic.render3d`
still builds as `render3d`.
**The camping game has moved out** to its own repository —
[Maroon Lake](https://git.workshopsoft.io/workshopsoft/maroon-lake) — taking
`examples/rendering/valley.ludic`, `hiker.ludic`, `camp/`, and 175 MB of survey data and
scanned kit with it. It was here as a demo of the renderer and became a game, and an
engine repository should not be carrying a game's assets. It is now the first consumer
of everything above, which is the point: what the renderer needs a game to be able to do,
it can now do from outside. `examples/rendering/smooth.ludic` stays as the renderer's
example in this tree.
- **OpenGL for Ludic, and a 3D renderer on it.** `Gl.*` binds the whole OpenGL 4.1
core API — every `gl*` entry point of the platform `gl3.h` as `Gl.<snake_name>(…)`
with every `GL_*` constant, generated by `ludic-dev glgen` with per-call ABI thunks
(`runtime/native/gl_thunks.ll`; float/double parameters take `fixed`). Windowed
builds get an `NSOpenGLContext` on the existing window at Retina resolution
(`cocoa.ll`); headless builds render into an offscreen CGL context, so a program
that uses `Gl.*` renders and screenshots identically under the test harness.
`Gl.open / swap / screenshot / program / vao / floats …` cover the glue, and the
IEEE-float helpers (`f_add`, `mem_put_f32`, …) let Q16.16 programs fill real
float vertex and uniform data. `Gl.*` links `gl.ll + gl_thunks.ll + OpenGL.framework`
only when used; every other build is byte-identical.
The `ludic.render3d` package (`packages/ludic.render3d`) is a physically based
renderer written on `Gl.*`: HDRI sky with image-based lighting (irradiance, GGX
prefiltered, split-sum BRDF, sun extracted from the map), GPU-generated terrain
with scanned PBR materials (stochastic anti-tiling, triplanar rock, slope/altitude
splatting), cascaded shadow maps with PCF and world-unit biasing, a glTF loader
for scanned models, instanced vegetation with baked impostors, procedural grass
and lupines with wind and translucency, 4x MSAA with alpha-to-coverage, SSAO,
still water, cloud shadows, aerial perspective, an HDR pipeline with bloom,
auto-exposure, ACES tonemapping, grading, sharpening and grain. See
`examples/rendering/smooth.ludic`, and the Maroon Lake game (git.workshopsoft.io/workshopsoft/maroon-lake) for a
game built on it. The renderer's CC0 materials are fetched with `ludic assets`.
**16-bit PNGs**: the renderer's texture loader keeps 16-bit samples (normal /
displacement maps) and uploads them as `RGB16` / `R16`.
### Fixes
- **Resizing the window (or entering fullscreen) no longer empties the world.** It left
`gl error 1286` — `GL_INVALID_FRAMEBUFFER_OPERATION` — on every frame from there on, with
the terrain, the trees, the grass and the water gone and only the sky drawn.
The sun-visibility pass added in `changes/terrain-perf.md` borrows the depth buffer the
frame is about to be drawn with, so that rasterising it doubles as a depth prepass. It
was storing that borrowed texture in its `Target`, and a `Target` deletes whatever its
`depth` names when it is freed. On the first resize the sequence was: `post_free` deletes
the frame's depth texture, `post_init` immediately makes the replacement — and GL hands
back the name that was just freed — and then the visibility target, rebuilt for the new
size, deleted that name believing it was its own. The scene framebuffer lost its depth
attachment. What is left is a colour-only framebuffer, which is *complete*, so drawing
carried on with no depth test at all: the sky is a fullscreen quad drawn last, and with
nothing left to fail against it painted over the entire valley. The 1286s came from the
passes whose own attachment now named a texture that no longer existed.
A borrowed attachment is never written into the target now, and the frame's depth is
attached afresh at the start of each pass — it is a different texture every time the
screen-sized buffers are rebuilt, and one `glFramebufferTexture2D` per pass is cheaper
than any scheme for noticing that it changed.
`R3D_RESIZE_AT=<frame>` rebuilds every screen-sized buffer from that frame on, cycling
through four drawable sizes every few frames. A window cannot be resized in a headless
run, so this is the only way to reach the path; it reproduced the fault in one frame and
now runs twenty resizes, with the fly camera and with the game, without an error.
- An upgrade keeps the package store. The installer replaces the whole install
root, and `ludic add` caches packages in `~/.ludic/store` — so re-running the
one-liner deleted every package a project had fetched. The store is carried
across now; everything else in the root belongs to the toolchain and is replaced.
### Performance
- **Ground cover stops re-growing itself.** Walking a streamed world hitched, and the
hitch got worse the longer you played. Measured in the Maroon Lake game, with a new hitch
report rather than guessed at.
**The chunk cache had a cliff, not a slope.** A stream cached 4096 chunks and then
stopped remembering: past that the chunk was generated, used for one frame and thrown
away, so every ring walk regenerated it, for the rest of the session. It arrives after
enough of the map has been walked — six evictions' worth over seven kilometres, so an
ordinary session reaches it — and it is the point where cover starts visibly re-growing
as you turn. `stream_evict` now drops the half of the cache nobody has asked for in the
longest time (chunks carry the walk that last wanted them) and rebuilds the index over
what is left. Over a 7 km traversal: generation total **9073 ms → 2230 ms**, the worst
single frame's generation **11.4 ms → 3.1 ms**, median frame 10.8 → 9.0 ms. With a cache
deliberately sized to saturate early, the same run goes from 2748 frames generating to
1548, and from a 13.3 ms median to 9.2. `R3D_NOEVICT` restores the old behaviour for
comparison, `R3D_STREAM_CAP=<n>` sets the cache size.
The other half of that hitch was in the game's own cover generator, and went with it to
the Maroon Lake game's repository: its candidates were paying for a second noise field, four
height samples and a path distance before the drift field that rules out most of the
meadow — 2341 µs → 518 µs per chunk, bit-identical output. Worth repeating in any
generator: a `stream_fill` is called for tens of thousands of candidates per chunk, so
the order of its tests is most of its cost.
**The hitch report** (`R3D_PROF=1`) is what found both. It prints the slowest frames of
the run with what was in each: CPU versus GPU wait, cover generated, instance bytes
uploaded, the game's own tick, and the renderer phase that took longest. Alongside it,
per-chunk generation cost by stream and band, a census of what the caches hold, and a
stutter figure — the frame time a run spent beyond 1.2x its own median — because a mean
cannot show a hitch and a maximum is one unlucky frame.
It also found two content bugs in the game it was measured on, which is the report doing
its job: a cover stream whose placement rule never fires still pays full generation cost,
and the census makes that visible — 3364 cached chunks holding zero instances.
- **The ground costs half what it did.** Measured in the Maroon Lake game, the terrain was 10.3 ms of
a 22.2 ms frame; it is now 5.4 ms of 16.4 ms — 45 fps to 61 fps at 1080p, with the
frame otherwise unchanged (every viewpoint tested stays above 54 dB PSNR against the
old renderer, with no channel differing by more than 7/255).
**Measure by frame time, not by the pass timers.** `R3D_PROF`'s per-pass
`GL_TIME_ELAPSED` queries cannot be trusted on this driver: with the ground's shading
work removed the terrain query fell from 10.5 ms to 1.3 ms while the frame time did
not move at all. Every number above and below is a median real frame time, taken by
switching one thing off (`prof_ft_report`); the pass timers are still printed, and are
still useful for spotting a pass that appears out of nowhere, but they cannot size one.
`R3D_NOTERRAIN` skips the ground, `R3D_TNEARONLY` / `R3D_TFARONLY` draw every patch
with one tier's program, and `R3D_RES=<w>x<h>` renders at another size — the three
switches that say whether a cost is the ground, which tier it is in, and whether it is
pixels at all.
**Each detail tier is its own program.** `terrain.frag` holds a detailed near tier and
a cheap far one and chose between them per pixel, so every pixel of the valley walls
was compiled — and scheduled — for a near path it never ran. CDLOD selection now knows
which tiers a patch can contain: one that never comes within the split draws with
`FAR_ONLY`, one wholly inside it with `NEAR_ONLY`, and only the ring of patches that
straddle the band needs the program that holds both and cross-fades. Pixel-identical,
and it makes the tiers separately measurable: the near tier costs 7.5 ms over a whole
frame, the far tier 2.3 ms.
**The sun visibility is its own pass** (`tersun.frag`). The same CDLOD patches are
rasterised once into a screen-sized R8 buffer that holds nothing but each ground
pixel's sun visibility, and `terrain.frag` fetches it by fragment coordinate. The pass
costs 0.27 ms, shares the frame's depth buffer so it doubles as a depth prepass, and
takes the cascade read out of the shader that covers the screen. It picks its tier —
filtered PCF near, a single tap far — over the same cross-faded band the ground uses,
so the boundary is not a contour you can find on the hillside.
**Nothing is sampled for a weight of zero.** The ground sampled all four of its
materials for every pixel and then blended three of them at zero: a meadow pixel took
nine taps of triplanar rock, a cliff pixel nine taps of stochastic grass, and every
pixel in the valley took the snow tile and the four noise fields behind the lake's
shore wash — a wash that is a hairline along one shore within 120 m of the camera. The
survey photograph's classification now runs first, because it is what decides which
materials are present; each material block sits behind its own weight; the ridge field
that ragged the snow line is skipped 160 m below it, where it cannot change anything;
and inside the stochastic blend a cell's rotation, offset and rotated gradients are
computed inside its own test, so a cell whose sharpened weight rounds away costs
nothing. All of it exact where the weight is zero, and it is most of the win.
Material sampling is what remains (2.8 ms of the 5.4): scanned 2K tiles taken at 16x
anisotropy on ground seen at a grazing angle. `R3D_ANISO=<n>` sets the filter (the
default is unchanged at 16; 4 is worth 1.0 ms and 1 is worth 1.7 ms).
The shadow pass is 1.2 ms of the frame and has nothing to give: re-using the far
cascades between frames — their windows are snapped to a 14 m and a 64 m grid — is
worth 0.15 ms standing still and nothing while walking, so it is not in the tree.
## v0.6.1 — 2026-09-05
### Fixes
- **`ludic new` scaffolded a project that would not compile.** The project name went
straight into the `program <Name>` identifier, so `ludic new my-game` wrote
`program My-Game` — a subtraction — and the first `ludic run` failed with
`expected '{', got '-'`. A name is now turned into a valid identifier
(`my-game` → `MyGame`, `2048` → `Game2048`), and a name that cannot be a
directory or a package is refused with the rule rather than mangled.
Found while auditing every command's flags and arguments, along with:
- **Unknown options are errors.** `ludic build --headles` silently built a
windowed binary; `ludic build -o` with no path silently ignored it. Both now
say what is wrong and exit non-zero.
- **`ludic fmt` formats in place**, as its help always claimed — it was printing
the file to stdout and changing nothing. `ludic fmt --check` reports drift
without writing, for a hook or CI.
- **`ludic test nosuch.ludic`** says the file does not exist instead of passing it
to the compiler.
- **`ludic build-lib`** reported failures as a shell syntax error (an
interpolation written in a non-interpolating string), and its no-argument
auto-detection picked up `package.lock.ludic` as a module to compile.
- Error messages that still began with `x:` — the CLI's old name — now say
`ludic:`.
## v0.6.0 — 2026-09-05
### Refactoring
- **`ludic` is only the language's command line now.** The toolchain's own tasks —
building the compiler from its IR seed, the regression suites, the docs site,
releases — moved out of it into a separate `ludic-dev` binary that is built from
a checkout and is not part of an install.
- **`ludic help` is what a user can actually do**: `new`, `run`, `build`, `test`,
`add`, `fmt`, `lsp`, `doctor`, `upgrade`. No section about a repository they do
not have. Typing `ludic dev …` says where those tasks went rather than failing
as an unknown command.
- **`ludic dev <task>` becomes `ludic-dev <task>`** for contributors; every task
is otherwise unchanged. The bootstrap is now
`bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev && bin/ludic-dev build`.
- The shipped binary drops from ~880 KB to ~190 KB, since none of the release,
docs-generation or bootstrap machinery is linked into it any more.
### CI
- The docs deploy triggers on a change to `install.sh`. The site publishes the
installer, but the workflow's path filter did not mention it — so a release that
only fixed `install.sh` left the old script live at the URL the landing page
tells people to pipe into `sh`.
## v0.5.2 — 2026-09-05
### Fixes
- The installer also covers a non-login interactive `bash` — the shell most Linux
terminal emulators start, which reads only `~/.bashrc`. That file is now created
when it is missing (its presence changes nothing else about how bash starts),
while `~/.bash_profile` is still only appended to when it already exists, since
creating that one would stop bash reading `~/.profile`.
## v0.5.1 — 2026-09-05
### Fixes

View file

@ -25,8 +25,8 @@
> ```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/
> bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
> 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
@ -73,9 +73,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` |
@ -323,7 +320,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/ludic dev test`
asserts exactly that, which is a much stronger check on the backend than
"it started".
@ -337,13 +334,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/ludic dev 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/ludic dev build-cli`) accept:
```
<file.ludic> the program to compile (first non-flag argument)
@ -353,7 +350,6 @@ 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

View file

@ -19,24 +19,23 @@ 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
bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic
```
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/ludic`, the CLI — the same binary users install, which also
carries the toolchain's own tasks under `ludic dev` and 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 dev build # the whole toolchain into bin/ (ludicc, ludic, ludic-fmt, ludic-lsp)
bin/ludic dev help # every contributor task
bin/ludic help # what a user of the language sees
```
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 `ludic dev` from the repository root, so `assets/` and `selfhost/`
resolve. (A checkout is an install root: `bin/` beside `runtime/` and
`packages/`, exactly the shape `install.sh` lays down under `~/.ludic`. That is
why the same binary serves both.)
## The development loop
@ -44,17 +43,17 @@ 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/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
```
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 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
```
@ -68,7 +67,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 `bin/ludic dev docs-gen --out build/pages && bin/ludic dev docs-check build/pages` — 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.
@ -83,22 +82,22 @@ Releases are changeset-driven. Every user-facing change ships with a changeset
(step 5 above). Read the next release before cutting it:
```bash
ludic-dev release --dry-run # render the CHANGELOG section, write nothing
ludic dev release --dry-run # render the CHANGELOG section, write nothing
```
Then cut it:
```bash
ludic-dev release [major|minor|patch] # omit the level to derive it from the changesets
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
`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`
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
@ -114,10 +113,10 @@ macOS artifacts cannot be produced on the Linux runner — a `darwin-arm64` buil
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:
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
FORGEJO_TOKEN=… ludic dev publish v0.4.0
```
Checksums are one `.sha256` file per artifact rather than a single `SHA256SUMS`,
@ -139,14 +138,14 @@ 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`
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` |
| `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 |
@ -156,10 +155,9 @@ copies it in; `docs-check` fails without it). Change the host in:
`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 binaries** `ludic`, `ludicc`, `ludic-fmt`, `ludic-lsp` — `build.ludic`'s
`cmd_dev_build`, the release staging in `release.ludic`, `install.sh`, the
editors' executable-name lists.
- **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`,
@ -169,11 +167,11 @@ copies it in; `docs-check` fails without it). Change the host in:
- **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`.
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
`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.
@ -241,7 +239,7 @@ 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/ludic dev test` (and `bin/ludic dev 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.

File diff suppressed because it is too large Load diff

View file

@ -60,9 +60,9 @@ ludic run # compiles src/main.ludic and opens a native window
```
`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:
and a test. `ludic build` stops at the binary — one self-contained executable
with nothing to ship beside it. Rendering is deterministic, so a frame can be
produced without a window, which is what CI diffs:
```bash
ludic test
@ -77,18 +77,16 @@ 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:
Contributors work from the repository, where the same CLI carries the toolchain's
own tasks under `ludic dev`. Bootstrapping is the only step Ludic cannot do for
itself, since compiling Ludic needs a compiler — clang assembles the checked-in
IR seed, and that compiler builds the rest:
```bash
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/ludicc tools/ludic-cli/main.ludic -o bin/ludic
bin/ludic dev build # -> bin/{ludicc,ludic,ludic-fmt,ludic-lsp}
bin/ludic dev test # the regression suite
```
## The language
@ -114,28 +112,6 @@ bin/ludic-dev test # the regression suite
[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.
```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
```
Nothing about how the game is written changes. `gltf_load("assets/kit/hiker",
…)` reads a file during development and a run of bytes inside the bundle once
shipped, and cannot tell which — the pack is spliced in at `file_open`, the one
place every asset in a Ludic program comes through. A bundled game also gets a
boot splash it controls (`App.splash_hide()`) and a writable home under
Application Support, because Finder starts a `.app` at `/` where no save could
be written.
Without a pack beside it — which is every `ludic run` — nothing mounts and every
open goes to the filesystem exactly as before. See [docs/SHIPPING.md](docs/SHIPPING.md).
## Packages
Dependencies are identified by URL, resolved with minimal version selection, and
@ -144,7 +120,6 @@ cached in a content-addressed store:
```bash
ludic add git.workshopsoft.io/user/pkg # resolve, fetch, link into ludic_modules/
ludic get # install from package.ludic, write the lock
ludic remove git.workshopsoft.io/user/pkg # the inverse of add
ludic verify # check locked packages against the store
```
@ -179,7 +154,7 @@ 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).
Releases follow SemVer and are cut from changesets by `ludic-dev release`, then built
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).
## Contributing

View file

@ -1 +1 @@
0.22.0
0.5.1

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. `ludic dev release` consumes every changeset here into a new
`CHANGELOG.md` section, bumps `VERSION`, and deletes the consumed files.
## Format
@ -14,7 +14,7 @@ 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 `ludic dev release <level>`
overrides it).
- `type:` — the Conventional Commit type (`feat`, `fix`, `perf`, `docs`, …). It
decides which group the change lands in: `feat` → **Features**, `fix` →
@ -47,5 +47,5 @@ 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
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.

View file

@ -1,10 +0,0 @@
bump: minor
type: feat
**`ludic remove <module>` and `ludic get --json`.** `remove` is the inverse of `add`: the `require`
leaves `package.ludic`, the lock keeps what the remaining requires still reach (read from the store's
copies of each package's manifest, no network), and each package leaving the lock loses the
`ludic_modules/` link `add` made - never the shared store entry. A module that is not required is
refused; source still importing a removed package is a warning. `ludic get --json` prints what the run
changed in the lock as one JSON object on stdout - `{"added", "removed", "changed", "unchanged"}`, an
entry as the lock records it (`name`, `version`, `hash`, `kind`, `provides`), a change as `{name, from,
to, from_hash, to_hash}` - with the resolver's own lines on stderr.

View file

@ -1,7 +0,0 @@
bump: minor
type: feature
**Ports: `port Clock { now: fn() -> int, day: fn() -> int = fn first_day }` in a module and
`bind Clock { now: fn game_hours }` where the program is put together.** A module asks for what it
needs through `Clock.now()` without naming - or `uses`-ing - the module that answers. A port used
and never bound, a bind that leaves out a member with no default, a member the port does not have
and a second bind are refused; the binder must see the (exported) port.

View file

@ -1,7 +0,0 @@
bump: minor
type: feature
**A module's private records and events are its own.** A `property` or `event` a module does not
export no longer collides with the same spelling in another module: each private one is compiled
under its module's name and its module's types, `new`, `emit` and `@On` follow it. Exported ones are
one namespace, and two events of one spelling are now refused (the first used to take the other's
emits silently). Generic records, entity components and component or view records stay global.

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