Commit graph

169 commits

Author SHA1 Message Date
70d05bd504 feat(lang): friend module lab of fishing, data - a friend of the modules it names
A scoped friend sees those modules' private names and only the exports
of every other; a plain friend module still sees everything. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:12:47 +03:00
f4533331e7 feat(lang): module NAME uses A, B - a module reaches only the modules it names
A reference from a module that declares uses into a module it does not
name is refused, exported or not, naming the use and the fix. A module
with no uses clause keeps the old rule; a package's module is always
usable; a friend is not held to it; a cycle in the declared graph is
refused; LUDIC_VIS_REPORT=1 lists the violations as uses: lines. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:06:45 +03:00
3d2a103dfb fix(lang): a function named like a compiler built-in is refused; reseed
A program's own `run` was never called: every call to it lowered to the
built-in System.run (C's system()), and clang failed on the IR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:09:29 +03:00
9ad6347044 feat(lang): a component's event may be called set, a string prop reads a number, one name per component
- An action whose first word is `set` or `emit` followed by `(` is a call, so a component's
  `on set(v: int)` is pressed as `set(4)`; `set x = ...` is still the action.
- A `string` prop (or state, or parameter) given a number in a template reads it as text through
  ludic.ui's new `ui_val_text`; `label="{3}"` used to arrive as "".
- Two components of one name (or of names that differ only in case, which share their functions'
  names) are an error at the second declaration that names the first's file and line, instead of a
  "function cmp_x_def_y is defined twice".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:53:53 +03:00
0a7525b125 feat(ui): popovers, tooltips, progress; the render3d backend installs itself; duplicate names caught first
- `popover`: a top layer that keeps the pointer and the keyboard, closed by a press outside or Esc.
- `title` tooltips after half a second's rest, styled by .ui-tooltip.
- `<progress>` and `<meter>`.
- Atlases take rows and number cells, and importing ludic.ui/render3d.ludic is enough to draw with
  render3d.
- The compiler reports two declarations of one name before it type-checks, so a package global
  clashing with a program's reads as that, not as 29 type errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:41:02 +03:00
20d011f8e9 feat(lang): UI components as files - component Name { ... } beside Name.xml and Name.lss
The compiler turns a component declaration into:
- a record of its props and state;
- a constructor, a props setter, a model and a call;
- a class, registered with ludic.ui at start.

Its template and styles are read from beside it and compiled in, with @import inlined, and a
missing template fails the build.

In the runtime:
- each mounted instance keeps its own props and state, and `set` in a template writes the state;
- styles are scoped to the component;
- class, style and id on a component's tag land on its root, styled by the parent's sheet too;
- ui_reload re-reads a component's files from disk and keeps instances.

The package's own module is now ludic_ui, so a program may call a directory of its own ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:50:12 +03:00
637d07400e feat(ui): ludic.ui grows toward HTML, CSS and React (Phase 0.U1-U4)
- Natives: ui_native(tag, measure, draw) makes an element the program draws itself; ui_fire
  runs its on-<event> with event.value. input, select and textarea have simple defaults.
- React:
  - keyed <each>, <let>, <provide> context through components, <fragment>;
  - named slots, default props on <component>;
  - on-mount / on-unmount;
  - inline text inside text elements.
- CSS:
  - custom properties and var();
  - position relative/absolute/fixed with insets and z-index;
  - em/rem/vw/vh and @media;
  - wrapping text and ellipsis, overflow;
  - + and ~ combinators, :nth-child(an+b), :checked, :active.
- Developing: errors with file:line, ui_errors(), ui_reload() keeping state, and an
  inspector-style ui_dump.
- view fields infer the type of a literal or a named function's result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:17:25 +03:00
66a2bc2214 feat(lang): L11 views and templates - the UI is markup, not code
A `view Name { field = x; function q(..); on e(..) }` declaration is the one bridge between a
program and its UI: it writes view_<name>() -> UiView, whose model is a Value of every field and
whose call runs a query or an event by name.

ludic.ui is a template runtime:
- HTML-shaped XML screens and components, loaded at run time;
- {expression} bindings, if/else/each, props, slots, per-instance state;
- on-press / onclick actions (event, set, emit);
- component libraries (export="true", <import src as>).

Styling:
- stylesheets in <style> or importable .lss files (@import);
- CSS selectors (#id, .class, [attr=v], descendant and > combinators, :hover, :disabled,
  :first-child, :last-child, :nth-child, :not) weighed by specificity;
- the box model and flex under CSS's property names.

Also:
- default parameters, and positional-then-named calls;
- Value gains a float kind;
- a function shadowing a runtime one is refused;
- an index is evaluated before the slice is read;
- runtime errors name the right file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:58:47 +03:00
e543525eb7 feat(lang): L9 resource files - registry ... from
registry NAME of RECORD [as PREFIX] from "file.lres" fills a registry from a data
file of key { field: value } entries, read at compile time with the record as its
schema: every entry is checked like a def, errors name the resource file's line,
nested records and lists of them are bare { } / [{ }] typed by their fields, and
values are expressions in the registry's module. The entries are compiled in, so
nothing parses at start-up and a build that succeeds has validated its resources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:00:20 +03:00
7e7f3ab297 feat(lang): L8 registries by declaration - registry and def
registry NAME of RECORD [as PREFIX] is a global table; def NAME key { ... } in
any file is one entry, collected in source order and filled before any code
runs. Each entry gets an index constant (PREFIX_KEY), the table PREFIX_COUNT,
and a record with a key field gets it filled and a NAME_find(key). A record
literal naming a field its record lacks is now an error everywhere; a global's
initializer is lowered as its own file's code (its errors, and what its module
may see, were whichever statement came last).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:34:51 +03:00
b0b0b62bce feat(lang): L7 memory is safe unless it says unsafe
The typed buffers are slices: words/floats/fixeds/doubles/pointers(n) make
zeroed, bounds-checked []int/[]float/... and the type names mean them. buffer(n)
is a []byte, with text_of, Fs.read_bytes/write_bytes and view(xs, start, n).
bytes(), indexing a raw pointer or bytes, free, resize, Memory.*, raw file calls,
data_of and C externs are refused outside unsafe { } / unsafe function, and a
project's own files may write unsafe only with --unsafe; the runtime and packages
are the platform. A slice passed to an extern goes as its data.

