`ludic help` ended with a section titled "contributing to the toolchain itself", listing bootstrap, reseed, docs-gen and release tasks. None of that is available to someone who installed the language — those tasks need the repository — so the shipped tool was advertising work its user cannot do, in a namespace they have to read past to find `new` and `run`. The tasks move to a second program, dev.ludic -> bin/ludic-dev, built from a checkout and excluded from every release artifact. `ludic` keeps the project and package commands and nothing else; `ludic dev …` now explains where the tasks went instead of failing as an unknown command. What this shook out: the two programs share prelude/build/project/pkg, so the helpers each had accreted in whichever file first needed them — cc(), ensure_ludicc, the string functions, title_case, cmd_version — moved to where both can see them. The argument-shift indirection added for the `dev` namespace is gone with the namespace, so commands read argv directly again. `ludic-dev test` asserts the split rather than trusting it: the staged install must build a project, and `ludic dev build` there must fail while naming ludic-dev. install.sh keeps building older tags, whose bootstrap goes through main.ludic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
378 lines
17 KiB
Markdown
378 lines
17 KiB
Markdown
# Compiling Ludic
|
||
|
||
> **Note:** `ludicc` is **written in Ludic** (`selfhost/*.ludic`) and built from a
|
||
> checked-in IR seed — the C compiler this document once described has been
|
||
> deleted. The native pipeline below (Ludic → LLVM IR → object → binary) is
|
||
> unchanged. `ludicc` drives clang itself (via an `os_system` intrinsic), so
|
||
> `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly. `--fmt` is
|
||
> reimplemented as a lex+parse gate (the doc-check hook). The `--target`/
|
||
> cross-compile and `--shared` paths are still features of the old C driver not
|
||
> yet re-implemented on the self-hosted toolchain. See the
|
||
> [Bootstrap deep-dive](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Bootstrap) §5.7 on the wiki.
|
||
>
|
||
> Most people never invoke `ludicc` directly: the `ludic` CLI drives it.
|
||
>
|
||
> ```bash
|
||
> curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh # the toolchain, into ~/.ludic
|
||
> ludic new mygame && cd mygame
|
||
> ludic run # compile + run
|
||
> ludic build --headless # compile, deterministic render
|
||
> ```
|
||
>
|
||
> From a clean checkout, the compiler and the CLI come up in two lines and the
|
||
> CLI does the rest (run it from the repository root):
|
||
>
|
||
> ```bash
|
||
> # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/ludic
|
||
> mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc
|
||
> bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev
|
||
> bin/ludic-dev build # the whole toolchain into bin/
|
||
> # (ludicc, ludic, ludic-fmt, ludic-lsp)
|
||
> bin/ludicc examples/games/snake.ludic -o bin/snake # the compiler, directly
|
||
> bin/ludic build examples/games/snake.ludic # or through the CLI
|
||
> bin/ludic help # every command
|
||
> ```
|
||
>
|
||
> A `.ludic` file with handlers is a game and links windowed by default;
|
||
> `--headless` and `--windowed` force the mode. The engine runtime
|
||
> (`runtime/native/cocoa.ll`, the spliced `runtime/native/*.ludic`) and the
|
||
> bundled `ludic.*` packages are found under the **install root**: `$LUDIC_HOME`
|
||
> if set, otherwise derived from the binary's own location — the parent of its
|
||
> `bin/` directory, which is both `~/.ludic` for an install and the repository
|
||
> root for a checkout. `$LUDIC_CC` overrides the assembler/linker (default
|
||
> `clang`).
|
||
|
||
|
||
`ludicc` is a compiler, not a translator. It lexes, parses, checks and lowers
|
||
Ludic to **LLVM IR itself**, then hands that IR to the system toolchain to be
|
||
assembled and linked. There is no C in the middle: no generated `.c` file, no C
|
||
runtime compiled alongside your game, and no transpiling step you could inspect
|
||
and find your program rewritten in another language.
|
||
|
||
```
|
||
app.ludic
|
||
│ ludicc — lex, parse, lower (selfhost/frontend/*.ludic,
|
||
▼ selfhost/backend/*.ludic)
|
||
app.ll LLVM IR: your handlers, your properties, your runtime
|
||
│ IR assembler (selfhost/main.ludic drives $LUDIC_CC)
|
||
▼
|
||
app.o Mach-O / ELF / COFF object code
|
||
│ system linker
|
||
▼
|
||
app or libapp.dylib / .so / .dll
|
||
```
|
||
|
||
`clang` appears in that pipeline twice — as the IR assembler and as the linker
|
||
driver — which is the same role `rustc` and `swiftc` give it. Set `LUDIC_CC` to
|
||
point at a different LLVM toolchain if you have one.
|
||
|
||
## Artifacts
|
||
|
||
| you want | command |
|
||
| --- | --- |
|
||
| 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` |
|
||
| 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` |
|
||
|
||
† `--shared`, `--target`/cross-compile, `-c` and the wasm path were features of
|
||
the old C driver and are **not yet re-implemented** on the self-hosted toolchain
|
||
(see the note at the top). The rows above the line work today via the
|
||
self-hosted `ludicc`.
|
||
|
||
`bin/ludic build` wraps the common cases:
|
||
|
||
```bash
|
||
bin/ludic build examples/games/snake.ludic # -> build/snake (native)
|
||
bin/ludic build examples/library/combat.ludic --lib # -> build/libcombat.* (library)
|
||
bin/ludic build examples/games/snake.ludic --headless # -> build/snake_headless (out.ppm)
|
||
bin/ludic build examples/games/snake.ludic --web # -> build/web/ (browser)
|
||
```
|
||
|
||
The `--lib` and `--web` targets were part of the old C driver and are **not yet
|
||
re-implemented** on the self-hosted toolchain — `bin/ludic build` supports the native
|
||
windowed and `--headless` builds today.
|
||
|
||
## Programs and libraries
|
||
|
||
> **Not yet on the self-hosted toolchain.** `--shared` and the `nm`/library
|
||
> workflow below describe the old C driver's behavior; the self-hosted `ludicc`
|
||
> builds executables only for now. The `@export function` semantics are
|
||
> unchanged — only the packaging step is pending.
|
||
|
||
A source file opens with `program Name { … }`.
|
||
|
||
* A program with **handlers** is a game: it gets the phase-ordered frame loop
|
||
(`Start`, then `Input → FixedUpdate → Update → LateUpdate → Render` each tick).
|
||
* A program with only an **`entry`** block is a tool: it runs `entry` and exits.
|
||
* Either kind can be a library: only its `@export function`s become public
|
||
symbols; everything else stays private.
|
||
|
||
```ludic
|
||
# doc-check: skip — illustrative: elided body
|
||
program Combat {
|
||
@export function damage(attack: int, armour: int, roll: int) -> int { … }
|
||
function curve(level: int) -> int { … } # private: not a symbol
|
||
}
|
||
```
|
||
|
||
```bash
|
||
ludicc examples/library/combat.ludic --shared -o build/libcombat.dylib
|
||
nm -gU build/libcombat.dylib
|
||
# T _damage T _hits_to_kill T _xp_for (no _curve)
|
||
```
|
||
|
||
Those are ordinary C-ABI symbols, so anything that can call a shared library can
|
||
call Ludic. To call them from another Ludic program, declare them and link:
|
||
|
||
```ludic
|
||
extern function damage(attack: int, armour: int, roll: int) -> int = "damage"
|
||
```
|
||
|
||
```bash
|
||
ludicc examples/library/arena.ludic -o build/arena -Lbuild -lcombat
|
||
```
|
||
|
||
Libraries are linked as `@rpath/…` (`$ORIGIN` on Linux) and executables search
|
||
next to themselves, so a built pair keeps working when you move it.
|
||
|
||
## Cross-compilation
|
||
|
||
> **Not yet on the self-hosted toolchain.** `--target` and `-c` were old
|
||
> C-driver flags; the self-hosted `ludicc` builds only for the host today. The
|
||
> section below records the intended design — object code for ELF, COFF and
|
||
> Mach-O from one source — which the IR pipeline already supports in principle.
|
||
|
||
`--target` takes an LLVM triple and retargets the whole pipeline:
|
||
|
||
```bash
|
||
ludicc game.ludic --target x86_64-unknown-linux-gnu -c -o game-linux.o
|
||
ludicc game.ludic --target aarch64-unknown-linux-gnu -c -o game-arm64.o
|
||
ludicc game.ludic --target x86_64-pc-windows-msvc -c -o game-win.o
|
||
```
|
||
|
||
Object code for ELF, COFF and Mach-O comes out of the same source with no
|
||
per-platform branches in the compiler. Linking a foreign target additionally
|
||
needs that platform's linker and sysroot, as with any cross toolchain.
|
||
|
||
## The runtime is written in Ludic
|
||
|
||
`runtime/native/core.ludic` implements the framebuffer, `fill_rect`, the 5×7
|
||
bitmap text, the registers, the RNG, input and the frame dump — in Ludic. ludicc
|
||
splices it into every native build, and a builtin call in a game resolves to a
|
||
runtime function by name: `clear(c)` calls `rt_clear(c)`. Replace that file and
|
||
you have replaced the runtime; pass `--freestanding` to build without it.
|
||
|
||
Underneath the runtime there is exactly one layer, and it is not C: a set of
|
||
compiler intrinsics that lower to direct calls into the platform ABI.
|
||
|
||
| intrinsic | lowers to |
|
||
| --- | --- |
|
||
| `mem_alloc(n) -> pointer`, `mem_free`, `mem_copy`, `mem_set` | `malloc`, `free`, `memcpy`, `memset` |
|
||
| `peek8/peek32(p, i) -> int`, `poke8/poke32(p, i, v)` | `load` / `store` |
|
||
| `ptr_add(p, n) -> pointer`, `ptr_null()`, `ptr_is_null(p)` | `getelementptr`, `null` |
|
||
| `file_open(path, mode) -> pointer`, `file_read`, `file_write`, `file_close` | `fopen`, `fread`, `fwrite`, `fclose` |
|
||
| `read_byte() -> int`, `write_byte(c)`, `print_str(s)`, `print_int(n)` | `getchar`, `putchar`, `printf` |
|
||
| `str_len(s) -> int`, `os_exit(code)`, `os_time() -> int` | `strlen`, `exit`, `time` |
|
||
|
||
That is the operating system's interface — the floor Rust and Swift stand on
|
||
too. Everything above it, including all the graphics, is Ludic.
|
||
|
||
The runtime protocol is four optional functions. Define them (or let the
|
||
prelude define them) and the entry point calls them:
|
||
|
||
| function | when |
|
||
| --- | --- |
|
||
| `rt_init()` | once, before the `Start` systems |
|
||
| `rt_poll() -> int` | once per frame; its result is what `key()` reads |
|
||
| `rt_running() -> bool` | each frame; false ends the loop |
|
||
| `rt_shutdown()` | after the loop |
|
||
|
||
## The window
|
||
|
||
`runtime/native/cocoa.ll` is the macOS platform layer, written in LLVM IR. It
|
||
talks to the Objective-C runtime through its C ABI — `objc_getClass`,
|
||
`sel_registerName`, `objc_msgSend` — and to Quartz through CoreGraphics, which
|
||
is what a compiled `.m` file does anyway; this just skips the `.m`. AppKit
|
||
paints through `-drawRect:`, so the view class is built at runtime with
|
||
`objc_allocateClassPair` and an IR function is installed as its IMP.
|
||
|
||
ludicc assembles it exactly like the program's own IR and hands both objects to
|
||
the linker, adding `-framework Cocoa`. A `--headless` build omits it entirely,
|
||
reads keys from stdin and writes the last frame to `out.ppm`; the `win_*`
|
||
intrinsics compile to nothing there, so a headless binary never references a
|
||
symbol the window would have provided.
|
||
|
||
Other platforms build headless today. A Win32 or X11 port is another `.ll` file
|
||
with the same entry points — the window (`win_open`, `win_poll`, `win_present`,
|
||
`win_running`, `win_close`), keys (`win_held`, `win_held_bit`), the mouse and
|
||
cursor (`win_mouse`, `win_cursor_mode`, `win_cursor_confine`,
|
||
`win_cursor_maintain`), gamepad (`win_pad`) and touch (`win_touch`) — and no
|
||
compiler change.
|
||
|
||
## The web
|
||
|
||
WebAssembly is a target, not a port. The front end, the type checker, the ECS
|
||
lowering and the Ludic-written runtime are the same ones a macOS build uses;
|
||
only the triple changes.
|
||
|
||
```
|
||
game.ludic
|
||
│ ludicc — the same lex, parse, check and lower
|
||
▼
|
||
game.ll LLVM IR, triple wasm32-unknown-unknown
|
||
│ IR assembler
|
||
▼
|
||
game.o + wasm.o (runtime/web/wasm.ll, the platform layer)
|
||
│ wasm-ld
|
||
▼
|
||
game.wasm + index.html + platform.js + assets.json + the assets
|
||
```
|
||
|
||
```bash
|
||
bin/ludic build examples/games/chronorift.ludic --web
|
||
python3 -m http.server -d build/web 8000 # then open http://localhost:8000/
|
||
```
|
||
|
||
`build/web/` is self-contained: copy it to any static host — GitHub Pages, S3,
|
||
itch.io — and the game runs. It needs no server-side anything, and no
|
||
cross-origin isolation headers.
|
||
|
||
**No game logic passes through JavaScript.** The handlers, the queries, the
|
||
fixed-point arithmetic, the PNG decoder, the TrueType rasteriser and the UI are
|
||
all compiled Ludic executing as wasm. `platform.js` is 300 lines and implements
|
||
the same five-function window protocol `cocoa.ll` implements, plus the host
|
||
services wasm has no OS to ask for. It is the web's Cocoa, not an interpreter.
|
||
|
||
### The toolchain
|
||
|
||
A wasm build needs an LLVM with the WebAssembly backend and `wasm-ld`. Linux
|
||
distributions ship both in `clang` and `lld`, so nothing extra is needed there
|
||
or in CI. Apple's clang is built without the WebAssembly target, so on macOS:
|
||
|
||
```bash
|
||
brew install llvm
|
||
```
|
||
|
||
ludicc looks in `/opt/homebrew/opt/llvm/bin` and `/usr/local/opt/llvm/bin`
|
||
before falling back to `PATH`. `$LUDIC_CC` and `$LUDIC_WASM_LD` override both,
|
||
so any LLVM works — a distro one, a downloaded release, `zig cc`, wasi-sdk.
|
||
|
||
### Who owns the frame loop
|
||
|
||
A native build runs the loop:
|
||
|
||
```c
|
||
ludic_boot(); while (ludic_alive()) ludic_frame(); ludic_teardown();
|
||
```
|
||
|
||
A browser tab cannot be held inside that loop — it would never paint, and the
|
||
key events the loop is waiting on would never be delivered. So a web build
|
||
exports those four functions instead of `main`, and `platform.js` calls
|
||
`ludic_frame` from `requestAnimationFrame`. Both targets emit the four from the
|
||
same code in `ll_emit_loop_parts`, so the handlers that run, and the phase order
|
||
they run in, are identical; only the owner of the loop differs.
|
||
|
||
### The floor
|
||
|
||
`wasm32-unknown-unknown` has no libc, so `runtime/web/wasm.ll` *is* the floor —
|
||
hand-written LLVM IR, assembled by the same toolchain as everything else:
|
||
|
||
| what | how |
|
||
| --- | --- |
|
||
| `malloc` / `free` | a first-fit free list over linear memory, growing it with `memory.grow` |
|
||
| `memcpy` / `memset` | the `memory.copy` / `memory.fill` instructions (`-mbulk-memory`) |
|
||
| `strlen` | a byte loop |
|
||
| `fopen` / `fread` / `fwrite` / `fclose` / `fseek` / `ftell` | wasm imports, over a preloaded asset image and `localStorage` |
|
||
| `getchar` / `putchar` / `print_str` / `time` / `exit` | wasm imports |
|
||
| `win_open` / `win_poll` / `win_present` / `win_running` / `win_close` | wasm imports, implemented against a `<canvas>` |
|
||
|
||
Nothing above that file changes for the web: `core.ludic`, `image.ludic`,
|
||
`inflate.ludic`, `truetype.ludic` and `ui.ludic` compile to wasm unmodified.
|
||
|
||
### Assets and saves
|
||
|
||
The browser has no synchronous file access, and `file_open()` is synchronous, so
|
||
a web build ships an image of its files instead of a filesystem. ludicc records
|
||
every string literal in the program that names a file existing at compile time,
|
||
writes the list to `assets.json`, and copies the files into the bundle;
|
||
`platform.js` fetches them all before the first frame. `file_open()` then
|
||
resolves exactly the paths it resolves natively.
|
||
|
||
That is a heuristic, and a deliberately visible one: a path the compiler never
|
||
sees written down is a path the browser cannot be told to fetch ahead of time,
|
||
and a path outside the project (`/System/Library/Fonts/…`) is refused with a
|
||
warning rather than silently dropped.
|
||
|
||
Writes go the other way. `file_open(path, "wb")` buffers and commits to
|
||
`localStorage` on close, so `save()` / `load()` survive a page reload, and a
|
||
read prefers a save the player has made over the shipped asset of the same name.
|
||
|
||
### Testing a wasm build
|
||
|
||
`--headless --target wasm32-unknown-unknown` produces a bare module with no
|
||
page, driven by a runner instead of a browser:
|
||
|
||
```bash
|
||
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`
|
||
asserts exactly that, which is a much stronger check on the backend than
|
||
"it started".
|
||
|
||
## What a build contains
|
||
|
||
Everything: properties and models, spawn/despawn, queries with bindings,
|
||
`where` filters and model filters, `match`, `machine`/`become`,
|
||
`scene`/`layer`/`enter`, module state (`var`), `const`, int and Q16.16
|
||
fixed-point arithmetic, control flow, functions, `extern fn` FFI, strings, the
|
||
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
|
||
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:
|
||
|
||
```
|
||
<file.ludic> the program to compile (first non-flag argument)
|
||
-o <path> output binary; with --emit-llvm, the IR path.
|
||
Parent directories are created. With no -o and not
|
||
invoked as `ludic`, the IR is written to stdout.
|
||
--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
|
||
--fmt lex + parse only; exit 0 if it parses, 1 on a parse error
|
||
(the check-docs gate; canonical formatting not yet restored)
|
||
--save-temps keep the intermediate .ll
|
||
--run compile then run (what `ludic run` uses)
|
||
(unknown -flags are ignored with a warning, never taken as the input file)
|
||
|
||
environment:
|
||
LUDIC_CC the LLVM that assembles IR and drives the linker (clang)
|
||
LUDIC_HOME the install root — runtime/, packages/, VERSION
|
||
(default: the parent of the binary's bin/ directory)
|
||
LUDIC_MODULES the project's fetched packages (default: ./ludic_modules)
|
||
```
|
||
|
||
Mode is automatic when neither `--windowed` nor `--headless` is given: a program
|
||
with `system`s (a game) links windowed, anything else headless.
|
||
|
||
Not yet re-implemented on the self-hosted toolchain (old C-driver flags):
|
||
`--shared`, `--emit <kind>`, `-c`, `--target`/cross-compile,
|
||
`--freestanding`, `-v`, and the explicit link inputs (`-L`/`-l`/`-framework`/
|
||
`-Wl`). Those, plus `LUDIC_WASM_LD`/`LUDIC_RUNTIME_DIR`/`LUDIC_RUNTIME`, describe
|
||
the previous driver and are documented here as intended design.
|
||
|
||
There is one backend. `ludicc` has no mode that emits C, and no part of a
|
||
build compiles or links a C translation unit — including the web one, where the
|
||
platform layer is LLVM IR and the loader is 300 lines of JavaScript that never
|
||
sees a game rule.
|