`float` (32-bit) and `double` (64-bit) with + - * / %, comparisons and unary minus.
Decimal literals take their type from context and stay `fixed` elsewhere; int and long
promote implicitly (LUDIC_WARN_FLOAT_PROMOTE=1 lists every promotion). float(), double(),
int(), long() and fixed() convert; floats(n)/doubles(n) buffers; float fields, globals,
constants and parameters; Math.* computes in float for float arguments; string/print
write the shortest round-tripping decimal; float_bits/float_from_bits expose the bits.
@deterministic code may not use floats. The f_* runtime helpers stay as they are.
Editors know the new type words; the JetBrains plugin is 1.5.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The arrow Key constants folded to the codes of w/s/a/d, which is what the
per-frame key reports for an arrow, but cocoa.ll has always stored an arrow in
the held-key set under 128-131. So Input.key_down(Key.Up) read the W bit and
Input.move_i's arrow half never moved anything. Input.key keeps its WASD alias,
so games comparing it with 'w' (snake, chronorift) still take the arrows.
New example input_arrows.ludic, checked by ludic-dev test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The UCRT's remove() deletes files only, so a tree removed bottom-up left every
directory behind. emit_win defines remove over DeleteFileA, falling back to
RemoveDirectoryA. Found by Maroon Lake's selftest26 ("1 left behind"), which now
passes on Windows with the rest of that game's self-tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--target <triple> (default: the host, from OS=Windows_NT) selects the Windows
runtime. emit_win.ludic defines the POSIX names the backend already calls over
the UCRT and Win32 in IR, so rename replaces an existing file, ftell is 64-bit,
and fopen is binary. Known folders follow %APPDATA% / %LOCALAPPDATA% / %TEMP%,
and the driver speaks cmd.exe, writes .exe outputs and finds LLVM's clang.
Verified: macOS three-stage fixpoint, ludic-dev test 135/135, and on Windows
the Mac-emitted Windows IR and the Windows-built compiler's IR are identical
through two generations.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ludic bundle` builds an AppIcon.icns and macOS reads it out of the .app, so a
shipped game has an icon. `ludic build` produces a bare executable: no bundle, no
CFBundleIconFile, and so no icon at all - macOS draws the generic green "exec"
tile. That is the build a developer runs every day, which is why "the game has no
icon" can be true for months while the bundle is perfect. It was here: the .app's
icns validated against iconutil, the plist was right, the signature was right,
and NSWorkspace rendered the artwork - and the binary beside it still had the
exec tile.
App.set_icon(path) takes the bytes through lp_pak_open, so a packed path and a
loose one both work and this does not repeat Audio.load's trick of taking a
filesystem path only. NSData copies them, so the buffer goes straight back. A
missing or undecodable image leaves the existing icon alone rather than clearing
it; a bundled app is unaffected; headless links no AppKit and compiles it away.
Verified the Cocoa sequence against an ObjC twin doing the same message sends:
before, a bare binary's applicationIconImage is the generic 128x128 tile; after,
it is the 1024x1024 artwork.
Backend change, so selfhost/ludicc.seed.ll is reseeded: the bootstrap fixpoint
and the C-free rebuild from the seed both pass.
They were the macOS layout on every platform - the comment above them even said
"macOS/BSD layout" - 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 joining a
path itself.
macOS save/config ~/Library/Application Support/<app>
cache ~/Library/Caches/<app>
Linux save $XDG_DATA_HOME or ~/.local/share/<app>
config $XDG_CONFIG_HOME or ~/.config/<app>
cache $XDG_CACHE_HOME or ~/.cache/<app>
macOS is unchanged to the byte, deliberately. save_dir and config_dir stay the
same directory there: 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.
An XDG variable that is set but EMPTY falls back to the default, which is what
the spec says and what an exported-but-unset variable looks like from a shell.
uname is read once and remembered rather than per call, because save_dir is
called on every save.
Verified on both branches. macOS by running it; the Linux branch by building the
probe's IR with the cached platform flag pinned, which exercises the emitted XDG
code exactly - unset, set, and set-but-empty all resolve as the spec says. The
suite's own case asserts the HOST's convention, so a macOS box covers the Apple
branch and CI covers XDG.
This is a backend change, so selfhost/ludicc.seed.ll is reseeded with it: the
bootstrap fixpoint (gen2 == gen3) and the C-free rebuild from the seed both pass.
`ludic build` produces a program. Double-clicked it opens a Terminal window, it
wears the generic executable icon, it calls itself whatever the file is called,
and it carries none of its assets. `ludic bundle` produces an application.
Everything it needs is in package.ludic, so the command takes no arguments: an
Info.plist and PkgInfo from `app` lines, an .icns built by sips and iconutil at
all ten sizes macOS asks for from a single source PNG, the asset pack in
Contents/Resources, and an ad-hoc signature - which is not optional on Apple
silicon, where an unsigned binary is killed rather than warned about. The bundle
identifier falls back to the package path reversed, so a project that never
thinks about it still gets a defensible one instead of two apps sharing a key
Launch Services hangs the Dock, saved state and permissions off.
A bundled game is moved to ~/Library/Application Support/<name> before main,
because Finder starts a .app with its working directory at "/" where no save
could ever be written. Reads come out of the pack, writes land somewhere real
and per-user, and the game's save code needs no change and no platform
knowledge.
The splash is the other half of looking like an application. A game that loads
165 MB spends a visible moment doing it with nothing on screen, which from the
outside is indistinguishable from a launch that failed. splash_show puts a
borderless window up from the same constructor that mounts the pack - before
main, so it appears while the process is still starting rather than after the
slow part it exists to cover - and reads the artwork out of the pack like any
other asset. It turns the run loop enough times to be mapped and composited
there and then; once composited the backing store survives a busy main thread,
so it stays up for the whole load.
Nothing hides it automatically. Only the game knows when its first real frame is
ready, and a splash that vanishes before that leaves the same black gap it was
covering, so the game calls App.splash_hide(). Headless there is no splash and
the call lowers to nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Ludic game opened its assets by a path relative to the working directory, so
`build/mygame` ran only from the project root and there was nothing to give
anyone but "the binary, and also this whole tree". A game is not one file, but
shipping it has to be.
`ludic pack` writes a .lpak: a header, a name-sorted entry table, a name heap
and the blobs, 16-byte aligned. Entries are stored rather than compressed - PNG,
JPEG and glTF binary arrive compressed already, and a decompressor on the load
path would spend CPU to make the file no smaller.
The reader is spliced into the compiler at the one place every asset in a Ludic
program comes through: file_open. gltf_load, tex_load, Audio.load, Fs.read_text
and the renderer's own shader loads all bottom out there, so routing it through
@lp_pak_open reaches every one of them without any of them knowing. A read of a
packed path becomes an fmemopen over the mapped bytes; everything else is the
fopen it always was. Fs.exists and Fs.size consult the packs too, so a game that
guards a load with Fs.exists keeps finding its assets once they are packed.
The pack is mmap'd rather than read: 165 MB of textures costs one syscall at
boot and pages in only what is touched. @lp_pak_boot runs from
@llvm.global_ctors, before main, so a pack is mounted before the game's first
line - and it reads packs.index, a plain list, so the mount order is explicit
and a later pack shadows an earlier one.
Without a packs.index nothing mounts and every open goes to the filesystem
exactly as before, which is every `ludic run` during development. Packing is a
shipping step and is invisible until you ship.
The compiler reproduces itself byte-exactly and the C-free bootstrap from the
seed still holds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`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>
The engine runtime and the bundled ludic.* packages belong to the toolchain,
not to the project, so the compiler has to know where the toolchain lives. It
derived that from the directory the binary sits in, which is `bin` — so an
in-repo build only found runtime/native/cocoa.ll when the caller set
LUDIC_HOME=., and an installed compiler had no way to find it at all.
ludic_home() derives it properly instead: $LUDIC_HOME when set, else the
binary's directory with a trailing `bin/` stripped, else a $PATH scan for
argv[0] (an install is invoked by bare name, which carries no directory). Both
layouts that exist then have the same shape — ~/.ludic/{bin,runtime,packages}
and a repo checkout — so the same rule serves both and LUDIC_HOME becomes an
override rather than a requirement.
`import "ludic.core/…"` also falls back to $LUDIC_HOME/packages, after the
project's own ludic_modules/, so a project that has not fetched its own copy
gets the packages that shipped with the toolchain instead of a symlink farm.
The `ludic` multi-call name is dropped from the compiler: that name now belongs
to the CLI, and --run is the flag it drives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `[a, b, c]` list literals (E_LIST → emit_list); static_type learns
slice-element, `new T`, list, string and literal kinds
- `x op= y` lowers through the same path as `x = x op y` (emit_bin_vals):
fixed `*=`/`/=` use the Q16.16 64-bit paths, string `+=` concatenates,
int→long widens; unary `-` keeps a fixed operand's type (arith_ty)
- one `unescape()` table for "strings", 'chars' and `interpolation`;
`'\''`, `'\\'`, `'\"'` no longer read as 0; unterminated char literals
and unexpected characters are errors instead of silently skipped
- every diagnostic is `file:line: error: msg` (g_parse_file / g_err_file,
Node.file + Node.line set by node()); tok_desc() in expectation errors;
duplicate `function` names and unknown `phase` names are reported in
source terms (phase_id used to default unknown phases to Overlay)
- interpolation holes skip braces inside string literals
- hand-IR preludes move from the user `@fn_` prefix to `@lp_` so a user
`is_ws` / `str_eq` / `path_join` no longer collides at link time
- `@ClearColor(expr)` accepts any constant expression; `Os.pid()` added
(docs page + inventory); `str_starts()` in support/str
- main.ludic: `else if` flag ladder, char literals, stale script comments
- examples/lang/operators.ludic covers all of the above; os.ludic covers
Os.pid; docs pages for Os.pid and the Overlay phase; ten changesets
- reseeded: selfhost/ludicc.seed.ll is the new compiler's own fixpoint
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closes the two open issues and lands the pending unreleased batch:
- #90: `Sprite { atlas: 1 }` routes esys_sprite through atlas_draw_ex
(scale/flip/tint), so cell / cell_span / strip ids of any size draw
through the engine sprite-render system. examples/library/sprite_atlas
is the pixel-readback regression.
- #91: `become` from an @On(Event) listener / global handler / plain
function no longer segfaults the compiler; it emits @L_scene_leave()
(a dispatch on the live scene id) so the leaving scene's on-exit runs.
UI_* handles are readable from any code (widget table built on first
use). examples/library/scene_menus covers it.
- fix: a windowed `ludicc -o` build that reaches the audio runtime only
through the atlas/Assets preload import now links audio.ll +
AVFoundation (the audio backend link was gated on a game-level
Audio.* call, so any windowed game declaring Sprite failed to link).
- the hand-written "Unreleased" CHANGELOG section is converted to
changesets under changes/ so `x release` generates it.
- plus the batch: engine-driven retained UI + UiClicked event, Overlay
phase, TileSkin tilemap-render system, Key.* constants, Font/Ui/File
namespaces, Sprite.strip, prefabs, managers, countdown fields,
enum-typed machines, layer @Queries, ludic.prefs / ludic.dungeon
packages, Ai.seek pathing, Solids.solid2, cursor confine (mode 3)
fix, shooter centre-aim fix, reserved-word function diagnostic.
Verified: x test (124/124), x test-tools, check-impl, check-vocabulary,
check-docs, docs-gen + docs-check, bootstrap-cfree (seed is a fixpoint).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The heart of Tiled support: load a map and draw it.
- rt_tmap model (Tmap/TmLayer/TmTileset in tiled.ludic): map header + ordered
layers (dense int32 GID arrays, heap-allocated to w*h, lifting the 96x64 cap)
+ tilesets. Built from the intermediate Value tree, so the TMX and TMJ paths
both feed it.
- GID resolver (Tiled.resolve): gid -> (tileset, localId, flipH/V/D); the three
flip flags masked off before the local-id lookup, returned alongside. gid==0
is empty.
- Image-backed render (Tiled.draw): every visible tile layer in file order,
blitting each tile from its tileset image with flips applied at draw.
- Compatibility projection (Tiled.project / auto on load): a designated
collision layer projects to the legacy byte tilemap ('#' solid, '=' one-way
via the oneway property, ' ' empty) so Grid.*/Path.*/esys_move are unchanged.
- Tiled.load resolves external tilesets + images relative to the map file and
auto-projects a collision/solids/walls layer.
- The grid and physics_tiles demos now run off a loaded map (grid_maze.tmx /
physics_map.tmx) instead of hand-authored Map.row strings, byte-identically.
Two compiler fixes fell out of this (see the changeset):
- emit_index_addr set g_addr_ty before evaluating the index, so slice[obj.field]
came back mis-typed; set it last, like the raw-pointer branches.
- @strcmp was declared by both the world table and the fs prelude; centralise
it in the head prelude so a game that uses Fs/Path links.
Proven by library/tiled_p1.ludic (14 assertions: loads+renders Kenney map
identically from .tmx and .tmj, flip mirroring) + the converted grid/
physics_tiles demos. x test: 92 passed; self-host bootstrap fixpoint intact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of the fuller type-system proposal: two by-value spatial types
that lower to packed integers (no heap, copy like scalars).
- IVec2 — integer 2D vector, a pair of int packed into one i64, for tile
and grid coordinates: make/zero/x/y/add/sub/scale/dot, the grid distance
manhattan, equal, and to_vector (widen into the fixed-point Vector).
- Rect — axis-aligned rectangle, four Q16.16 fixed components packed into
one i128, for HUD boxes and hitboxes: make/x/y/w/h, the derived
right/bottom/center, and the contains (point) / intersects (overlap) tests.
Both are exact and deterministic, bit-identical on every platform. Vector
and Color already cover phase 1's other 2D primitives.
Wired end to end: emit_core llty (IVec2->i64, Rect->i128), emit_call
dispatch, the FRAGS list + reseeded seed, a selfhost test (types2d),
per-symbol docs + type pages + inventory, and the vocabulary/editor sync
(header, JetBrains, TextMate, LSP).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ergonomic layer over the engine-owned SpriteAnim/Motion systems (#43):
- Named clips: Anim.clip("run", frames, fps, mode) registers a clip by name and
Anim.play(entity, "run") plays it; Anim.play(entity, fps, frames, mode) sets
the clip directly. A name-keyed registry in systems.ludic.
- Frame events: Anim.on_frame(entity, frame) arms optional SpriteAnim
event_frame/event_fired fields; the engine flags the tick the clip first lands
on that frame, and Anim.fired(entity) reads it — the game reacts, so it stays
inside the no-runtime-dispatch event model.
- Motion.to(entity, from, to, dur, ease) starts a value tween over the Motion
component in one call (reflection-ABI writes, resetting the timer).
- Fluent Tween handles (runtime/native/tween.ludic): Tween.to / Tween.chain /
Tween.delay build a sequenced, disposable handle advanced by a new engine-owned
system (esys_tween, run each Update tick); Tween.value / Tween.done /
Tween.parallel / Tween.stop read and control it. The 1-arg Tween.done(handle)
is disambiguated from the 2-arg pure Tween.done(timer, dur).
Splicing: g_uses_anim_rt pulls in systems.ludic; g_uses_tween_rt pulls in
tween.ludic and inserts esys_tween into the Update phase. All integer and
deterministic, so animation and motion reproduce exactly under replay/lockstep.
Worked example + regression: examples/library/anim_sugar.ludic
(4 8 2 1 0 100 100 0 0 1 20 20 30 0 1). Twelve new docs pages (Anim, the new
Motion namespace, Tween handles). Full suite 77 passed, self-host C-free fixpoint
intact, no golden drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>