What the change found: Sync's atomics on a slice header, words(n) uninitialised,
input's fixed axes in ints, truetype's fixed outlines as ints, skin matrices
typed int, gl_shader's source table made from raw bytes. render3d gets safe
entry points (safe_api.ludic). Rendering is byte-identical; a frame costs the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:53:27 +03:00
9259808f80 feat(lang): L6 namespaces declared in Ludic - alias
`alias meth(labels) = target` in a namespace block makes Ns.meth a call to
target with those labels (the target's own parameter names without a list). The
engine's 41 table-driven namespaces - 438 methods: Http, Udp, Process, Json,
Value, Screen, Input, Audio, World, Tiled, ... - leave emit_ns_call for
runtime/native/namespaces.ludic, spliced into every program; 532 lines of
compiler go and the seed shrinks by 23k lines of IR. The game's IR is byte
identical. The checker checks an alias call's arguments against its target.
Still built in: the inline namespaces (Math, Text, List, Vector, Color, Time,
Date, ...) and the methods that pick a target by argument type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 02:40:57 +03:00
6a24b14f0e feat(lang): L5 generic records and functions
property Pool<T> { ... }, function first<T>(xs: []T) -> T, map<T, U> over fn
types; a type writes an instance as Pool<Thing>, nested as deep as needed. The
parser names an instance Pool$Thing and remembers its generic and arguments; the
checker takes the generic declarations out, infers a call's type arguments from
its arguments or its result's declared slot, and makes each instance once as an
ordinary record or function, checked like any other. Errors print Pool<Thing>.
An instance keeps its generic's module and export (L3). ludic-fmt keeps type
arguments together while spacing comparisons and shifts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 02:21:15 +03:00
57b66bdf47 feat(lang): L4 type checker between parse and emit
selfhost/check/ walks every function, the entry, tests, globals' initializers and
@On listeners with real scopes, and refuses mixed number kinds, text and numbers,
two record types, mismatched slices and fn types, wrong argument counts, wrong
returns and wrong push elements - every mix-up at once, each at its line.
LUDIC_CHECK_REPORT=1 lists them by category. pointer stays untyped (L7's).

What it found is fixed: render3d's HDR scan calling the float-bits extern f_lt
with floats; ludic.shooter's right-stick aim overflowing past half a push;
prof.ludic storing longs in []int; extern arguments now coerced to their
parameters. Text-returning runtime functions say string; Assets.ready says bool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:34:49 +03:00
0677aee93f feat(lang): L3 module scope - module, export, friend module
A barrel's 'module NAME' makes its directory a module; a declaration other modules
use says 'export'. Private use from another module is an error naming the module
and where to mark it; 'friend module' sees everything (a test harness); a file in
no module is public and a package keeps its own module. LUDIC_VIS_REPORT=1 lists
every violation instead of stopping, so a codebase can be given its exports first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:52:12 +03:00
0c73287e35 feat(lang): functions are values (L2), and render3d takes its scene as callbacks
fn(int, float) -> bool is a type, fn name is any top-level function's value, and a call through
a local, a global, a record field, a slice element, a parameter or a result of a function type
is an indirect call; two function types mix only when equal, a call checks its argument count,
and a value may be null (examples/functions/values.ludic). Job.parallel_for keeps its worker
check.

render3d's scene is registered rather than required by name: r3d_on_draw, r3d_on_casters and
r3d_on_stream_fill (hooks.ludic). The two rendering examples register theirs - and had defined
scene_draw_casters with no parameter while the renderer passed one, which nothing checked.
render3d declares numbers float itself; smooth.ludic is converted to floats and returns when
r3d_init fails instead of running on into a segfault. Noise.* check their argument count (a call
one short crashed the compiler). selfhost-build says why it failed. The migration tool reads a
declared float as evidence. Seed regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:27:26 +03:00
dc75a5a5ab feat(lang): a name is defined once, a local once per block, and a result is always returned
L1. Two vars, consts, enums, properties or events of one name in a program are an error naming
both places (functions already were); the first used to win silently. A let / var of a name its
own block already declared is an error (it used to shadow). A function with a result type whose
body can reach its end without a return is an error, asked structurally of the body - a return,
an if/else or a match with a default arm whose every branch ends - rather than handing back
whatever the result slot held. The game, the lab and every package example pass all three;
the lab had one harmless shadow, and the game's split screens had two real bugs of the kind.
examples/rejected/ holds the four refusals, checked by the test runner's new reject_case.
Seed regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:55:20 +03:00
c57eafcacc feat(text): Text.to_float; a void function's return <value> is an error
Text.to_float reads a leading decimal number (strtod), the twin of Text.to_int, for data files.
A return with a value in a function that returns nothing now says so where it is, instead of
reaching clang as 'store void'. Seed regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:19:28 +03:00
f5ab5cf88a fix(events): a listener's return ends that listener, not the dispatch
Listeners are compiled into one @ev_<E> function and return branched to its exit, so every
listener declared after one that returned early - and every foreign listener - never heard
the event. The per-kind listener shape (return unless it is my kind) answered only the
first-declared kind. examples/events/answer.ludic holds it; seed regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 17:42:18 +03:00
cc89fc37a4 feat(lang): strict numbers in float files; render3d on float
A numbers float file adapts decimal literals to a fixed operand or slot, and refuses to
promote a computed int to a float implicitly: there it is almost always float bits. Explicit
float(x) is always allowed.

render3d's numbers are float, converted by tools/migrate/floatbits.py - a whole-program
inference of which ints carried IEEE bits (union-find over flows, calls, returns, buffers,
nested buffers and lexical scopes) and a rewriter to operators, Math.* and float literals,
with float_bits / float_from_bits left only where bits really cross (runtime scratch
buffers, mixed buffers). Seed regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 17:13:25 +03:00
3ac0d8d5cb feat(lang): numbers float - a module whose decimal literals are float
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:02:11 +03:00
e6f565a6c4 carry: the runtime and render3d work Maroon Lake builds against
Input.text, App.monitor_count / window_to_monitor / window_fixed,
gl_sleep_us, and the grass and collide changes, uncommitted on main and
depended on by the game; carried here so the language work starts from
what the game actually uses. main's working tree is untouched.
2026-09-23 15:55:02 +03:00
86492064aa fix(selfhost): the Windows seed too
`reseed` writes both, and the committed Windows seed carried none of the bounds
check (0 sites against 859 in the regenerated one), so a bootstrap there would
have built a compiler without it - the same half-a-change as the host seed, on
the other platform.

Not verified by running: a Windows bootstrap cannot be done from this Mac. What
is checked is that it is generated by the same `reseed` from the same source as
the host seed, and that it carries the emission the host seed does. The host's
own bootstrap is a proven fixpoint and selfhost-test's C-free bootstrap passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:31:22 +03:00
ba756cccc1 fix(selfhost): reseed, so the bounds check survives a bootstrap
0998cb5 committed the backend source and not the IR seed it is built from, so
`ludic-dev build` on a clean checkout rebuilt the compiler from a seed that
predates the change and produced one with no bounds checking at all. The check
only existed in whichever binary happened to be sitting in bin/.

That is the same half-a-change this project has just been bitten by twice - a
caller committed without the definition it needs, a table's declaration moved
without its parameters. A compiler change is the source AND the seed.

    seedcheck.ludic:5: index out of range: 5, len 1

from a compiler bootstrapped out of the checked-in seed, which is the thing
that was not true before.

bootstrap: FIXPOINT (gen2.ll == gen3.ll). selfhost-test: 33 passed, 0 failed,
including the C-free bootstrap from the seed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:30:41 +03:00
0998cb5a18 feat(compiler): a slice index is checked against its length
A slice has carried { ptr, len, cap } since it existed and nothing ever read
the len: every index emitted a bare getelementptr. Running off the end of one
wrote into whatever the allocator had put next, and the program died somewhere
else entirely - a maroon-lake crash took a day to find because the stack named
a texture upload and the write was in a telemetry buffer five frames earlier.

Now each index loads the length and compares unsigned, which rejects a negative
index in the same instruction, and a failure aborts with the location the other
located errors use:

    oob.ludic:9: index out of range: 7, len 3

`words` and the other raw buffers are unchanged - they are a bare malloc with no
length to check, which is the argument for moving off them.

The SPIR-V variants are regenerated in the same commit: shaders --check was
failing against the edited GLSL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:54:12 +03:00
4979cc0c94 feat: every key on the keyboard is bindable - the F-row, the six-pack, Caps Lock and the numpad
The platform gave these keys no code at all, so a game's rebinding screen could not take
one and nothing said why. Windows asked the active layout what they type and got nothing
(w_vk_char answers 0 for a key with no character); macOS let them fall through to
charactersIgnoringModifiers, which reports NSF1FunctionKey and its neighbours at 0xF704
and up - outside the 256-bit held set either way.

w_keyval and ev_keyval now name them, with the same codes on both: 132-143 F1-F12,
144-149 Home / End / PageUp / PageDown / Insert / Delete, 150 Caps Lock, 152-161 the
numpad digits, 162-166 its * + - . and /. The numpad's Enter is Enter.

Key.F1, Key.Home, Key.Numpad0 and the rest fold at compile time, and Input.key_label
names them without asking the layout - a key that types nothing is called the same thing
on every layout.

examples/library/input_typeless_keys.ludic covers the codes, the names, the held set and
the press edge; 150 passed in ludic-dev test, 33 in selfhost-test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:57:02 +03:00
7f9f8de08a fix: CSPRNG on Windows, and a window's first title is the package's app name
Crypto.random_* and Uuid.* read /dev/urandom, which Windows lacks, so every
byte was zero; the Windows target now calls RtlGenRandom (advapi32).
ludicc takes --title, which ludic build/run/bundle pass from package.ludic's
app name, so rt_init opens the window under that name instead of the
program name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:22:05 +03:00
43d5eb5b88 feat(launcher): Process.*, Http.save_to/received/expected, App.window_hide/show
Process.spawn/poll/kill/free - non-blocking child processes with no shell: posix_spawn on
macOS (process.ll), CreateProcessW with MSVC-quoted arguments and no console window on
Windows (process_win.ll), linked only when a program uses Process.*.

Http.save_to streams a response body into a file (NSURLSession with a run-time delegate
class on macOS, the WinHTTP read loop on Windows); Http.received / Http.expected report
progress while it is pending. Freeing a pending request cancels it and parks the slot
until the worker has finished.

App.window_hide / App.window_show take the game's window off the screen and back without
closing it; the run goes on while hidden. Docs, examples, tests and a changeset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:32:23 +03:00
ebb1a00352 feat(udp): Udp.* - polled IPv4 datagrams on macOS and Windows
Udp.open/port/send/recv/from_ip/from_port/close/resolve/local_ip/ip/ip_text, native
BSD sockets (udp.ll) and Winsock (udp_win.ll, -lws2_32), linked only when a program
uses Udp.*; example, docs and tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:16:14 +03:00
1900f9ca80 fix(compiler): == / != on references is identity; only text compares by content
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>
2026-09-16 16:13:31 +03:00
e010c2cecc feat(lang): float and double types with ordinary operators
`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>
2026-09-16 16:13:31 +03:00
f92d7f89c6 feat(tooling): JetBrains IDE support, LSP navigation, barrel imports, package scripts and hooks
- 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>
2026-09-16 16:13:31 +03:00
619476ffed fix(input): keys are physical positions on every layout; Input.key_label names them
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>
2026-09-15 23:26:53 +03:00
04cda22d19 fix(parse): fn stays an ordinary name unless a function name follows on the same line
`fn name` read `while p < fn and ...` as a reference to a function called `and`, which broke
tools/ludic-cli/glgen.ludic and so the dev tool's own build. A reference now needs the name on
the same line and not one of and / or / not / in / is. The threads example checks `fn` as a
local before `and`. Reseeded; bootstrap-cfree reproduces the seed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:12:29 +03:00
c10abd9f9f feat(jobs): real OS threads - Job.parallel_for, fn name, thread-safe Sync
- `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>
2026-09-15 13:52:22 +03:00
208cad7ca1 feat(vk): Vk.* - Vulkan 1.0-1.4 generated from the registry, loaded at run time
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>
2026-09-15 09:52:12 +03:00
fb4d904ab6 Merge branch 'fix/arrows-http-backspace': held arrow keys, ludic build Http.* link, macOS Backspace
# Conflicts:
#	tools/ludic-cli/build.ludic
2026-09-13 03:28:29 +03:00
40d78cbd92 feat(windows): Http.* over WinHTTP, and the splash and window icon through WIC
http_win.ll implements http.ll's hs_* contract over WinHTTP: the request is a
record built on the game thread, the whole exchange runs on a worker thread,
TLS uses the system's certificate checks, and headers are answered from the
kept request handle. ludicc links it with winhttp instead of refusing Http.* on
Windows.

win32.ll decodes the splash and App.set_icon images with WIC (ole32): the
splash is a topmost borderless window at the artwork's size in points times the
display scale, composited over its background colour; the icon becomes an
HICON set on the window now or when win_open makes one.

Verified on the PC: examples/library/http.ludic prints its expected output, a
real GET to https://git.workshopsoft.io/ returns 200 with its body and
Content-Type, and the bundled Maroon Lake shows its splash centred at launch and
its icon in the title bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 03:24:50 +03:00
55b0e2ebf6 feat(windows): the ludic CLI and ludic bundle on Windows
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>
2026-09-13 03:01:33 +03:00
6117f73602 fix(input): Key.Up/Down/Left/Right are the held set's 128-131
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>
2026-09-13 03:00:29 +03:00
3300fb3957 feat(windows): sound, through XAudio2
audio_win.ll implements audio.ll's snd_* contract over XAudio2 from IR: a
source voice per clip, restart on play, LoopCount for loops, SetVolume,
SetFrequencyRatio and a balance pan through SetOutputMatrix, with the COM
slots taken from the SDK's xaudio2.h. Clips are RIFF WAVE read through
lp_pak_open (weakly referenced), so a packed sound loads directly. ludicc links
it with xaudio2 and ole32, and silences xinput.lib's importeddllmain warning.

Verified: ludic-dev test 135/135, selfhost-test 32/32; on the PC a harness
loads, plays, pans, loops and stops clips, and Maroon Lake windowed reports
"sound: 31 of 31 clips loaded".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 02:42:48 +03:00
00d25dcc3e feat(windows): a window - win32.ll, WGL on it, and a windowed Windows build
win32.ll implements cocoa.ll's contracts over user32 and xinput: the message
pump, the held-key set and frame key in Ludic codes, the mouse with raw input
for cursor mode 2, clip-based cursor modes released on focus loss, XInput pads,
and the software framebuffer present. win32_gl.ll puts the WGL 4.1 core context
on that window (vsync, borderless full screen); gl_win.ll's context code is now
lgl_wgl_core, shared with the headless hidden window, whose win_gl_* stubs move
to gl_win_nowin.ll. audio_win.ll links Audio.* silently for now.

Verified: macOS fixpoint, ludic-dev test 135/135, selfhost-test 32/32; on the
PC gl_triangle renders identically headless and in a real window, and Maroon
Lake builds windowed and runs a playtest to frame 240 in the desktop session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 02:33:53 +03:00
1bc255bc40 fix(windows): Fs.remove removes an empty directory, as POSIX remove does
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>
2026-09-13 02:27:21 +03:00
cb05721a89 feat(windows): Gl.* on Windows, through WGL and a driver-filled thunk table
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>
2026-09-13 02:10:36 +03:00
5801005fca feat(windows): a Windows target for ludicc, and the compiler builds itself there
--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>
2026-09-13 01:59:41 +03:00
38b6b81cd7 feat(app): App.set_icon, so a game without a bundle still has an icon
`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.
2026-09-12 12:55:12 +03:00
6bcb5f4ae1 fix(os): save_dir/config_dir/cache_dir follow the platform, not just macOS
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.
2026-09-11 19:46:47 +03:00
3c27606747 feat: per-voice audio, outlines for what is not an actor, and a coast
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>
2026-09-11 15:25:46 +03:00
be74b4de6f feat(bundle): ship a game as a macOS .app, with a splash it controls
`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>
2026-09-10 16:57:27 +03:00
ce5bf0fe0e feat(pack): asset packs, so a built game is a thing you can hand someone
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>
2026-09-10 16:37:23 +03:00