emit_bin_vals lowered every pointer-typed ==/!= to @lp_str_eq, so two
distinct records compared their bytes up to the first zero byte. Content
compare now needs both sides to be text (string, or the untyped
pointer/ptr runtime code carries text in); other references use
icmp eq ptr, and string vs a non-text reference is a compile error.
Adds selfhost/tests/identity.ludic; both seeds regenerated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`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>
- JetBrains plugin 1.4.0: semantic colours (builtin / vendor / own), template strings,
brace handling, run configurations and a test console, package.ludic and
package.lock.ludic editing (completion, docs, app preview, colour previews, asset
navigation), External Libraries for the runtime and packages, doc pages for built-ins
- ludic-lsp: go to definition for imports, document links, hover with inferred types,
type definition, signature help with parameters, docs from docs/language
- `import "dir"` resolves a barrel `dir/index.ludic`
- package.ludic `entry`, `script` and `hook before|after <command>`; `ludic <script>`,
`ludic script`, `ludic scripts`
- `ludic test --verbose` and `--test NAME`; the test runner filters by name
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Windows runtime keyed the held set by the character MapVirtualKeyA gave a
virtual key, so a game's WASD belonged to whatever the active layout put there,
and with an input method on every letter arrived as VK_PROCESSKEY. The typing
block is now read from the scancode (the arrows, numpad and F-keys still by
virtual key); macOS reads keyCode the same way. Input.key_label(key) names a key
in the player's own layout (Windows) or as its US character elsewhere.
Verified on Windows with SendInput into a live window: VK_Z carrying W's scancode
holds 'w', VK_PROCESSKEY carrying A's holds 'a', Caps Lock changes nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ludic-dev shaders: a variant whose first file is *.mesh compiles that stage with
glslang -S mesh for Vulkan 1.3, without the vertex stage's depth-remap wrapper
or invariant gl_Position (a mesh shader writes an array of positions and
remaps depth itself). Its SPIR-V keeps the .vert name, so the manifest and the
loader are unchanged. The 51 existing programs build identical SPIR-V.
runtime: lsl_call_piii(fn, ptr, i32, i32, i32) calls a command-buffer command
through a pointer. NVIDIA Streamline's interposer exports no
vkCmdDrawMeshTasksEXT, so it is to be looked up per device with
vkGetDeviceProcAddr. Both runtimes assemble.
Nothing uses either yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
streamline.ludic: slInit before the Vulkan instance, feature support per
adapter, a frame token per frame, Reflex sleep and PCL latency markers, and
DLSS super resolution on the lit HDR frame (Halton jitter, depth + zero
motion vectors with camera motion from clipToPrevClip, matrices carrying
the Vulkan path's y flip and depth remap). Bloom, tonemap and sharpen read
the upscaled size. The device asks for privateData and present_id, which
Streamline's hooks need. R3D_DLSS / R3D_REFLEX / R3D_SL_LOG for tests.
gpu_feature_implemented: DLSS and Reflex.
ludic bundle (Windows): app native "<dir>" copies native libraries beside
the executable.
Verified on an RTX 3070 Ti: DLSS Quality evaluates 1280x720 -> 1920x1080.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Device: multiDrawIndirect, drawIndirectFirstInstance and drawIndirectCount where present.
- Buffers carry storage and indirect usage; a GPU-owned buffer is never swapped under a draw.
- Compute programs from shaders/compute.list (binding 0 parameters, 1.. storage buffers),
built by `ludic-dev shaders`; gpu_compute / gpu_dispatch / gpu_draw_mesh_indirect in gpu.ludic.
- R3D_VK_PROBE=1: a dispatch read back (OK on the RTX 3070 Ti).
- scatter_cull.comp: a tree layer's frustum test and LOD split on the GPU, with the lit, prepass,
impostor and shadow-LOD draws reading its records. Behind R3D_GPU_CULL=1 and off by default:
at the camp it is slower (43.0 fps against 53.3), because the frame's cost is per-draw
descriptor sets and it adds empty-level draws. Validation-clean; OpenGL frames unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `fn name` names a top-level function as a value (E_FNREF, lowers to @fn_<name>); the worker
entry point for Job.parallel_for, which checks it takes (int, pointer-like) and returns void.
- runtime/native/threads.ll (pthreads) and threads_win.ll (Win32 SRWLOCK/CONDITION_VARIABLE): a
pool of one worker per core but one, parked between batches; every thread claims chunks by
compare-and-swap. Linked only into programs that use Job/Promise/Sync, by `ludicc -o`,
`ludic build` and the test suite's build helper.
- Sync.* is real: native mutexes, atomics as cmpxchg retry loops (neither clang takes atomicrw,
the PC's rejects seq_consistent), mutex-guarded channels, Sync.cpu_count from the OS.
- spawn/despawn on a pool thread stop the program with a located panic.
- examples/library/threads.ludic and its test; docs for fn, Job.parallel_for, Job.is_worker.
- Reseeded (bootstrap-cfree: out.ll == seed.ll). 141/141 on macOS; jobs, threads and the guard
pass on Windows from the reseeded Windows seed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OpenGL links a vertex output to a fragment input by name; SPIR-V links them by location, and
glslang's --auto-map-locations numbered each stage in its own declaration order. The foliage
prepass's depth.frag declares v_wpos then v_uv where model.vert writes v_wpos, v_nrm, v_uv, so
the prepass read a normal as its texture coordinate, its alpha test cut every flower head and
leaf, and the lit pass (depth EQUAL) drew nothing over them. `ludic-dev shaders` now gives both
stages explicit locations: the vertex stage's out order numbers them and the fragment stage looks
each in up by name. All 45 variants checked: every fragment input sits on its vertex output.
Also:
- A clear still waiting for its pass when the framebuffer changes now runs on that framebuffer,
instead of becoming the load op of whichever pass began next.
- R3D_DUMP_ATLAS writes every impostor and card atlas a run bakes (build/atlas_<n>_*.ppm); the
40 baked on Vulkan match OpenGL's.
The PC's Vulkan frame now shows the flowers as OpenGL does, validation-clean. ludic-dev test 140
passed; OpenGL frames byte-identical at the five viewpoints; 59 self-tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things the first Vulkan frames on the RTX 3070 Ti showed against OpenGL on the same PC:
- Exposure: the adaptation pass reads the HDR scene's smallest mip, and a render target made
without pixels had one level, so exposure came from a single texel. A target asked for mipmaps
now grows a full chain (level 0 kept), and passes draw through level-0 views keyed by the
image's generation.
- Foliage: the depth prepass and the lit pass (depth EQUAL) are different variants. Vulkan vertex
stages now declare an invariant gl_Position so both land on the same depth.
- Alpha to coverage is enabled only on a multisampled pass. OpenGL ignores it without MSAA; Vulkan
with one sample dropped every fragment under half alpha.
vk_resources also checks a big-endian 16-bit RGB upload (a normal map). VKRES OK; ludic-dev test
140 passed; the PC's Vulkan frame is validation-clean; OpenGL frames byte-identical at the five
viewpoints with 59 self-tests passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The renderer's projections are OpenGL's, whose clip-space depth runs from -w to w; Vulkan clips
everything below 0. `ludic-dev shaders` now wraps each vertex stage - its own main runs, then
gl_Position.z = (z + w) / 2 - so every variant's depth lands in [0, w]. The GLSL the OpenGL
renderer compiles is untouched. No y flip is needed: a Vulkan target's row 0 is where OpenGL's is
(NDC y = -1), so render to texture, sampling and gl_FragCoord agree between the two, and only the
present and the screenshot flip.
45 vertex modules regenerated (spirv-val clean, manifest unchanged); ludic-dev test 140 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vulkan cannot compile GLSL when the game starts, so the programs render3d builds are listed
(shaders/variants.list, 45 of them, collected with R3D_PROGRAMS_LOG across the self-tests, the
screens, the viewpoints and the debug switches) and `ludic-dev shaders` compiles each into
shaders/spv/<id>.vert.spv and .frag.spv with a manifest of what the backend needs: each
stage's uniform block and member offsets, the samplers' bindings, the vertex inputs.
The GLSL is the renderer's own, assembled as programs.ludic assembles it, through glslang's
relaxed Vulkan mode, so gpu_uniform / u_* can write the same uniforms into a block on Vulkan.
Bindings are assigned by the tool (glslang's own numbering put several samplers of one stage
on binding 0): the vertex block is 0, the fragment block 1, samplers from 2 in name order,
shared across both stages. Every stage passes spirv-val; `ludic-dev test` rebuilds and compares
wherever the Vulkan SDK is installed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ls a b` fails when `a` is missing even though `b` exists, so with VULKAN_SDK unset the
guard skipped the check on a Mac that has the SDK under ~/VulkanSDK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ludic-dev vkgen reads vk.xml into runtime/native/vk_api.ludic (constants, every struct's
<Struct>_sizeof and <Struct>_<field> offsets, one extern per command) and vk_thunks.ll.
Every size and offset was compiled against the SDK's C headers; `ludic-dev test` checks the
tracked files against the registry wherever the Vulkan SDK is installed.
vk_win.ll (vulkan-1.dll) and vk_mac.ll (libvulkan.1.dylib, MoltenVK) open the loader at run
time, so a program built with Vk.* starts on a machine without Vulkan. ludicc and ludic
build link both for any program that uses Vk.*. The seeds are regenerated for the new
compiler.
vk_probe reports what a machine's Vulkan can do; vk_compute dispatches a Slang compute
shader and reads the picture back, clean under the validation layer on an RTX 3070 Ti and
on an M4 Pro through MoltenVK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The delta was this frame's position minus the last, and the last started at 0,0, so the
first frame reported the cursor's whole distance from the corner as motion. A cursor-mode
change did the same, switching between a locked cursor's virtual reticle and the real
cursor. Maroon Lake's camera adds mouse_dy to its pitch and came up pointing at the ground.
examples/library/input_mouse_rebase.ludic covers both cases, plus ordinary motion and
re-setting the mode already in force.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
http_link_flags grepped the IR for "@hs_", which every program's header
declares, so `ludic build` linked the HTTP transport and Foundation into
everything - invisible on macOS, a failed link on Linux, where it broke four CI
cases (ludic run, the installed layout, ludic new, the seed build through
`ludic build`). It now looks for a call to hs_send: examples/library/http.ludic
has one, snake.ludic (which still declares ten @hs_ names) has none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The asset-pack boot calls _NSGetExecutablePath, which glibc does not have, so
the Linux link of the compiler seed failed in every CI job - build-and-test,
cfree-fixpoint and every publish run - since packs landed. The last release CI
created was v0.7.0. The Linux shim now defines it over /proc/self/exe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CLI's shell commands go through shell(), which is run() on POSIX and a
scratch script handed to Git for Windows' bash on Windows (exit codes read
directly there). compile_app links through `ludicc -o` on Windows, ludic run
starts the .exe, and ludic-dev build and ensure_ludicc assemble
selfhost/ludicc.win.seed.ll, which ludic-dev reseed now writes beside the
macOS seed. ludic bundle makes build/<name>/ with a GUI-subsystem exe carrying
the .ico beside `app icon` as an llvm-rc resource, game.lpak and packs.index;
ludicc gains --gui and --link, and quotes its whole link line for cmd.exe.
Verified: ludic-dev test 135/135, selfhost-test 32/32; on the PC a checkout
bootstraps from the Windows seed, ludic-dev build makes the toolchain, and
`ludic bundle` makes build/Maroon Lake/, which runs from its own folder.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compile_app linked gl.ll when the IR named @lgl_* but never http.ll and
Foundation for @hs_*, so examples/library/http.ludic failed to link under
`ludic build` on every hs_* symbol while `ludicc -o` built it. http_link_flags
greps for @hs_ and links them in both modes; ludic-dev test now builds the
example through `ludic build` as well.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gl_win.ll creates a hidden-window WGL 4.1 core context and carries gl.ll's
float/memory helpers, with ldexp and QueryPerformanceCounter in place of the
libSystem calls. glgen now also writes gl_thunks_win.ll: the same 478 thunks,
calling through pointers that @lgl_win_load fills from wglGetProcAddress (and
opengl32.dll for GL 1.1). ludicc links the pair against opengl32/gdi32/user32
on a Windows target.
Verified: gl_api.ludic and gl_thunks.ll regenerate byte-identically, ludic-dev
test 135/135, selfhost-test 32/32, and headless gl_triangle on an RTX 3070 Ti
matches the macOS frame to within one level per channel.
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.
A pack root is packed wholesale, and that is the right default - a game writes
`pack "assets"` and everything it opens is in the pack. What also goes in is
everything the game does NOT open: the preview renders a model pipeline leaves
beside its meshes, the intermediate a texture bake writes and never reads again,
the .blend the .gltf came out of. Nothing errors, nothing looks wrong, and the
app is simply bigger than the game. The only way out the manifest offered was
naming every file by hand, which is worse - a list that goes stale the day
someone adds a texture.
So `.packignore`, with gitignore's rules, because that is the file everyone
already knows. Anchored and floating patterns, `preview/` for directories only,
`*` and `?` stopping at a separator where `**` crosses one, `[a-z]` classes, `!`
re-includes with the last line winning, a deeper file beating a shallower one,
and no re-including out of an ignored directory.
The semantics are not claimed, they are checked: the implementation was diffed
against git itself over two fixtures - 35 paths, 19 patterns, nested ignore
files, directory negation, `[!0-9]` and `\#` escaping - and `git check-ignore`
and `ludic pack` agree on every path.
Two rules of its own, because a pack is not a working tree. `.packignore` is
never packed (nothing reads one at run time, and --no-ignore does not bring it
back). And it governs the project's own roots only: a package's resources - the
renderer's shaders above all - are added after the gather, so a stray `*.frag`
in a game's ignore file cannot quietly un-ship what it needs to draw anything.
`ludic pack` reports what it left out; `--no-ignore` packs everything so you can
see what a rule is costing. `ludic bundle` gathers through the same path, so the
two agree by construction.
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>
`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>
`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>
`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>
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>
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>
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>
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>
- 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>
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>
A windowed action game can hide the OS cursor and lock/confine the mouse to the
window. Input.cursor_mode(mode): 0 normal, 1 hidden (draw your own reticle),
2 locked (hidden + dissociated — the mouse feeds relative motion via
Input.mouse_dx/dy and Input.mouse_x/y is a clamped virtual cursor, FPS/twin-stick
aim), 3 confined (dissociated but visible; the mouse can't leave the window).
The platform auto-releases (shows + reconnects) while the window is not key
(Cmd-Tab) and on close, so the cursor is never left captured. Headless it is a
no-op (DCE'd).
Native macOS impl in cocoa.ll: [NSCursor hide]/[unhide] (ref-counted, toggled
only on change so the count stays balanced across focus changes),
CGAssociateMouseAndMouseCursorPosition, and CGGetLastMouseDelta for the relative
virtual cursor, behind a new win_cursor_mode intrinsic. Windowed-only behaviour
(not in the headless golden suite); example compiles headless and links
windowed. Full suite 115/0, fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sprite loading was a bare png_load — one file per 16x16 sprite, no way to load
one sheet and address a cell by grid coords or name. Adds a Sprite.*/Assets.*
runtime (atlas.ludic) over the variable-size image loader, so a cell is a
sub-rect of the kept image and is NOT restricted to the 16x16 sprite table:
Sprite.sheet(path,cw,ch), Sprite.cell(sheet,col,row),
Sprite.cell_span(sheet,col,row,cols,rows) (a sprite may span >1 cell),
Sprite.define/named (name + lookup), Sprite.draw/draw_scaled (through
camera/zoom/clip like Screen.sprite), Sprite.width/height, and
Assets.image/load/get. Spliced on demand (Sprite.sheet/… or Assets.*), so a
program using neither is byte-identical.
Example examples/library/atlas.ludic (verified against a real 12x11 Kenney
sheet, incl. a 2x3 multi-cell span and named lookup). 14 docs pages. Full
suite 114/0, goldens byte-identical, fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The compiler auto-splices runtime/native/* for any ECS game, but resolved it
relative to the build CWD, then fell back to the package module root
($LUDIC_MODULES) — forcing every external project to copy/symlink the engine
runtime into ludic_modules/. The runtime is part of the toolchain, not the
project: do_import now resolves a runtime/... import that isn't found locally
from $LUDIC_HOME (default: the compiler binary's dir — where cocoa.ll/audio.ll
already come from), before the module root. So ludic_modules/ holds only
third-party packages.
In-repo builds are byte-identical (the runtime resolves locally there, so the
$LUDIC_HOME fallback never fires; fixpoint holds). Verified by a hermetic test
that builds an ECS game from an external CWD with no runtime/ or ludic_modules/
under it, resolving the runtime from LUDIC_HOME. Full suite 113/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#79 — Input.axis_i(neg,pos) -> int returns a -1/0/1 movement intent from the
multi-key device set, so WASD-to-movement needs no bool->int glue and feeds an
int mover directly (dx = Input.axis_i('a','d')).
#84 — a Bounds config entity (rect + policy, from ludic.core) drives the
engine-owned world-bounds system (LateUpdate): clamp / wrap / bounce (clamp +
flip Body velocity) / kill (despawn a body fully outside). Reads the Collider
size so the box stays inside; off by default, spliced only when Bounds is
declared (byte-identical otherwise). Adds World.despawn(e) — the by-id
reflective despawn (runs @OnDespawn + frees) via a new world_despawn intrinsic
whose @fn_world_despawn helper is emitted in emit_program's tail once a use is
seen (the g_uses_* prelude pattern), used by the kill policy and callable from
any system.
Examples input_movement + world_bounds. Full suite 112/0, goldens
byte-identical, fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Since #83 the loop commits the device layer once per frame (input_drive), but a
game that ALSO called Input.poll by hand committed a second time in the same
frame; input_device_commit copies in_held into in_prev at the top of every
commit, so the second commit left in_prev == in_held and the edges (held &&
!prev) could never see a transition.
Fix: an in_have_frame_driver flag. The loop's input_drive sets it; a manual
Input.poll under the loop then becomes a no-op returning the frame's key
instead of re-committing. An entry-driven harness has no loop, so the flag
stays false and each Input.poll commits a frame as before (the #7/#50
record/replay + device tests are unchanged). Frame loop now calls input_drive.
Example input_edge (press edge on the down frame, release edge on the up
frame). Full suite 110/0, goldens byte-identical, fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>