A frame is drawn by more than one pass - a shadow map, a water reflection, the
scene - and actor_draw runs in each. An Actor's rim survives that because it is
a field on the actor; the queue did not, because the first pass to run emptied
it. A queued outline was drawn into whichever target came first and was gone by
the time the scene was drawn, so nothing appeared with every uniform, matrix
and mesh correct - which took a while to find.
A flush now closes the batch rather than clearing it, and the next
outline_model opens a new one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things a game could not say, each of which had been worked around.
Audio.play_at(id, gain:, pitch:, pan:) fires a one-shot with its own gain,
pitch and stereo position. Audio.volume and Audio.pitch are global - they are
the options screen - so a game placing a sound in the world was fighting them,
and distance attenuation was simply not expressible. The backend already took
volume and rate per call; this adds setPan: alongside them and stops routing
through the master state.
ludic.render3d gains outline_model(model, mat, width, r, g, b): a rim around
something that is not an Actor. The outline pass walked the actor list and
stopped, so instanced scatter - a forest - could not be highlighted at all. It
is a queue flushed by the same pass, which is what gets the depth test right
when the caller does not control pass order.
And terrain_coast(cx, cz, margin, fall), the other way to make an island:
the sea around the survey's own edge rather than cut out of the middle of it.
terrain_island measures a radius from a centre, which drowns two thirds of a
real survey to make an island of the rest; this measures inward from the
boundary, so everything the data covers stays land and the coast is where the
data runs out. Both modes gained a strand - the last few metres of height
either side of the water line compressed, which stretches a cliff plunge out
into beach and shallows.
132 regression tests and the self-host fixpoints pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Maroon Lake keeps assets/polyhaven as a symlink into a shared checkout - which
is an ordinary thing to do, and what `ludic assets` encourages - and `find`
does not follow symlinks. The pack was written, reported success, and silently
omitted all 71 files behind the link, including the sky HDRI.
What that looked like from the outside is worth recording, because it is the
failure mode this whole feature has to avoid: the bundled game started, printed
one line about an HDRI it could not read, carried on, and then died in
terrain_height reading offset 0x1cd681c off a null pointer - the CPU height
field, never allocated, because r3d_init had given up several steps earlier. A
missing asset surfaced as a segfault a long way from the cause.
So: `find -L`, and a warning when a declared root contributes no files at all.
A root that packs nothing is nearly always a typo or a link into a tree that was
never fetched, and the warning costs one line where the alternative costs an
afternoon.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`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>
The water shader's `u_screen`, and the reflection target's size, were both taken
from gl_w/gl_h - the drawable. But gl_FragCoord in that shader runs over the
scene target, which is post_w x post_h. They match only at a render scale of 1;
at anything less the refraction and depth reads landed in the wrong corner of
the frame and the lake showed a squashed copy of it instead of its own bed.
Both now come from the scene target. The reflection is sized from it too, which
also stops it paying for pixels the water never samples. R3D_DUMP_REFL reads the
target's own w/h rather than recomputing them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`terrain_island(cx, cz, r, fall)` keeps land out to `r` and then scales the
terrain down into the water over `fall` metres and on to a shelf. Scaling rather
than blending to a fixed bed is what makes the coastline come out of the terrain
that is already there: low ground turns into beach and shallows, high ground
into cliff. `r = 0` leaves the survey alone, so nothing that does not ask for an
island is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The moon was fixed opposite the sun and worth a constant trickle of light. It
now carries a phase, 0 new .. 0.5 full .. 1 new again, and that one number
decides three things at once: the disc's terminator, the fraction of its light
that reaches the ground, and where in the sky it rides.
The moon is where the sun was `lag` of a day ago, so a full moon rises as the
sun sets and a new moon travels with the sun and is never seen. A new moon is
now a properly dark night, which is what makes a carried light worth having.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An actor gains `outline` (metres of rim) and `ocol` (its colour). The pass draws
the model a second time with its vertices pushed out along their normals and its
front faces culled, so only the far side of the swollen shell survives - a
silhouette exactly `outline` metres wide. It is depth-tested against the scene,
so anything standing in front of the actor hides the rim too.
skin.vert grows an OUTLINE path for the push; outline.frag is the flat-colour
fragment shader it pairs with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Gl.*` binds the whole OpenGL 4.1 core API — every entry point of the
platform gl3.h with every GL_* constant, generated by `ludic-dev glgen`
with per-call ABI thunks. Windowed builds get an NSOpenGLContext on the
existing window at Retina resolution; headless builds render into an
offscreen CGL context, so a program that uses Gl.* renders and
screenshots identically under the test harness. It links gl.ll, the
thunks and OpenGL.framework only when used; every other build stays
byte-identical.
packages/ludic.render3d is a physically based renderer written on that
surface: HDRI image-based lighting, GPU-generated terrain with scanned
PBR materials, CDLOD, cascaded shadows, glTF with skinning, instanced
vegetation with impostors, procedural grass, water, SSAO, and an HDR
pipeline with bloom, auto-exposure and ACES.
It also carries this session's work on it: the terrain at half its cost
(10.3 -> 5.4 ms of frame), the streaming hitch that got worse the longer
you played, a resize that emptied the world, and the packaging that lets
a game use the renderer from its own repository — `ludic assets`, the
material manifest shipping with the package, and shader lookup falling
back to the install root. See changes/ for each, with its numbers.
The camping game that drove all of it has moved out to its own
repository, Maroon Lake; examples/rendering/smooth.ludic stays as the
renderer's example here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The install root is not only the artifact: `ludic add` caches fetched packages
in <root>/store, keyed by content hash. install_staged moved the whole root
aside and replaced it, so re-running the installer — which is exactly what
`ludic upgrade` does — deleted the cache and forced every project to refetch.
The store is moved into the staged tree before the swap. Nothing else in the
root is preserved, because everything else is the toolchain and should be
replaced.
Verified by staging an install, planting a store entry, upgrading, and reading
the entry back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ludic new my-game` wrote `program My-Game`, so the first thing anyone did with
a new project — run it — failed with `expected '{', got '-'`. The project name
is a directory name and the identifier is Ludic source, and they do not accept
the same characters: names are now folded into a valid identifier (my-game ->
MyGame, 2048 -> Game2048, a.b.c -> ABC) and a name that cannot be a directory or
a package is refused with the rule instead of being mangled into one.
An audit of every command's flags turned up more of the same shape, all fixed:
- build/run ignored unknown options, so `--headles` silently produced a windowed
binary, and `-o` with no path was silently dropped.
- `ludic fmt` printed the formatted text to stdout while its help said "in
place", so it appeared to do nothing. It writes now, with --check for the
report-only case a hook wants.
- `ludic test nosuch.ludic` deferred the error to the compiler.
- build-lib's failure messages ran `{tmp_dir()}` through the shell literally —
an interpolation written inside a non-interpolating string — and its argument
guess matched package.lock.ludic, then tried to compile the lockfile.
- Several messages still identified the tool as `x`.
`ludic-dev test` now scaffolds under four awkward names, builds and tests each,
and asserts a space-bearing name is refused — the case that shipped broken.
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>
docs-gen publishes install.sh with the site, but the workflow only ran for
docs/**, tools/docgen/** and tools/ludic-cli/**. v0.5.2 changed install.sh,
CHANGELOG.md and VERSION — so nothing matched, no deploy ran, and the fix that
release existed for was never served to anyone running the one-liner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
~/.bashrc was only appended to when it already existed, so on an account without
one — a fresh container, a minimal image — `bash -i`, which is what most Linux
terminal emulators start, did not see the toolchain even though every other
shell did.
.bashrc is now created when missing. Unlike .bash_profile, whose existence stops
bash reading ~/.profile, a .bashrc that only sources the env file changes nothing
else about how bash starts.
Verified across zsh -i, zsh -c, bash -l, bash -i, sh -l and dash -l on a staged
HOME: all six resolve ludic, a second run writes nothing, and .bash_profile is
still never created.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The installer edited one profile — whichever ~/.zshrc or ~/.bashrc $SHELL
pointed at — and skipped any profile that did not already exist. So a fresh
account got nothing written at all, a bash user's ~/.bashrc is not read by the
login shell macOS Terminal starts, and ~/.zshrc is only read by interactive zsh.
The toolchain installed correctly and `ludic` was still not a command.
The PATH edit now lives in one file, <install>/env (plus env.fish), and each
profile gets a single line that sources it: ~/.profile for sh and for login bash
with no .bash_profile, ~/.zshenv because zsh never reads ~/.profile and reads
this one for every invocation, ~/.bashrc and ~/.bash_profile when they already
exist, and fish's config when fish is installed. Missing .profile/.zshenv are
created; .bash_profile deliberately is not, since creating it would stop bash
from reading ~/.profile at all.
Sourcing a shared file rather than appending an export keeps a re-install from
accumulating a second entry, and leaves one place to delete when uninstalling.
Verified with a staged HOME: zsh -i, zsh -c, bash -l, bash -i and sh -l all
resolve ludic; a second run reports "already on your PATH" and writes nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ludic version` looked for bin/ludicc and VERSION relative to the working
directory. In the toolchain repo that is right by accident; from a project — the
only place a user runs it — there is no ./bin, so a perfectly good install
answered "(version unknown)". It now resolves the compiler through
ludic_home(), like every other command.
The regression test asked for the version from the repo root, so it passed for
the same accidental reason the bug hid behind; it now asks from the staged
project, where the answer can only come from the install.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Getting started meant cloning the repository, bootstrapping a compiler and
learning a task runner called `x`. That is a contributor's workflow handed to
everyone who wants to try the language.
Installing is now one command:
curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh
install.sh puts a complete toolchain — compiler, CLI, engine runtime, bundled
ludic.* packages, formatter, language server — in ~/.ludic and adds it to PATH.
Prebuilt artifacts are checksum-verified; where a platform has none, or the
release predates this layout, it bootstraps from the compiler's own IR seed with
clang. The docs site publishes the script beside the pages that quote it, so the
page and the script can never come from different releases.
`x` becomes `ludic`, and the surface splits by audience. A user of the language
sees `new`, `run`, `build`, `test`, `add`, `fmt`, `lsp`, `doctor`, `upgrade`;
`ludic new` scaffolds a project that builds and plays as it stands. Everything
the toolchain repo needs moved under `ludic dev` — build, test, reseed,
bootstrap-cfree, docs-gen, release — unchanged apart from the namespace. Those
tasks read arguments one position further along, so dispatch_dev sets a shift
and commands use arg_n()/arg_total() rather than each knowing its own depth.
Release artifacts become complete install roots (bin/ beside runtime/, packages/
and VERSION) rather than bare binaries, which is what the installer unpacks.
`ludic dev test` asserts the whole shape: it stages an install, puts it on PATH
with no LUDIC_HOME, and runs new -> build -> test through it.
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>
Clicking a keyword, type, builtin or namespace method in a code sample is
supposed to open a summary card for it. The script that builds those cards
never went away; its stylesheet did.
The site redesign split item.css into base.css and docs.css and filed the card
chrome — .hovercard, .hc-*, .tok, .kind-badge — under docs.css. Only the four
reference page kinds link that file. The landing page links base.css and
site.css, so a click there appended an unstyled, position:static div to the end
of the document: built, filled with the right text, and invisible.
The card chrome now sits in base.css beside the .t-* token colours it belongs
with, which every page links. It is also position:fixed rather than absolute,
matching the viewport coordinates positionCard() reads out of
getBoundingClientRect() — absolute put the card an entire scroll offset away
from its token on any reference page read past the fold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README opened with three restatements of "no C", then a limitations table,
then a repo-layout map, and closed with Chrono Rift's keybindings — P2/Mage
`I`/`K` select, `J` confirm — which is a game manual, not a language README. It
never showed the language itself.
It now leads with what Ludic is, a compiling code sample, how to build, what the
language offers, and an honest status. The layout table is contributor material
and CONTRIBUTING already covers that ground.
Two things were not merely stylistic:
- Nine links pointed at wiki pages that no longer exist.
- "Language at a glance" advertised `system` with `reads`/`writes`, plus
`requires`/`ensures`. None of those are keywords — the grammar has `handler`,
and `System.*` is a namespace. That bullet described a vocabulary retired
several releases ago.
The sample is verified to compile, every internal link resolves, and every
command named is one `x help` actually offers.
Also makes commit-lint survive a force-push: it linted `event.before..sha`
without checking that `before` still resolves, so rewriting or gc'ing that
commit failed the job with "Invalid revision range" on a push whose messages
were all valid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build_artifacts wrote a single dist/SHA256SUMS covering whatever that host
happened to build. But a release is assembled from more than one machine — the
Linux runner cannot produce the darwin-arm64 toolchain — and
forgejo_upload_assets deliberately skips an asset whose name is already
attached. So the first host to publish wrote SHA256SUMS, and every artifact
added later was silently left uncovered by it.
Emit one <artifact>.sha256 per tarball instead. The names are unique, so each
host's contribution stands on its own and nothing goes stale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Publishing the site force-pushes an orphan `pages` branch. Two runs racing can
therefore land out of order and leave `pages` holding the older build, with both
runs green and nothing to indicate the site went backwards.
A `pages-deploy` concurrency group with cancel-in-progress makes a newer push
cancel an older in-flight build rather than queue behind it, so the last push to
land is the one that ends up published.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every workflow's first step clones ${{ github.server_url }}, which on a
self-hosted Forgejo instance is an internal address like http://forgejo:3000.
The runner's default is to put each job on a freshly created per-job network
that the Forgejo container is not attached to, so the clone dies with
fatal: unable to access 'http://forgejo:3000/…': Could not resolve host
Nothing in the repo said so, and the failure is intermittent: Docker forwards
names it cannot resolve to the host's resolver, which answered for the container
name often enough that CI passed for weeks before stopping.
Documents the fix (pin job containers to a network Forgejo is also on, using a
dedicated one rather than the general application network so a CI job cannot
reach unrelated services) and how to verify it without running a workflow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each clang invocation the task runner makes emitted
warning: overriding the module target triple with arm64-apple-macosx15.5.0
four times per `x build`, with nothing for anyone to act on. `cc()` now passes
-Wno-override-module, the same flag ludicc already passes for its own link.
The comment in selfhost/main.ludic explaining that flag had it backwards — it
claimed "our IR carries an explicit target triple", when the emitted IR names
no triple at all, which is precisely why clang substitutes the host's and
warns. Corrected. No IR changes, so the seed is untouched.
Also adds .claude/launch.json entry for previewing the generated docs locally.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bin/ is gitignored and not checked in, so the first command in README.md,
COMPILING.md, CONTRIBUTING.md, tools/x/main.ludic and on the site —
clang selfhost/ludicc.seed.ll -o bin/ludicc && ...
failed on a clean checkout with `ld: open() failed, errno=2 for 'bin/ludicc'`.
Verified against a fresh clone. Every copy now leads with `mkdir -p bin`, which
is what the CI workflows had been doing all along.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was no release workflow. Artifacts were built by `x release --publish` on
whatever machine the maintainer was sitting at, from whatever happened to be in
bin/, with no checksums and nothing proving the tagged tree passed its tests.
Pushing a v* tag now publishes. The workflow builds the toolchain from the IR
seed, runs `x test`, `x test-tools` and `x bootstrap-cfree` against the tagged
tree, and only then creates the Forgejo release. It refuses to publish when the
tag and VERSION disagree, or when CHANGELOG.md has no section for that version.
`x publish [vX.Y.Z]` is the command behind it and runs locally too. It builds
dist/ — a source tarball from the tag, this host's toolchain, and a SHA256SUMS
covering both — and takes the release notes from that version's CHANGELOG
section, so notes and changelog cannot drift. It only adds assets the release
is missing, which is how a macOS build gets attached to a Linux-built release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build_section piped every changeset body through `tr '\n' ' '`. A multi-line
changeset came out as one paragraph, so nested bullets rendered as inline
" - " runs and a whole release read as a single unbroken block — v0.3.0 was one
~4 KB bullet.
The renderer is now Ludic rather than a shell one-liner. A section is grouped
by conventional-commit type (Features, Fixes, Performance, ...), each changeset
is one bullet, and continuation lines are indented two spaces so nested lists
and paragraphs stay inside their item. Bullets are sorted within a group, so
cutting the same release twice produces the same text.
Also:
- `x release --dry-run` renders the pending section to stdout and touches
nothing, so a release can be read before it is cut.
- `x changelog-render` re-renders a section from a directory of changesets, and
`x changelog-section` prints one release's section back out of CHANGELOG.md.
- The v0.1.0 and v0.3.0 sections are re-rendered with the former, from the
changesets recovered at each tag's parent commit; the bullet counts (6 and
25) and word multisets are unchanged. v0.2.0 is left alone: it carries a
hand-written summary and topical subheadings, and regenerating it would have
replaced curation with raw changeset dumps. The file header now says that
a section may carry such a summary, since it previously claimed released
sections are never hand-edited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generated site had the shape of a product launch page: a near-black navy
ground with mint/coral radial glows, a gradient-clipped headline, a glowing
pill badge, nine emoji feature cards and scroll-reveal animations. None of it
told a reader anything about the language.
It is now typographic and light-first — a warm paper ground, a serif display
face, one ink-blue accent used only where it means something, and rules
instead of floating cards. Colour is reserved for code. Dark mode is the same
design with the ground inverted, defined once as tokens under a single
prefers-color-scheme block.
Structurally:
- base.css holds the tokens and shared chrome; site.css and docs.css hold what
is specific to the landing page and the reference pages. They ship as linked
files rather than being inlined into all 900+ pages, which takes the site
from 16 MB to 5 MB and means a design change no longer needs a regenerate to
be seen.
- api.css was dead — the generator never referenced it, rendering the API index
with item.css — and is gone. docs.css replaces item.css and covers all four
reference page kinds.
- Grids draw their separators as cell borders instead of bleeding a ruled
background through gaps, so a final row with fewer cards than columns stops
cleanly instead of leaving a grey hole. The feature card count is not a
multiple of the column count at any breakpoint.
- Inline code loses its tinted chip; in a language reference, a box behind
every keyword turns a paragraph into confetti.
- Fonts are the platform's own, so the site makes no webfont request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- LANGUAGE.md: real diagnostic format, list literals, Font.load / Ui.*
(no `reg`/`set_reg`, no `/Handler/Library` typo), `extern function`,
existing example links, Input.key(); builtins table trimmed to what exists
- COMPILING.md: pipeline names selfhost/ (not compiler/*.c), `program`
instead of game/module, all thirteen win_* entry points by group
- README.md: the window seam is not "five" functions
- CONTRIBUTING.md: docs are checked with `x docs-gen` / `x docs-check`
- docs/language: kw-ui and fn-ui_build use the namespaced API;
@ClearColor documents constant expressions; examples/README lists
operators.ludic
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- runtime: HEADLESS_FRAME_PATH, STICK_DEADZONE / STICK_LEFT_X/Y, key and
byte codes as char literals throughout (`k == 'w'`, `fill(rt_map, ' ', …)`)
- ludic.gameplay/stats: drop the duplicate `stat_field` (it answered "atk"
for every build stat); Stats.base uses stats_field_name
- ludic.shooter: compare aim modes and fire patterns with AimMode.* and
WeaponPattern.* instead of raw ints; STICK_RIGHT_X/Y
- ludic.npcai: DecisionMade / brain_set_state use AiState.*
- examples/games/menu.ludic uses Font.load / Ui.* with FONT_PATH and
BACKDROP named; strings.ludic header says what it prints
- whole tree: `x = x + 1` → `x += 1` (single-term right-hand sides only),
`0 - x` → `-x`, ASCII codes → char literals; every .ludic and every
```ludic fence reformatted with the fixed formatter (whitespace only)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- ludic-fmt: `rows[i]`, `new []int`, `s[a..b]`, `emit(`, `~x`, list-literal
and query-tag braces stay tight; member calls hug their paren
(`Date.new(`, `Prefab.spawn(`); `<< >> & | ^ ~` are operators and
`&& ||` are not — mirrored in ludic_syntax.h, LudicLexer.kt and the
TextMate grammar. 149 of 274 tracked sources failed --check before.
- ludic-lsp: `initializationOptions.compilerDiagnostics` / `compilerPath`
are honoured — on save the document's compilation unit is compiled and
`file:line: error: msg` is published as a "ludicc" diagnostic (the
option had been documented but never implemented); the workspace scan
file is per process; bracket codes spelled as char literals
- Overlay added to LUDIC_PHASES, LudicTokens.kt, ludic-mode.el and the
grammar (the game uses it; check-vocabulary flagged the drift)
- editors: VS Code snippets/package.json/extension.js (`${workspaceFolder}`
and `~` expansion, PATH lookup, Apache-2.0, real repo URL), Neovim root
markers, Helix/Zed/Sublime bin/ paths, Emacs vocabulary, JetBrains
comments; tools/editors/README.md no longer describes C tooling
- .forgejo workflows clone `${{ github.server_url }}/${{ github.repository }}`
so a fork or mirror tests itself; the docs publish pushes the same way
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- every scratch file lives in `$TMPDIR/x_<pid>/` (tmp_dir/tmp_path in
prelude.ludic), removed by main's new dispatch() → tmp_cleanup();
X_KEEP_TMP=1 keeps it. `x test` and `x check-*` may now run together.
- `x test-lsp` (tools/x/lsp_test.ludic) replaces tools/test-lsp.py: the
whole request stream is framed into one stdin file, the server runs to
`exit`, and the response stream is parsed back by request id; adds a
check that ludicc's own error is published on save
- tools/x/forgejo.ludic replaces tools/ci/forgejo_release.py (curl with a
0600 header file; the token is no longer on the command line;
LUDIC_FORGEJO_API for forks)
- `x docs-palette --check` regenerates into scratch and compares, so the
drift guard judges the working tree rather than git HEAD; the generator
no longer emits a trailing blank line the formatter rejects
- `x test-tools`: exit 2 from test-lsp / test-grammar.js is a visible
skip, never a pass; the widget-prop test uses the `id: Root` syntax
- tools/test-grammar.js: current vocabulary (property/model/handler/
prefab/scene/event/become/@Queries), LUDIC_NODE_MODULES, exit 2 on skip
- tools/atlas.ludic rewritten in the current language (it did not compile)
- operators.ludic / os.ludic registered in the suite
- json.ludic: j_quote() writer helper
Co-Authored-By: Claude Fable 5.1 <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>
Comprehensive migration guide for the v0.3.0 release: non-breaking upgrade,
the windowed quit-key + input auto-drive behaviour changes, the draw_sprite
deprecation, and adoption recipes for the package manager, controllers, Tiled
maps, and the #75-#89 engine/language features. Linked from the README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ludic's ECS is already pool-based — the allocator recycles freed entity slots
through a freelist (L_alloc pops @L_freen before growing @L_entc), and component
storage is fixed per-entity arrays, so spawn/despawn churn (bullet-hell/horde)
does no per-spawn heap allocation and cannot fragment. Expose that with a Pool.*
namespace so a game can watch reuse: Pool.live (alive now), Pool.free (recycled
slots waiting), Pool.reserved (high-water — stays flat across a steady
spawn/despawn loop, proving reuse not reallocation), Pool.capacity (the fixed
cap). Zero-cost inline reads of the existing counters.
Example pool.ludic proves the key property: after despawn+respawn,
Pool.reserved() stays 3 (freed slot reused) — prints 0 0 3 3 0 2 1 3 0 3 1.
4 docs pages. Full suite 118/0, goldens byte-identical, fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Assets loaded synchronously in Boot stalled the first frame(s). Adds an
Assets.* preload queue over the #81 atlas: Assets.enqueue(name, path) queues a
named image without loading it, Assets.pump(max) loads up to max per frame
(returns how many), and Assets.total/loaded/ready/progress (0..100) drive a
progress bar. A loading scene pumps a few per frame, draws Assets.progress(),
and becomes the play scene once Assets.ready() — the deterministic, no-threads
form of async preloading (work spread across frames; same enqueue+pump order
loads identically every run). Loaded assets are reachable by name via
Assets.get / Sprite.named.
Example preload (enqueue 3, pump incrementally 0->33->66->100, ready flips, get
by name) prints 3 0 0 0 1 33 66 1 100 1. 6 docs pages. Full suite 117/0,
fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`namespace Name { export function foo(...) ... internal function bar(...) ... }`
declares a Name.* namespace once and controls its public surface declaratively,
instead of annotating every function with @Namespace(Name). Inside the block
each `function short(...)` is emitted as `namelower_short`; export (the default)
makes it callable as Name.short(...), internal keeps it a private helper
(emitted, callable by short name from siblings — calls are rewritten — but
Name.internalOne() is a compile error). Block sugar for the per-function
@Namespace annotation; a package's public API reads at a glance. Namespaces
declared the old way are unchanged (gameplay_foundation still passes).
namespace added to LUDIC_KW_DECL + JetBrains/TextMate + docs page + inventory
(vocab/impl/docs checks green). Example namespace_block (export + internal +
sibling calls + internal-visibility compile error verified). Full suite 116/0,
fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>