Two ECS-native, deterministic namespaces for 2D motion, driven off the fixed
frame clock so replays and lockstep netcode reproduce every frame and every
eased value exactly. Both are pure computed-inline Q16.16 / integer math (no new
runtime, no heap) — the game stores a timer on a component and calls these each
frame, exactly the way Collision.* / Grid.* are used.
Anim.* — spritesheet frame animation:
- Anim.frame(timer,fps,count) -> int looping frame index
- Anim.once(timer,fps,count) -> int one-shot, clamps on the last frame
- Anim.pingpong(timer,fps,count) -> int bounce 0..count-1..0
- Anim.finished(timer,fps,count) -> bool has a one-shot run past its end?
- Anim.duration(fps,count) -> fixed seconds for one cycle
- Anim.cell_x/cell_y(frame,cols,cell) -> int source rect on a grid sheet
Tween.* — value interpolation over a timeline:
- Tween.progress/loop/yoyo(timer,duration) -> fixed normalized amount
- Tween.done(timer,duration) -> bool
- Tween.ease(t, mode) -> fixed shape by a literal curve 0..6,
the same curves as Ease.* (now
factored into a shared ease_eval)
- Tween.number/round/point/tint(from,to,t) blend a fixed / int / Vector / color
The typed blends reuse the existing fixed / Vector / color helpers, and
Tween.ease shares Ease.*'s exact formulas via the new ease_eval(mode,t) — one
source of truth for every easing curve in the engine.
examples/library/anim.ludic asserts 34 cases (frame math, clamping, ping-pong,
cell geometry, timeline clamp/loop/yoyo, rounding, color/vector blends, and
Ease.in == Tween.ease(.,1)); wired into x test (now 62 passed). Docs: Anim +
Tween sections with 16 per-symbol pages, inventory/coverage green. Seed
reseeded; the C-free bootstrap fixpoint holds.
The stateful sugar the proposal sketches (named clips, Anim.play, fluent
Tween.chain/parallel handles, and an auto-injected advance system) is deliberately
left as a follow-up — this lands the deterministic math core both halves stand on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grid.* operates on the Map tilemap (Map.size/Map.row): a cell is passable unless
it is out of bounds or holds the caller's `wall` tile (a char code, e.g. '#'), so
any impassable glyph works. Everything is integer and deterministic.
- Grid.line(x0,y0,x1,y1) -> []Cell Bresenham line cells (LOS/raycast base)
- Grid.blocked(x,y,wall) -> bool the shared passability test
- Grid.line_of_sight(x0,y0,x1,y1,wall) unobstructed straight line?
- Grid.flood(x,y,wall) -> []Cell 4-connected reachable region (BFS)
- Grid.a_star(x0,y0,x1,y1,wall) -> []Cell shortest 4-connected path (A*,
Manhattan heuristic), empty if unreachable
The engine (runtime/native/grid.ludic, ~150 lines of Ludic, C-free) is spliced
into a game via core.ludic since it reads the tilemap runtime; returned Cell
slices are ordinary Ludic slices (`len` / `[i]`; each cell has `.x` `.y`).
Pathfinding lives under Grid rather than a `Path` namespace — that name is
already the filesystem-paths library (#10).
Verified against Python references: a 1500-case fuzzer over random maps agrees
exactly on A* path length (optimal, == BFS), flood-fill count, and line-of-sight.
examples/library/grid.ludic asserts the behaviour and is wired into `x test`
(now 61 passed); docs: a Grid section + 5 per-symbol pages, inventory/coverage
green. Seed reseeded; the C-free bootstrap fixpoint holds.
Scope: this lands the Grid.*/pathfinding half of #24. The ECS Query.* helpers
(count/first, and nearest/within which want a runtime spatial index) remain the
tracked follow-up the issue calls out as blocked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A regular-expression library with PCRE/PECL-compatible syntax, implemented as a
Thompson NFA / Pike VM so a bad pattern from a modder can NEVER cause
catastrophic backtracking — matching is O(n·m), never exponential. `(a+)+$` on
40 non-matching chars, `(a*)*b`, `(.*a){20}b` all run in microseconds; a 50 KB
input scans in ~7 ms.
The engine (runtime/native/regex.ludic + regex_vm.ludic, ~700 lines of Ludic, no
C) parses a pattern to a small bytecode program — an unanchored lazy `.*?` prefix
makes a plain search match anywhere — and the VM runs every alive thread in
lockstep per input byte, deduped by program counter and carrying capture slots
(save/restore, leftmost-greedy priority). Supported: literals, `.`, classes
`[...]` (ranges, negation, `\d \w \s` and their negations), anchors `^ $`,
alternation `|`, capturing and `(?:…)` groups, and `* + ? {n} {n,} {n,m}` in
greedy or lazy form, plus the common escapes; numbered capture groups. Errors are
values — an invalid pattern compiles to null, never a crash. Backreferences and
look-around are out of scope for a linear engine, and on the degenerate case of a
nullable subpattern under an unbounded quantifier positions may differ from a
backtracking engine (the price of the linear-time guarantee) — documented.
Surface (Regex.*, aliased in emit_call.ludic to the regex_* functions):
compile / valid / matches / test / find / exec / next / replace / group /
group_count / start / end / ok.
The runtime is spliced on demand: the parser sets a flag when it sees `Regex.`
and maybe_splice_runtime imports the engine — so it costs nothing in a program
that doesn't use it and works in a plain tool (not just an ECS game).
Verified against Python's `re` as an oracle: a 20k-case grammar fuzzer agrees
100% on realistic patterns (0 / 15000 with capture groups) and 99.8% on group-0
spans across the full pathological grammar, the residual being the documented
nullable-quantifier case. examples/library/regex.ludic asserts the behaviour
(wired into `x test`, now 60 passed); docs: a Regex section + 13 per-symbol
pages, inventory + coverage green. Seed reseeded; the C-free bootstrap fixpoint
holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to #31: the doc/lint/grammar checks moved to Ludic there; this ports
the remaining docgen piece (gen.py / check.py / palette.py) so nothing in the
documentation pipeline is Python any more.
Three new `x` subcommands, all in Ludic and compiled by Ludic:
- x docs-gen [--out DIR] the static-site generator: parses docs/language/**
front-matter + bodies (fences, Parameters:), builds the section/symbol
model, reads the asset templates, and emits every page + ns/color/api pages
+ the landing page + ludic-highlight.js + symbols.json + .nojekyll.
- x docs-check [DIR] the coverage / integrity guard (required files, a
page per inventory.json symbol, duplicate-token and one-dir-per-namespace
guards, highlighter link targets).
- x docs-palette the named-colour source of truth: the palette table
moved into tools/x/docgen.ludic, emitting emit_color.ludic (pointer, not
ptr) + palette.json.
Verified against the Python oracle: `x docs-gen` reproduces all 466 output files
BYTE-FOR-BYTE (a Ludic json.dumps/html.escape/front-matter port — ordered dicts,
indent=2 vs compact, ensure_ascii \uXXXX, codepoint-aware truncation), and
`x docs-check` matches check.py's pass/fail output. Wired into `x test` as a
gate (docs-gen -> docs-check on a fresh site; docs-palette stays byte-identical).
CI swap: ci.yml and docs.yml call the Ludic generator; docs.yml drops the
python:3.12 container and bootstraps the toolchain from the IR seed instead.
tools/docgen/{gen,check,palette}.py deleted; only assets/ + inventory.json
remain. Completes #31's criterion 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`new` accepted only a bare `new T` (every field its declared default) or
`new []T`, but the docs (kw-new) document `new Record { field: value, … }`
as the way to construct a record with non-default fields — a documented,
intended form the parser never accepted (`let o = new Point { x: 3 }` failed
with "expected newline or ';'").
Parse an optional `{ … }` override record after the type in a `new`
expression (reusing the existing `record()` parser that `spawn` uses), and
seed each field in emit_new_struct from that record when present, else from
the field's declared default. `new []T` and bare `new T` are unchanged.
Also mark the illustrative kw-import fence `# doc-check: skip` (its imports
are example paths that can't resolve in isolation), which makes `x check-docs`
fully green (398 fences, 0 drifted) — so it is now wired as a gate in
`x test-tools` and CI, guarding against future doc/compiler drift.
Reseed is a clean fixpoint (x bootstrap-cfree holds); x test (56),
x selfhost-test (29, golden renders unchanged) and x test-tools (30) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The repository root carried 11 large Markdown files (~330 KB); most were
long-lived design records rather than things a newcomer needs on first
contact, which buried the README and mixed "how to use Ludic" with "how we
decided to build it."
Move the design/roadmap docs to the Forgejo wiki (now enabled and
populated): Events, Networking, Scenes, Lifecycle, Mobile and
Syntax-redesign design records, the Bootstrap deep-dive and the Luanti
roadmap, under a Home index + sidebar. Each page had its selfhost/ source
links corrected for the #29 reorg and every repo-relative link rewritten to
an absolute URL on main so it resolves from the wiki.
All eight were current, actively-maintained records, so none were dropped.
The root now holds README.md plus the two user-facing references,
LANGUAGE.md and COMPILING.md; the README links to the wiki, and the
remaining references in LANGUAGE.md / COMPILING.md / examples/README.md and
the emit_net.ludic header comment point at the wiki pages. The emit_net.ludic
change is a comment only — the seed stays byte-identical and bootstrap-cfree
+ the full suite (56) stay green.
Closes#26
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Split the flat 38-file selfhost/ into concern-based subdirectories:
frontend/ lex, parse, parse_game, ast
support/ str, buf, io
backend/ core IR + expression/statement lowering
backend/game/ ECS/scene/event/world lowering
backend/stdlib/ the namespaced Math.*/Text.*/Crypto.*/… intrinsics
and split the three oversized emitters at responsibility boundaries so
no file mixes concerns:
emit_game.ludic -> + emit_world.ludic (reflection world table,
tick helpers, @main synthesis)
emit_expr.ludic -> + emit_call.ludic (namespaced builtins, call
lowering, expr dispatch)
emit_text.ludic -> + emit_text_prelude.ludic (emitted string-builder runtime)
FRAGS in tools/x/selfhost.ludic is updated to the new paths with the link
order preserved, and the Python doc/vocabulary tooling is updated to walk
the new layout. Because the build is a plain in-order concatenation and
every split lands on a blank-line boundary, the regenerated seed is
byte-identical: `x reseed` leaves selfhost/ludicc.seed.ll unchanged,
`x bootstrap-cfree` still reaches its fixed point, and both `x test` (56)
and `x selfhost-test` (29, incl. golden renders) stay green.
Closes#29
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The project had no versioning discipline: 0 tags, no CHANGELOG, no way for the
compiler to report a version. Add a lightweight, native release flow.
- Versioning: SemVer, with VERSION as the single source of truth. `ludicc
--version` (and `ludic --version`) read it at runtime — so a bump touches one
file and never reseeds the compiler. `x version` reports it too.
- Changesets: one small Markdown file per user-facing change under changes/
(bump level + type + summary; see changes/README.md). This replaces "remember
to edit the changelog" with a mergeable artifact, no Node changeset tool.
- `x release [major|minor|patch] [--publish]`: fold the pending changesets into a
new CHANGELOG.md section (grouped by type), bump VERSION, commit, and tag
vX.Y.Z. The level defaults to the highest changeset bump. `--publish` also
pushes and creates the Forgejo release with source + toolchain tarballs;
tools/ci/forgejo_release.py is the small stdlib-Python HTTP glue for the
release API (a native Http client is issue #6).
Seed the initial changesets describing the shipped surface; the first `x release`
turns them into the v0.1.0 CHANGELOG. Reseeded for the --version flag; C-free
bootstrap fixpoint holds; suites 56 / 29 / 29 on macOS, 51 / 28 (+skips) on Linux
CI, bootstrap-cfree byte-identical on both.
Part of the repository-cleanup / DX pass (with #32, #34).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`System.*` and the newer `Os.*` (added in #21) both covered the environment
around the game, with four members (`arg`, `arg_count`, `env`, `exit`) lowering
byte-for-byte identically and the standard streams overlapping in concern. That
is a user-facing ambiguity (`System.env` vs `Os.env` are indistinguishable) and
a drift hazard (two copy-pasted codegen paths).
Make `Os.*` the single canonical environment/process namespace and retire the
overlapping `System.*` members:
- Remove `System.{arg, arg_count, env, exit, stdout, stderr}` from the namespace
dispatch (selfhost/emit_expr.ludic). Use `Os.arg`/`Os.arg_count`/`Os.env`/
`Os.exit` and `Os.stdout_write`/`Os.stderr_write` instead.
- `System.*` now covers only its unique low-level surface: the raw file handles
(`file_open/read/write/seek/tell/close`), `read_char`, and `run`.
- The bare `arg`/`exit`/`getenv`/`file_stdout`/`file_stderr` intrinsics stay —
they are the primitive layer the self-hosted compiler itself uses; only the
redundant *namespaced* sugar is gone.
- Docs: drop the six retired `docs/language/system/*` pages, retune the section
blurb, update inventory.json and the LSP signature table.
- Secondary finding from the issue: cross-link `Text.upper`/`Text.lower`
(ASCII-only) to `Unicode.upper`/`Unicode.lower` (full Unicode case mapping).
Reseeded; C-free bootstrap fixpoint holds. All suites green (56 test / 29
selfhost / 29 test-tools); docs cover every implemented feature (291 ns-methods).
Closes#40
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A cohesive filesystem & IO library — the foundation for saves, config, mods, and
asset loading — wrapping the bare file_* builtins into one safe, ergonomic API a
non-expert can use without touching a file descriptor or a byte buffer.
Path.* join / dir / base / ext / stem / normalize (pure lexical string ops)
Fs.* exists / is_dir / read_text / write_text / append_text / remove /
size / mkdir / copy / list
Mime.* of (extension table) / sniff (magic bytes: PNG/JPEG/GIF/PDF)
Pure string IR for Path.*; libc (fopen/access/mkdir/rename/opendir…) for Fs.*;
C-free, emitted on demand (g_uses_fsrt). Safety and determinism baked in:
- write_text and copy are atomic (write a temp file, then rename over the target)
so a crash mid-write never corrupts the previous file;
- mkdir creates parents (mkdir -p);
- list is sorted for a stable, reproducible directory walk;
- fallible calls return values (null / false / -1), never crashes — ready for a
first-class try/else when the error-handling work lands.
Complements Os.* (#21): Os supplies per-user locations, Fs the operations. v1
targets the native macOS/BSD filesystem with "/" separators; Windows separators,
a sandboxed wasm virtual FS, recursive directory copy, and richer magic-byte
sniffing are documented follow-ups.
- examples/library/fs.ludic: 32 assertions across pure Path ops (incl. normalize
resolving ./ .. and duplicate slashes), a real create/read/append/copy/list/
remove cycle under build/, and Mime by-extension + by-magic (GIF signature vs a
.bin extension). Wired into `x test` (now 56 passed).
- docs: new Path, Fs, and Mime sections + 18 per-symbol pages; inventory updated;
every fence passes check-docs; site builds via docgen.
- seed regenerated; `x bootstrap-cfree` fixpoint holds.
Closes#10
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make Ludic text correct-by-default over UTF-8, so player names, translated UI,
and chat behave for every language instead of counting bytes and splitting
characters in half. The byte-oriented Text.* stays for speed; Unicode.* is the
layer that understands code points and (approximately) grapheme clusters.
- len / byte_len code points vs bytes — the two lengths, kept distinct
- is_valid_utf8 strict validation of untrusted input
- char_at / chars code-point access by index; chars() -> []int
- upper / lower case mapping (ASCII + Latin-1)
- truncate first n code points, never a half-character
- grapheme_len user-perceived characters (approx UAX#29)
Pure integer/byte IR over NUL-terminated buffers; C-free, no data-table blob.
Decoding and validation cover the full UTF-8 range (overlong/surrogate/>10FFFF
rejected). grapheme_len collapses combining marks, variation selectors, ZWJ
sequences (family emoji), and regional-indicator flag pairs. Documented v1
scope: wider-script/locale case rules (Latin-Extended, Greek, Cyrillic, Turkish
i, German ß) and NFC normalization are follow-ups.
- examples/library/unicode.ludic: asserts the invariants across ASCII, Latin-1
(é round-trips through upper/lower), a decomposed "café" (5 code points, 4
graphemes), a ZWJ family emoji (5 code points, 1 grapheme), and a flag (2
regional indicators, 1 grapheme). Wired into `x test` (now 55 passed).
- docs: a new Unicode section + 9 per-symbol pages clarifying byte vs code point
vs grapheme; inventory updated; every fence passes check-docs; site builds.
- seed regenerated; `x bootstrap-cfree` fixpoint holds.
Closes#13
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An Os.* namespace, Go-flavored and game-scoped, for the environment *around*
the game: the command line, environment variables, standard streams, process
exit, the host platform, and the per-user known folders a game writes into.
Rounds the bare System.* builtins (arg/getenv/exit) into one coherent surface.
- args / arg_count / arg the argument vector (args() -> []string)
- env / env_or / has_env read env vars (null-safe via env_or)
- set_env / unset_env mutate this process's environment
- exit(code) terminate with a status code
- platform() / arch() host facts (uname sysname/machine)
- stdout_write / stderr_write raw writes to the standard streams
- save_dir / config_dir / cache_dir / temp_dir per-user known folders
Pure libc over NUL-terminated strings; C-free, no new runtime. arg_count/arg/
exit stay light (no prelude) as thin aliases of the existing intrinsics; the
rest share one Os runtime prelude emitted on demand (g_uses_osrt). platform()
is portable (uname system name is field 0 on every Unix); arch() and the
known-folder layout follow the macOS/BSD conventions — the fully supported
native target today. Linux/Windows/wasm folder resolution and a target-aware
arch() are documented follow-ups.
- examples/library/os.ludic: asserts the invariants that hold regardless of
host — env round-trip, env_or fallback, unset, args()==arg_count(), non-empty
platform/arch and known dirs. Wired into `x test` (now 54 passed).
- docs: a new Os section + 17 per-symbol pages; inventory updated; every fence
passes check-docs (--fmt) and the site builds via docgen.
- seed regenerated; `x bootstrap-cfree` fixpoint holds.
Closes#21
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Log.* namespace: five levels (trace/debug/info/warn/error), a runtime
threshold, and structured key=value fields, so games get something better than
scattered print calls and release builds can go quiet without touching call
sites.
- Log.trace/debug/info/warn/error(msg, [k, v]...) -> stderr, "[LEVEL] msg k=v"
- Log.set_level(n) show only level >= n (0 = all default, 5 silences all)
- Log.level() read the current threshold
Fields accept strings, ints, and longs (numbers formatted automatically); the
level tag is chosen at compile time so a filtered-out level costs only a
comparison. Writes to stderr, never touching the simulation — no effect on
determinism/replays. v1 is the console sink; rotating-file and in-engine overlay
sinks are noted as follow-ups.
- examples/library/logging.ludic: asserts the set_level/level threshold
round-trip and that every level (with mixed-type fields) runs without faulting;
the stderr gating itself was verified by hand (warn/error emit, lower levels
suppressed). Wired into `x test` (now 53 passed).
- docs: a new Log section + per-symbol pages; inventory and coverage pass.
- seed regenerated; `x bootstrap-cfree` fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Noise.* namespace for procedural generation, implemented entirely in Q16.16
fixed point over an integer permutation hash so a seed reproduces the exact same
field on every platform and run (native/headless/wasm) — the determinism edge
over float noise that drifts across CPUs.
- value2 / perlin2 / simplex2 — value, gradient, and simplex noise -> [-1,1]
- fbm2(x,y,seed,octaves) — fractal Brownian motion (octaves of simplex)
- cellular2 / cellular2_id — Worley F1 distance + nearest-cell id
- unit(n) — remap [-1,1] -> [0,1]
Covers issue phases 1–2 fully plus cellular from phase 3; domain warp, ridged/
billow, and sample1/sample3 remain as follow-ups. Pure integer IR, C-free;
cellular/fbm reuse the math prelude's fx_sqrt.
- examples/library/noise.ludic: asserts the invariants a fixed-point generator
must hold (Perlin == 0 at lattice points, every sampler within [-1,1],
reproducibility, seed sensitivity, non-negative cellular distance). Wired into
`x test` (now 52 passed).
- docs: a new Noise section + per-symbol pages; inventory and coverage pass.
- seed regenerated; `x bootstrap-cfree` fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Crypto (#19): add the OS cryptographically-secure random surface
(random_bytes/random_hex/random_u32, reading /dev/urandom) and a standard
base64 encoder, completing the library alongside the existing SHA-256/
HMAC-SHA256/verify_hmac/hex/ct_equal. All pure integer IR, C-free.
Uuid (#16): a new namespace for stable, collision-free IDs — v4 (random) and
v7 (time-ordered) generation, plus parse/is_valid/to_text/equals/nil. UUIDs are
canonical lowercase 36-char strings; v4 and v7's random tail draw from the
crypto CSPRNG, so both carry the documented determinism caveat (mint at the
edges, never inside lockstep simulation). Reuses the crypto prelude's
fn_secure_bytes / fn_hex_encode.
- examples/library/{crypto,uuid}.ludic: known-answer vectors (SHA-256, HMAC,
base64 per RFC 4231/4648) and structural invariants (uuid version/variant
bits, parse/equals), wired into `x test` (now 51 passed).
- docs: per-symbol pages for every new method + a new Uuid section; inventory
and impl-vs-docs coverage check pass.
- seed regenerated; `x bootstrap-cfree` fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Repository-cleanup / DX pass folding three tracker items into one coherent
change, verified green end to end (`bin/x test` 49/0, `bin/x selfhost-test`
29/0, `bin/x test-tools` 29/0).
#28 — curate & categorise examples/
- 42 flat entries regrouped into intent-revealing subdirs: games/, rendering/,
ecs/, events/, networking/, lang/, library/ (was lib/).
- chronorift dir-vs-file duplication resolved: the entry file and its import
modules now live together under games/chronorift(.ludic).
- Every path reference updated repo-wide (test runner, editor-tool drivers,
docs/site, design docs).
- New examples/README.md indexes the whole set with run commands.
- Showcase examples without a self-asserting entry (hello, events, net_rt) now
get a compile-only rot guard in `bin/x test`, so nothing here rots silently.
#30 — text-diffable golden baseline
- The 4 binary selfhost/golden/*.ppm blobs are replaced by a single
selfhost/golden/renders.sha256 manifest (SHA-256 per render). Hashes are
byte-identical to the old PPMs, so the baseline is unchanged — only its form.
- game_case now compares framebuffer hashes; a regression shows as a changed
hex line in review, not "binary files differ".
- New `bin/x golden` regenerates the manifest deliberately (review with
`git diff selfhost/golden/renders.sha256`).
#27 — PPM & asset handling
- Headless renders now write build/out.ppm, never the repo root; `x app`,
`x clean`, messaging and .gitignore updated to match. Nothing is written to
the working root any more.
- Redundant local Kenney .zip archives removed (the art ships extracted;
.gitignore already excludes *.zip). CC0 License.txt files retained.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The security-sensitive counterpart to the fast, non-cryptographic Hash.*
library: standard, test-vector-backed hashing for signed saves and message
integrity, kept in its own namespace so nobody reaches for the wrong tool.
Crypto.sha256(s) SHA-256 -> 64-char lowercase hex
Crypto.hmac_sha256(key, msg) HMAC-SHA256 -> 64-char hex
Crypto.verify_hmac(key, msg, mac) recompute + constant-time compare -> bool
Crypto.hex(s) lowercase hex of a string's bytes
Crypto.ct_equal(a, b) constant-time string equality
The primitives are implemented from scratch in plain integer LLVM IR
(FIPS 180-4 / RFC 2104): no libc crypto, no data-dependent branches in the
compression rounds, so a given input hashes to the same 32 bytes on every
platform and run. Digests are returned as hex strings, not raw bytes, because
a `str` is null-terminated and a raw digest can contain a NUL. MAC checks use
a non-short-circuiting compare so timing does not leak how much of a forged tag
was correct.
Emitted on demand via g_uses_cryptort, mirroring the emit_hash prelude gate.
Scoped to the deterministic, known-answer-testable core; OS-backed
random_bytes (the one piece that can't be validated by test vectors) is left
for a follow-up.
Tested against published SHA-256 vectors (empty/"abc"/fox + 55/56/64-byte
multi-block padding) and HMAC-SHA256 vectors; wired into the self-host suite as
`crypto`. Docs: a new Crypto section with honest "what this protects / does
not" guidance, one page per method, all fences checked and in the inventory.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grow List sorting from a numeric-only insertion sort into a small,
game-friendly toolkit that sorts records and query results by a key or a
full comparator, stably and in O(n log n).
- List.sort_by(s, keyfn) ascending by a key (draw order, price)
- List.sort_desc_by(s, keyfn) descending (leaderboards)
- List.sort_with(s, cmpfn) full cmp(a,b)->int comparator (multi-field)
Comparators/keys are passed as named top-level functions rather than
lambdas, so the toolkit ships without waiting on closures (#1).
Engine: a stable bottom-up merge sort. emit_takeright is the single
place stability is decided ("take the right run's head only on a strict
win" -> equal keys keep prior order). List.sort becomes a hybrid:
insertion sort for n<32, merge sort above; both stable, so output is
unchanged. Key functions must return an integer-ish type; record slices
hold pointer elements, so the key/comparator receives the record pointer.
Tests: selfhost/tests/sort.ludic (scalar large-n, sort_by, sort_desc_by,
stability, sort_with). Docs: list-sort_by/desc_by/with + updated
list-sort. All suites green (28 self-host / 46 test / 29 test-tools);
reseeded, C-free bootstrap fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finish issue #9 by adding the two remaining acceptance items on top of the
calendar/clock core, still pure-integer and deterministic:
DateTime.format(dt, pattern) -> string render an instant via a token
pattern (YYYY/YY/MM/DD/HH/mm/ss;
other chars pass through)
DateTime.parse(text, pattern) -> int read an instant back; -1 on a
non-digit where one is expected
Clock.now/set/advance/reset a game-controlled simulated clock
(the @L_clock global) that never
touches the wall clock, so gameplay
reading Clock.now() is replay-safe
format/parse take a string-LITERAL pattern and are expanded at compile time
(field offsets are then constant), folding @fn_str_concat over literal runs and
two small runtime helpers: @fn_dt_pad0 (zero-padded field) and @fn_dt_rd
(fixed-width digit reader that stops at the terminator and flags malformed
input). Clock is a universal i32 global declared in emit_head, so it works in
entry and game programs alike.
Adds examples/offline_rewards.ludic — the issue's worked "you were away N hours"
example, driven from its own entry and asserted in the regression suite — plus
selfhost/tests/datetime2.ludic (format/parse round-trip, parse failure, clock),
docs (Clock section + 4 pages, DateTime.format/parse pages), inventory and LSP
hover. Reseeded; C-free fixpoint holds; all suites green (27 self-host / 46
regression / 29 tools); check.py (366 symbols), check-impl.py (218 ns-methods)
and validate.py OK.
With this, #9's scope is fully delivered: DateTime/Date/Duration + core ops,
format/parse, a deterministic simulated clock, docs + offline-rewards example,
and tests. (v1 stays UTC-only, no leap seconds, i32 epoch valid through 2038.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement the calendar/clock half of #9 as plain-i32 integer epochs — no
new type, no floating point (the issue's "integer epochs to avoid drift") —
so every operation is deterministic and bit-identical on every platform:
Duration — a span in whole seconds; seconds/minutes/hours/days build one,
as_seconds/as_minutes/as_hours/as_days read it back. Because a
duration is just an int, `+` and `>` work with no extra machinery
(Duration.minutes(5) + Duration.seconds(30), away > Duration.hours(3)).
Date — a civil day as days-since-1970 (UTC): new/year/month/day/weekday/
is_leap/days_in_month/to_epoch/add_days/diff_days.
DateTime — an instant as seconds-since-1970 (UTC, matching Time.now):
from/date/add/year/month/day/weekday/hour/minute/second.
Time.since(past) = now - past, for offline-progress / "time away" checks.
New selfhost/emit_datetime.ludic (is_/emit_ for the three namespaces, wired
into emit_ns_call + the frag list). The two civil<->epoch conversions are
Howard Hinnant's public-domain proleptic-Gregorian algorithms, emitted once
per program as the @fn_days_from_civil / @fn_civil_from_days prelude and gated
by g_uses_datert; days_in_month is next-month-day-0 (no lookup table). Time
gains `since`. Docs (Duration/Date/DateTime sections, 28 method pages +
time-since), inventory, and LSP hover kept in sync; a registered test checks
component math against hand-computed values. Reseeded; C-free fixpoint holds;
all suites green (26 self-host / 45 regression / 29 tools); check.py,
check-impl.py and validate.py OK.
format/parse, a game-controlled simulated clock, and timezones are tracked
follow-ups; v1 is UTC-only and, on the i32 epoch, valid through 2038.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement the Vec.* half of #25 under the proper (de-abbreviated) name
Vector, unblocking it with a self-contained value type instead of waiting
on the full #1 type system.
A Vector is two Q16.16 fixed components (x, y) packed into one i64 — a true
by-value type that lives in a register and never allocates (reuses the new
`long`/i64 support; llty maps `Vector` to i64). Fifteen operations, all
deterministic fixed-point reusing fx_mul/fx_div/fx_lerp and the @fn_fx_*
prelude: make/zero/x/y, add/sub/scale/dot, length/distance/normalize/lerp,
rotate/angle/from_angle.
New selfhost/emit_vector.ludic (wired into emit_ns_call + the frag list),
the `Vector` primitive type in llty and the grammars/LSP/JetBrains tokens,
docs (type-vector + 15 Vector.* pages + section), and a registered test.
Reseeded; C-free fixpoint holds; all suites green (45/25/29); site + check.py OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
De-abbreviate the two bare fixed-point conversion builtins:
flr(f) -> int -> floor(f) -> int (fixed -> int, flooring)
fx(i) -> fixed -> fixed(i) -> fixed (int -> fixed; mirrors how the
stringify builtin is `string`)
Updates the compiler dispatch, all call sites, the grammars/LSP/JetBrains
tokens, and the docs (fn-flr -> fn-floor, fn-fx -> fn-fixed). Reseeded;
C-free fixpoint holds; all suites green (45/24/29); site + check.py OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expand the abbreviated pointer types to full words on the language surface:
ptr -> pointer (a raw address / FFI handle)
ptrs -> pointers (a buffer of pointers)
The Ludic type name is distinct from LLVM's own `ptr` spelling: llty() maps
`pointer`/`pointers` to LLVM `ptr`, and the emitted IR keeps `ptr`, so only
the Ludic-level surface changes. Rewrites type annotations across all
sources, the 8 hardcoded pointer type-tags, the `pointers`-buffer indexing
in emit_addr, the grammars/LSP/JetBrains tokens, and the docs
(type-ptr -> type-pointer, type-ptrs -> type-pointers). int/bool keep their
conventional short spelling (like Math).
Reseeded; C-free fixpoint holds; all suites green (45/24/29); site + check.py OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expand the abbreviated string type and its conversion builtin to the full
word everywhere:
str -> string (the immutable-string type)
str(x) -> str -> string(x) -> string (the stringify builtin;
what `{…}` interpolation calls)
Types are recognized by identifier, and llty maps both spellings to LLVM
`ptr`, so this is an atomic source rewrite: type annotations, the Ludic
type tags, the builtin name/dispatch, and the interpolation desugar, plus
the grammars, LSP, docs (type-str -> type-string, fn-str -> fn-string), and
inventory. int/bool stay (universally accepted, like Math).
Reseeded; C-free fixpoint holds; all suites green (45/24/29); site + check.py OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expand the function-declaration keyword to the full word across the whole
language and toolchain:
fn name(...) -> T { ... } -> function name(...) -> T { ... }
Done as a self-hosting migration: teach the parser both spellings, reseed,
rewrite every .ludic definition to `function`, then drop `fn`. The compiler
now rejects `fn`. Touches the parser, all selfhost/tools/runtime/example/test
sources, the grammars (TextMate shared+vscode, ludic_syntax.h, JetBrains
LudicTokens.kt), the LSP and formatter, the Python doc/vocab tools
(check-impl, check-docs, validate, palette, test-lsp), and the docs
(fences, prose, kw-fn -> kw-function).
Reseeded; C-free bootstrap fixpoint holds. All suites green (45 regression,
24 self-host, 29 tool); the docs site generates and check.py passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expand the abbreviated public namespaces to full words, part of the
language-wide de-abbreviation pass:
Mem -> Memory, Sys -> System, Net -> Network, Collide -> Collision
Math stays (universally accepted, like int/bool). Renames the dispatch
strings, LSP signatures, docs (dirs, files, frontmatter), and the
inventory manifest; behavior is byte-identical (the bare rt_ targets are
unchanged). Reseeded; C-free bootstrap fixpoint holds; all suites green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finish the unblocked "Math / Ease" half of #25: the fixed-point
transcendentals deferred from #2. Vec.* stays blocked on the vec2 type
in #1.
- Math.exp, Math.log (natural), Math.pow — deterministic Q16.16 via two
new prelude fns in emit_math_prelude: @fn_fx_exp2 (range-reduced 5th-order
Taylor 2^f, then a clamped shift by the integer part) and @fn_fx_log2
(llvm.ctlz for the exponent + an atanh series on (m-1)/(m+1) for the
mantissa). exp=2^(x·log2 e), log=log2(x)·ln2, pow=2^(b·log2 a).
- Ease.elastic — ease-out elastic 2^(-10t)·sin((10t-0.75)·2pi/3)+1.
- Pure integer IR, so bit-identical on every platform. Results must fit the
Q16.16 range (|x| < 32768); larger magnitudes saturate (documented).
Test selfhost/tests/transcend.ludic (registered in the self-host suite) +
docs for all four. Reseeded; the C-free bootstrap fixpoint holds. All suites
green (24 self-host, 45 regression, 29 tool).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement the bulk of the namespaced-stdlib proposal (workshopsoft/ludic#2):
156 namespace methods across Math, Text, List, Ease, Collide, World, Net,
Sys, Save, Mem, extended Screen, Color functions, extended Random, and Time.
All deterministic fixed-point; self-hosting (C-free bootstrap fixpoint holds).
Compiler (selfhost/):
- Math.*: sqrt/sin/cos/tan/atan2/asin/acos (fixed-point runtime prelude —
bit-by-bit isqrt, 256-entry interpolated sine table, Ross atan2), plus
hypot/dist/dist2/deg_to_rad/rad_to_deg/posmod/wrap/ping_pong/snapped/
move_toward/smoothstep/lerp/remap/sign/floor/ceil/round.
- Text.* (complete): upper/lower/trim/repeat/pad, split/join/replace,
and the libc-backed queries.
- List.* (complete): insert/remove_at/remove/sort plus the earlier ops.
- Ease.* (in/out/in_out/back/bounce) and Collide.* (rects/point_rect/
circles/rect_circle).
- Phase 3: World/Net/Sys/Save namespaced over the bare builtins (byte-
identical IR) and Mem.* (bytes/words/copy/fill/peek/poke).
- Screen.* extended (line/circle/fill_circle/triangle/fill_triangle via new
runtime primitives; sprite/sprite_scaled aliases), Color.* functions,
Random.* (value/int/sign), Time.* (frame/delta/elapsed/now — new
game-loop frame counter).
- Fix a lexer bug: fixed-point literals with >4 fractional digits overflowed.
Docs & tooling:
- 129 new per-symbol doc pages; gen.py made data-driven (namespaces
discovered from the docs, no hardcoded list); new check-impl.py enforces
that every implemented namespace method / keyword / type / phase has a
doc page, wired into `x test-tools`. Document the previously-undocumented
keywords (break/continue/where/entry/new/public + and/or/not tokens).
- LSP: namespaced signature help (ns_method_sig) covering every namespace.
Tests: 12 new self-host/regression tests + a golden render for the drawing
primitives. All suites green (selfhost 21, regression 45, tools 29).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An opinionated, game-facing surface for the language:
- Color.<Name>: 221 named colors resolved to 0xRRGGBB at compile time
(selfhost/emit_color.ludic).
- Namespaced builtins Screen.* / Input.key / Random.* / Map.*, so calls read as
subject.action; present() -> Screen.show(), clear -> Screen.clear,
fill_rect -> Screen.fill_rectangle, etc.
- Named arguments, e.g. Screen.fill_rectangle(x:, y:, width:, height:, color:),
reordered to the callee's parameters at emit time.
- machine/become can run over a named program-scope var, not just a register.
- Migrate examples off numeric registers to named vars; snake/menu/chronorift
render byte-identical to the goldens and the bootstrap fixpoint is preserved.
- Regenerate the checked-in seed (selfhost/ludicc.seed.ll).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement the rest of NETWORKING-DESIGN.md (N2–N6) and eliminate every
`.c` file from the repo. clang remains only the LLVM-IR assembler; no C
is compiled anywhere.
Networking (selfhost/emit_net.ludic + parser/emit changes):
- N2 @Sync: per-model serialize/apply + by-kind dispatchers; POD-scalar
compile error and empty-participation warning; selective replication.
- N3 @Owned: @L_owner array + owner/set_owner/is_owner; owners snapshot.
- N4 @ToServer/@ToClients remote events: framed net_send + net_pump re-emit.
- N5 @Server/@Predicted role guards + drivable sim (tick_fixed/tick_render,
entry-owns-the-loop).
- Built-in loopback transport so multiplayer runs with zero foreign code;
extern fn net_send/net_poll still overrides it for a real socket.
- N6 blessed runtime (examples/net_rt.ludic) + end-to-end demo (net_demo).
- Fix: llty("entity") is now i32 (entities are i32 handles), so let e = self().
C elimination:
- Networking + foreign-mod-ABI tests rewritten as self-contained pure-Ludic
programs (examples/net_*, world_*, mod_events, scoped); tests/ removed.
- Reflection ABI exposed to Ludic as world_* builtins (Ludic-to-Ludic modding).
- Formatter rewritten C→Ludic: tools/ludic-tools/fmt.ludic.
- Language server rewritten C→Ludic: tools/ludic-tools/lsp.ludic (lexer, index
parser, cross-file workspace resolver, JSON, all LSP handlers).
- Obsolete migrate_*.c codemods deleted; ludic_syntax.h kept as vocabulary data.
Suites: ./test.sh 44/44, ./tools/test-tools.sh 28/28 (LSP 42/42), fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Groups B and C of the leftover-primitive cleanup — renames, not new machinery,
and deliberately NO unsafe_ prefix (a __-prefix is itself a C convention, and an
`unsafe` marker carries no signal in a fully-manual-memory language with no safe
subset to contrast against).
memory: mem_free -> free mem_realloc -> resize mem_set -> fill
ptr_add -> offset
process: os_argc -> arg_count os_arg -> arg os_exit -> exit
os_system -> run os_getenv -> getenv read_byte -> read_char
dead: mem_copy, os_time, write_byte (0 uses) — deleted
Two reseeds: accept both old and new names in the intrinsic dispatch, then
migrate every call site and drop the old names. file_open/read/write/seek/tell/
close are left as-is — they're the domain-prefixed syscall layer wrapped by
read_file, not the argc/argv-style C-ness the audit targeted; a `File` type is a
separate, larger design if wanted.
test.sh's CLI smoke updated (os_exit -> exit); check-vocabulary's grammar marker
moved off the deleted names. Reseeded (22243 lines); C-free fixpoint holds;
goldens identical; 18/18; vocab + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The last peek/poke pairs were the same "typed buffer access wearing a C name" as
peek8/peek32, so they become indexing too:
peekf(sc_x, i) -> sc_x[i] (a `fixeds` buffer -> a fixed)
pokep(gc_bmp, s,b) -> gc_bmp[s] = b (a `ptrs` buffer -> a pointer)
str_len(s) -> len(s) (len is polymorphic since 7f; str_len was dead)
Two new element-typed buffers join words: `fixeds` (32-bit fixed) and `ptrs`
(pointer, 8-byte stride). emit_index_addr dispatches on the base type; IR is
byte-identical to the old intrinsics.
The peekf/peekp buffers (ed_x0/ed_x1/ol_x/sc_x fixed coords; gc_bmp/img_px/
tt_data/ui_text pointer arrays) were retyped scope-aware, then the 33 sites
migrated to indexing. Two bugs found via a menu golden diff / a link-time type
error and fixed: the buffer-name regex truncated digit suffixes (ed_x0 -> ed_x),
and the char-literal brace-miscount skipped a few module declarations (same class
as 7j).
Reseeded (22371 lines); C-free fixpoint holds; goldens byte-identical; 18/18;
vocab (fixeds/ptrs types in, 5 intrinsics out) + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Allocation reads as intent, not malloc:
mem_alloc(64) -> bytes(64) (64 bytes -> a byte buffer)
mem_alloc(w * h * 4) -> words(w * h) (w*h 32-bit words)
bytes(n) mallocs n bytes and returns a plain pointer (byte-indexed); words(n)
mallocs n*4 bytes and returns a `words` pointer (int-indexed). Since words(X) and
mem_alloc(X*4) allocate the identical number of bytes, the migration cannot change
any allocation size — the `* 4` factor just moves from the argument into the
allocator name, pairing naturally with the Phase-7j `words` retyping
(`var fb: words = words(w * h)`).
Migrated 102 sites (mem_alloc(E*4) -> words(E), else bytes(E)); deleted the
mem_alloc intrinsic. mem_realloc/free/copy/set stay as the low-level
reallocation/free family. Reseeded (22565 lines); C-free fixpoint holds; goldens
byte-identical; 18/18; vocab + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
32-bit word access is indexing now, not peek32/poke32:
peek32(ui_rx, i) -> ui_rx[i] (reads an int)
poke32(rt_fb, i, c) -> rt_fb[i] = c (writes an int)
A buffer typed `words` (a pointer whose elements are i32) indexes with `w[i]`
as a full int; a plain `ptr`/`str` keeps byte indexing. emit_index_addr picks the
element type from the base's type — byte-identical IR to the old intrinsics, so
the migration reproduces the compiler and every golden render exactly.
The ~60 word buffers (rt_fb, tt_*/gc_* font tables, png_px/spr_px pixels, ui_*
layout arrays) were retyped from `ptr` to `words` scope-aware (per-function, so
the s/out/p byte-vs-word name collisions across functions stay correct), then the
254 peek32/poke32 sites migrated to indexing. A scope-analysis miss left 12
buffers (gc_*, sc_d, ui_rx/ui_ry) un-retyped — caught as a menu golden diff and
fixed. No true mixed byte+word access exists on any one variable, so a per-buffer
element type is sound.
Reseeded (22527 lines); C-free fixpoint holds; goldens byte-identical; 18/18;
vocab (byte/words types in, peek32/poke32 out) + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Raw byte access is now indexing, not C-style peek/poke:
peek8(src, i) -> src[i] (reads a byte, widened to int)
poke8(out, j, r) -> out[j] = r (narrows the int to a byte)
E_INDEX on a non-slice pointer/string lowers to a `getelementptr i8` + load/zext
(read) or trunc/store (write) — byte-identical to the old peek8/poke8, so the
migration reproduces the compiler exactly. A "byte" element type (llty i8) drives
the widen/narrow. The compiler's own byte work now reads naturally, e.g.
`is_slice_ty` is `t[0] == 91 and t[1] == 93`.
Subtlety fixed on the way: g_addr_ty (the out-param carrying the indexed element
type) must be set AFTER evaluating the index expression, since a member/index in
the index would otherwise clobber it — doing it early made a byte read load a
full pointer from a byte address and crash the self-compile.
Two reseeds: add byte-index support keeping peek8/poke8, then migrate 148 call
sites and delete the intrinsics (+ the now-dead emit_gep_i8). Vocabulary drops
peek8/poke8. Reseeded (22604 lines); C-free fixpoint holds; goldens identical;
18/18; vocab clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`s[a..b]` is a fresh substring of the bytes [a, b) — the modern, end-based form
of the C-style `substr(s, start, count)`:
substr(src, start, i - start) -> src[start..i]
substr(t, 2, len(t) - 2) -> t[2..len(t)]
substr(src, i, 2) -> src[i..i + 2]
Mechanics: a new E_SLICE postfix (`base[lo..hi]`, distinct from `base[i]`
indexing) lowers to a @fn_str_slice prelude (malloc + copy + terminate), emitted
once into any program that slices. Two reseeds: add the syntax + prelude, then
migrate the 22 substr calls and delete substr. The migrator recognises the
common `count == end - start` shape and emits the clean `s[start..end]` rather
than `s[start..start + (end - start)]`.
examples/strings.ludic gains slicing (now prints 1..9). Reseeded (22673 lines);
C-free fixpoint holds; goldens identical; 18/18; vocab + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Answering your readability point directly: the compiler's own string-building —
left uglier by the 7c `+` migration, e.g. `emit_bind(("load i32, ptr " + ip))` —
now reads as interpolation:
emit_bind(("load i32, ptr " + ip)) -> emit_bind(`load i32, ptr {ip}`)
emit_bind(("icmp eq i32 " + (kv + (", " + itoa(ak)))))
-> emit_bind(`icmp eq i32 {kv}, {itoa(ak)}`)
perr(("assign to unknown " + t.s)) -> perr(`assign to unknown {t.s}`)
164 concat chains across selfhost converted by a tool that flattens the `+` tree,
keeps call/index parens (only grouping parens are rewritten), and converts only
**brace-free** literals — LLVM IR structure strings full of `{`/`}` stay as `+`
rather than becoming awkward `{{`/`}}`. No new language surface; interpolation
already desugars to the same concat.
Reseeded (22565 lines); C-free fixpoint holds byte-for-byte (the strongest proof
the reconstruction is exact); goldens identical; 18/18.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`len` is now polymorphic — a slice's element count OR a string's byte length —
so the C-style `slen` (strlen) is gone: `slen(name)` -> `len(name)`. emit_len
branches on the operand type (slice header vs @strlen). Two reseeds: add the
string branch, then migrate the 19 slen calls and delete slen.
Reseeded (22565 lines); C-free fixpoint holds; goldens identical; 18/18; vocab +
doc-fences clean. (String slicing s[a..b] to replace substr is deferred to its
own phase.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One `print` instead of two C-style names: `print(x)` writes an int OR a string
followed by a newline, dispatching on the operand type (int -> %d, string ->
%s). `print(int)` emits byte-identically to the old print_int, so every existing
call and every smoke-test output is unchanged.
print_str was only ever the raw IR-to-stdout dump in ir_flush (no newline), which
is not "printing a line" — so it now uses file_write to a new file_stdout()
stream, keeping the emitted IR byte-for-byte identical. That frees `print` to
have consistent always-newline semantics.
Two reseeds: (A) add print + str + file_stdout keeping the intrinsics; (B)
migrate the 61 print_int calls to print, ir_flush to file_write(file_stdout()),
and delete print_int/print_str (+ the now-dead @.fmt_str). str(x) (the
interpolation converter from 7d) is now also a documented standalone builtin.
Vocabulary: print/str/file_stdout in, print_int/print_str out (ludic_syntax.h,
grammar, LudicTokens.kt). LANGUAGE.md updated. Reseeded (22551 lines); C-free
fixpoint holds; goldens identical; 18/18; vocab + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The readable way to build strings, as you noted `{a} {b}` beats `a + " " + b`.
A backtick string embeds any expression in `{…}` and desugars to the Phase-7c
`+` chain, wrapping each hole in `str(...)`:
`hello {name}, n={count + 1}`
== "hello " + name + ", n=" + str(count + 1)
- Lexer: a backtick captures its content raw as TK_INTERP.
- Parser: parse_interp splits literal runs from `{…}` holes (brace-depth aware,
`{{`/`}}` escape to literal braces), re-lexes each hole as a full expression
(save/restore toks/pi like an import), and folds it all into E_BIN(+) nodes —
so no new AST or runtime beyond the existing concat.
- str(x): a string passes through; int/bool/fixed convert via a small emitted
@fn_int_str prelude (digits from the end of a buffer, '-' for negatives),
emitted once into any program that uses it.
examples/strings.ludic gains interpolation cases (now prints 1..7); the smoke
covers it. Grammar + ludic_syntax.h tokenize backtick strings (holes highlighted
as embedded code). LANGUAGE.md documents it as the preferred form.
Reseeded (22530 lines); C-free fixpoint holds; goldens identical; 18/18; vocab +
doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strings are values now: `a + b` concatenates and `a == b` / `a != b` compare by
content, replacing the 605 `sconcat(...)` / `streq(...)` calls that made the
compiler read like C.
streq(name, "let") -> name == "let"
sconcat("load ", reg) -> "load " + reg
sconcat(a, sconcat(b, c)) -> a + b + c
Implementation: emit_bin gains a string path. Strings are pointer-typed, so any
`+` with a pointer operand concatenates and `==`/`!=` between pointers compares
content — except when one side is the `null` literal, which stays a pointer
identity test (the only two kinds of pointer `==` in the codebase). Both call a
small hand-written IR prelude, @fn_str_eq / @fn_str_concat, emitted once into any
program that uses string ops (so it works for tools, games and the compiler with
no runtime-splice dependency and no duplicate symbols).
Delivered as two reseeds: (A) add the operators + prelude with the full
pointer-aware dispatch, keeping streq/sconcat; (B) migrate every call site
(354 lines, via a string-literal-safe balanced-paren script that leaves the
function definitions alone) and delete streq/sconcat. examples/strings.ludic +
a test.sh smoke (prints 1 2 3 4 5) guard it.
Reseeded (21890 lines); C-free fixpoint holds; goldens byte-identical; 18/18;
vocab + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`null` is now a real pointer literal and null-tests are comparisons, instead of
`ptr_null()` and `ptr_is_null(x)`:
ptr_null() -> null
ptr_is_null(x) -> (x == null)
not ptr_is_null(x) -> (x != null)
Mechanics: a new E_NULL primary (`null`, like true/false) lowers to the `null`
pointer; emit_bin's comparison path now picks `ptr` vs `i32` from operand type
(via llty), so `==`/`!=` work on any pointer/record/slice. The two intrinsics are
deleted.
Two reseeds: (A) add the literal + ptr comparison keeping the intrinsics; (B)
migrate all 182 call sites (compiler + runtime, via a balanced-paren script that
skips string-literal args and rewrites `not ptr_is_null` to `!= null`) and delete
the intrinsics. Node/Val/Buf/Tok field defaults now read `ptr = null`.
Vocabulary drops the two from LUDIC_INTRINSICS; `null` joins true/false as a
language constant (grammar + ludic_syntax.h). LANGUAGE.md notes the literal.
Reseeded (21664 lines); C-free fixpoint holds; goldens identical; 17/17; vocab +
doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First step of the "stop feeling like C" pass. Bitwise ops were functions
(`band(x, MASK)`, `shl(a, 3)`); they are now real operators:
band -> & bor -> | bxor -> ^ shl -> << shr -> >> bnot -> ~
Precedence is Go-style so the C footgun is gone: `<<`/`>>`/`&` bind like `*`,
`|`/`^` like `+`, both tighter than comparison — `flags & MASK == 0` parses as
`(flags & MASK) == 0`. `>>` is logical (lshr), matching the old `shr`.
Mechanics: lexer tokenizes `<< >> & | ^ ~`; p_mul takes `<< >> &`, p_add takes
`| ^`, p_unary takes `~`; emit_bin routes them through the existing int arith
path (arith_code gains and/or/xor/shl/lshr) and E_UN handles `~`. The six
intrinsics are deleted.
Delivered as two reseeds: (A) add the operators keeping the intrinsics, (B)
migrate every call site to operator form (85 lines across compiler + runtime,
via a balanced-paren call->operator script; two multi-line big-endian reads in
image/truetype done by hand) and delete the intrinsics. Vocabulary drops the six
from LUDIC_INTRINSICS (ludic_syntax.h, grammar, LudicTokens.kt) and the grammar
gains a bitwise-operator rule. LANGUAGE.md precedence table rewritten.
Reseeded (21724 lines); C-free fixpoint holds; goldens byte-identical (the PNG
and TrueType decoders lean on these ops); 17/17; vocab + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bindings now signal mutability the way Rust/Swift do, instead of `let` meaning
"local" and `var` meaning "module-level":
- `let x = e` -> immutable binding; a later `x = …` is a compile error
(`cannot assign to immutable 'x' … use var`).
- `var x = e` -> mutable binding, at local OR module scope (position decides
scope; the keyword decides mutability).
- `const` -> unchanged (compile-time).
Immutability is of the *binding*, not the object: `let n = new Node; n.kind = 1`
is fine (mutation through the reference); only rebinding `n` is rejected. The
check lives in emit_assign — a direct `name =` whose target is a `let` local
(loc_mut == 0) errors; field/index targets and `var`/param/loop bindings are
unaffected.
Delivered as three reseeds so the self-hosting compiler never had to compile
source its own rules would reject:
A) add `var` as a local statement + per-local mutability tracking (loc_mut),
no enforcement;
B) migrate every reassigned `let` -> `var` across the compiler, runtime and
examples (337 declarations), driven by a per-function, string/comment-aware
scan (binding targets only, never `x.f =` / `x[i] =`);
C) turn on the check. bootstrap-cfree (compiler vs its own source) and every
golden build (which splices the runtime) then proved zero reassigned `let`
was missed anywhere.
Also folded in: removed leftover debug instrumentation in block() (a `cur=` /
print_int(777…) trace on the separator-error path) and fixed parse.ludic's stale
header comment (no more `struct`). Reseeded (21711 lines); C-free fixpoint holds;
goldens identical; 17/17; vocab + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixed the naming inconsistencies the cohesion audit flagged, converging on the
domain-first style the bulk of the surface already uses (ui_*, text_*, rng_*,
font_load, image_load):
- load_png -> png_load (asset loaders were split: font_load/image_load
- load_sprites -> sprites_load were domain-first, load_* were verb-first)
- setreg -> set_reg (missing underscore vs ui_set_int/ui_set_text)
Game builtins resolve to their `rt_` runtime function, so the renames are in
runtime/native (rt_png_load, rt_sprites_load, rt_set_reg) plus the ~100 example
call sites; `become` lowering in emit_machine now emits @fn_rt_set_reg. Purely a
surface rename — every renamed call maps to the same runtime symbol, so behavior
and golden renders are byte-identical.
Vocabulary + docs updated (ludic_syntax.h, grammar, LudicTokens.kt, LANGUAGE.md,
README, SYNTAX-REDESIGN). Reseeded; C-free fixpoint holds; goldens identical;
17/17; vocab clean.
Left as-is: os_argc/os_arg (compiler-internal intrinsics, already namespaced and
consistent with each other; renaming would need a bootstrap dance for little
gain). reg/set_reg keep the getter-bare/setter-set_ shape ui_focused/ui_set_int
already use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`struct` and `property` had identical syntax and differed only in semantics, so
they are now one keyword: `property`. How a property is stored follows from how
it is used —
- listed in a `model` or attached by `spawn` -> an ECS component, kept in the
engine's per-entity @S_/@H_ arrays and bound in queries (as before);
- constructed with `new` -> a heap record with reference
semantics (what `struct` used to be).
A program that declares only `property` records and functions — no `model`, no
`handler` — is not an ECS program: it gets record layouts and `new`, but no
entity storage, allocator, snapshot, or runtime splice. This is exactly the
shape of the Ludic compiler itself, whose Node/Tok/Buf/Val are now `property`.
Mechanics:
- record layout (%Cmp_) now always emitted in the header (emit_head), so `new`
works with or without the ECS; the per-entity arrays stay in
emit_ecs_storage. %Str_ is gone — one layout prefix.
- has_ecs() is now `has_systems() or has_models()`, not "any component"; a
property alone no longer drags in the ECS runtime. Added has_models().
- emit_new / member access / layout_ty / layout_node collapse onto find_comp.
Dropped struct keyword, parse_struct, find_struct, is_struct_ty, N_STRUCT
emission (the const stays at kind 0, the default node kind).
Migration done as two reseeds (the old compiler treats any component as ECS, so
it cannot see `property` records in the compiler source until has_ecs is fixed):
A) teach the compiler property-as-record + fix has_ecs, keeping `struct`;
B) migrate the compiler's own records to `property` and remove `struct`.
selfhost/tests/structs.ludic migrated (still prints 7 9 109 2 42). Vocabulary
drops `struct` from DECL (ludic_syntax.h, grammar, LudicTokens.kt). LANGUAGE.md
"Records" section rewritten. Reseeded (21613 lines); C-free fixpoint holds;
goldens identical; 17/17; vocab + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The handler-signature `query (vars) [terms] where …` clause and the
`reads`/`writes` clauses overlapped the `@Queries` annotation (and each other):
two ways to attach a query to a handler. Consolidated on the decorator.
- parse_system no longer parses `query`/`reads`/`writes` clauses; it keeps the
postfix `@anno(...)` channel and `phase`. A handler's query is the prefix
`@Queries(these: [...], on: Model)` annotation. Data-access hints are now
`@Reads(...)`/`@Writes(...)` — absorbed by the generic annotation skipper,
same parse-and-reserve status the old clauses had.
- The inline `for (…) in query […] where …` statement is unchanged and still
covers cross-property constraints / multiple kind filters. `query` stays a
keyword there (now dispatched via is_id so the vocabulary check sees it).
examples/hello.ludic and examples/qdecl.ludic migrated to `@Queries` (qdecl now
demonstrates a per-property constraint + `on:` tag); outputs unchanged
(4 4 10 3 / 0 3 -2). LANGUAGE.md handler sections rewritten. Vocabulary: drop
`reads`/`writes` from CLAUSE (ludic_syntax.h, grammar, LudicTokens.kt).
Reseeded (21931 lines); C-free fixpoint holds; goldens identical; 17/17;
vocab + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cohesion audit found two keywords that are pure duplicates:
- `when c { }` produced a byte-identical S_IF to an else-less `if c { }`
(no distinguishing flag) — so it was a second spelling of the same node.
- `enter Name` produced identical codegen to `become Name`: it set an
`ival` flag that emit_become never reads.
Both removed from the parser. `if` already parses with an optional `else`, so
nothing is lost. examples/scenes.ludic (an uncompiled design sketch) and the
LANGUAGE.md scenes section now use `become Name` for scene transitions.
Vocabulary synced (ludic_syntax.h, TextMate grammar, LudicTokens.kt);
check-vocabulary clean. Reseeded (22148 lines); C-free fixpoint holds;
goldens identical; 17/17.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three enable/disable statement scopes, each a reversible flag flip:
- `disable P on e` / `enable P on e` — one property on one entity. Clears
the has-flag so queries stop matching; field data persists in storage, so
enable restores it untouched. @OnDisable(P)/@OnEnable(P) handler hooks run
at the toggle point with the property bound by name.
- `disable Model` / `enable Model` — @ME_<Model> global flag; the model's
entities drop out of every query while disabled.
- `disable Handler` / `enable Handler` — @HE_<Handler> global flag; the
handler stops being called each phase while disabled.
Nothing is copied or freed — each toggle is one global store or one has-flag
store. Reduces entirely to existing ECS machinery (has-flags, kind filter,
per-phase call guards), so the data-oriented model is untouched.
New AST node S_TOGGLE; emit_toggle lowers it. Query {Model} filter now ANDs
@ME_; phase calls now guard on @HE_. Parser gains enable/disable statements
and @OnEnable/@OnDisable annotations.
Vocabulary: `on` promoted from RESERVED to CLAUSE (parser now dispatches on
it); enable/disable added as STMT keywords — synced across ludic_syntax.h,
the TextMate grammar, and LudicTokens.kt (check-vocabulary.py clean).
examples/toggle.ludic demonstrates all three scopes (prints 6 0 7 1 0);
test.sh smoke asserts it. Reseeded; C-free fixpoint holds; goldens identical.
Also: stop tracking tools/.idea/ (gitignored).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>