Networking N2–N6, and a fully C-free toolchain

Implement the rest of NETWORKING-DESIGN.md (N2–N6) and eliminate every
`.c` file from the repo. clang remains only the LLVM-IR assembler; no C
is compiled anywhere.

Networking (selfhost/emit_net.ludic + parser/emit changes):
- N2 @Sync: per-model serialize/apply + by-kind dispatchers; POD-scalar
  compile error and empty-participation warning; selective replication.
- N3 @Owned: @L_owner array + owner/set_owner/is_owner; owners snapshot.
- N4 @ToServer/@ToClients remote events: framed net_send + net_pump re-emit.
- N5 @Server/@Predicted role guards + drivable sim (tick_fixed/tick_render,
  entry-owns-the-loop).
- Built-in loopback transport so multiplayer runs with zero foreign code;
  extern fn net_send/net_poll still overrides it for a real socket.
- N6 blessed runtime (examples/net_rt.ludic) + end-to-end demo (net_demo).
- Fix: llty("entity") is now i32 (entities are i32 handles), so let e = self().

C elimination:
- Networking + foreign-mod-ABI tests rewritten as self-contained pure-Ludic
  programs (examples/net_*, world_*, mod_events, scoped); tests/ removed.
- Reflection ABI exposed to Ludic as world_* builtins (Ludic-to-Ludic modding).
- Formatter rewritten C→Ludic: tools/ludic-tools/fmt.ludic.
- Language server rewritten C→Ludic: tools/ludic-tools/lsp.ludic (lexer, index
  parser, cross-file workspace resolver, JSON, all LSP handlers).
- Obsolete migrate_*.c codemods deleted; ludic_syntax.h kept as vocabulary data.

Suites: ./test.sh 44/44, ./tools/test-tools.sh 28/28 (LSP 42/42), fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-29 15:08:23 +03:00
parent 96d01e45ab
commit bca8f126fc
67 changed files with 24066 additions and 9309 deletions

670
EVENTS-DESIGN.md Normal file
View file

@ -0,0 +1,670 @@
# Events & modding, expanded — a design doc
> **Status: EV0 fully shipped; EV1 (spawn/despawn), EV2 (first cut) and EV3
> shipped; EV4–EV7 are design.** Implemented, self-hosted to the C-free fixpoint,
> and each a `test.sh` check:
> - **EV0** — `event`/`@On`/`emit` lowered to `@ev_<E>` dispatch (compile-time
> listeners), **plus the foreign C ABI** (`ludic_on_<E>`, the `%Ev_<E>` payload
> struct, a fixed-capacity listener array), proven by a C mod in
> [`tests/mod_c/mod.c`](tests/mod_c/mod.c) binding
> [`examples/mod_host.ludic`](examples/mod_host.ludic). Byte-identical when no
> event is declared. ([`examples/events.ludic`](examples/events.ludic))
> - **EV1** — public events across the **whole architecture**, every scope shipped:
> **program** (`@Public @OnStart`/`@OnQuit` → `program_start`/`program_quit`,
> [`examples/program_events.ludic`](examples/program_events.ludic)); **models**
> (`@Public @OnSpawn`/`@OnDespawn` → `model_<M>_spawn`/`_despawn`,
> [`examples/promote.ludic`](examples/promote.ludic)); **properties** (`@Public
> @OnAttach`/`@OnDetach`/`@OnEnable`/`@OnDisable` → `prop_<P>_attach` etc.,
> [`examples/prop_events.ludic`](examples/prop_events.ludic)); **scenes** (a
> `public` scene → `scene_<S>_enter`/`_exit`,
> [`examples/scene_events.ludic`](examples/scene_events.ludic)); and **layers** (a
> `public` layer + `enable/disable layer L` → `layer_<L>_show`/`_hide`,
> [`examples/layer_events.ludic`](examples/layer_events.ludic)) — which also
> landed **SCENES E2 layer toggle** (`@LE_<L>` flag gating a layer's handlers).
> - **EV2 / EV2b** — the world table: the reflection ABI, generated from the
> compile-time schema, so a mod reads, writes, scans, identifies, **and creates**
> entity state **by name** without compiling against the game. `ludic_prop_id` /
> `ludic_field_id` / `ludic_get` / `ludic_set` / `ludic_has` (read/write —
> [`world_mod.c`](tests/mod_c/world_mod.c)); `ludic_entity_count` / `ludic_kind` /
> `ludic_model_id` (scan and identify — [`world_scan.c`](tests/mod_c/world_scan.c));
> `ludic_spawn(model_id)` (create, reusing the compiler's own spawn lowering —
> [`world_spawn.c`](tests/mod_c/world_spawn.c)); `get`/`set` address each field by
> its real struct offset, correct for `int`/`fixed`/`byte`/`ptr` and mixed layouts
> ([`world_mixed.c`](tests/mod_c/world_mixed.c)); and iterate
> (`ludic_query_next`, [`world_query.c`](tests/mod_c/world_query.c)). Emitted only
> for an ECS program that declares events, so event-free games stay byte-exact.
> The world table is complete: read, write, scan, identify, create, iterate.
> - **EV3** — `cancellable` events, the `cancel` verb, and `emit E(…)` as an
> expression returning the veto flag. ([`examples/cancel.ludic`](examples/cancel.ludic))
> - **EV5** — leak-proof scoped listeners: `ludic_off_<E>(token)` (explicit
> unregister; dispatch skips tombstoned slots), `ludic_on_entity_<E>(entity, cb)`
> (entity-scoped), and a generated `ludic_sweep_entity` called from `despawn` that
> nulls every listener the dying entity owned — a listener can't leak past its
> entity. Proven by [`tests/mod_c/scoped_mod.c`](tests/mod_c/scoped_mod.c).
> - **EV6** — re-entrant `emit` is depth-bounded (`@ev_depth` vs `EV_DEPTH_CAP`): a
> listener may emit another event, but an event cycle traps as an early return
> instead of hanging the frame. Dispatch order was already deterministic (array,
> registration order). Proven by [`examples/recurse.ludic`](examples/recurse.ludic).
>
> - **EV7 (schema opening)** — a mod defines a brand-new component at runtime:
> `ludic_register_prop(name, nfields)` mallocs flat `[MAX_ENT × nfields × i32]`
> storage + a has-flag array and returns a prop id past the compile-time range;
> `ludic_attach_dyn`/`ludic_detach_dyn` toggle it on an entity; `get`/`set`/`has`/
> `prop_id` fall through to the dynamic registry for ids ≥ the compile-time count.
> A mod adds entirely new data to entities by name, with per-entity isolation.
> Proven by [`tests/mod_c/world_dyn.c`](tests/mod_c/world_dyn.c). (EV7's other
> half — networking's local/remote event split — has no substrate in Ludic yet.)
>
> Still design: EV4 (the scripting-shim bridge — deferred to keep the suite
> interpreter-free) and EV7 networking. This is a companion to
> [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md) and [SCENES-DESIGN.md](SCENES-DESIGN.md).
> Where those docs extend Ludic's *internal, compile-time* lifecycle, this one
> proposes the *external, runtime* layer that turns those same lifecycle moments
> into a public event surface — the foundation a game can hand to mods written in
> Ludic, JS/TS, Lua, or anything with a C ABI. It distills a survey of modding and
> event systems (§3) into a phased roadmap (EV0–EV7, §12–§13). §14 lists the open
> decisions.
---
## 1. Thesis
Ludic already has a lifecycle. `@OnSpawn(Enemy)`, `@OnDetach(Sprite)`, scene
`on enter`, `@OnDespawn(M, reason: r)` — every one is a **compile-time,
closed-world, zero-cost** hook that desugars to a direct call at a fixed site.
That is the right design for the *game author*, who is compiled together with the
game. It is exactly the wrong design for a *mod author*, who is not.
A modding event system is the mirror image of the lifecycle layer along three axes:
| | Lifecycle hooks (today) | Modding events (this doc) |
|---|---|---|
| World | **closed** — all handlers known at compile time | **open** — mods add listeners after compilation |
| Binding | **static** — a checked symbol, a direct call | **dynamic** — registered at load, dispatched at runtime |
| Language | **in-language** — Ludic, compiled together | **cross-language** — JS/TS/Lua/native over an ABI |
The instinct would be to build a second, parallel system. **The design that keeps
Ludic's discipline builds one system seen from two sides.** A lifecycle hook is a
*private* view of a moment; a public event is the *same moment* exposed across the
ABI. The author promotes a hook to an event; the compiler keeps its zero-cost
direct calls **and** emits one guarded `bus_emit` at the very same site. Nothing
exposed → nothing emitted → goldens stay byte-identical, exactly like `has_ecs`
and the `g_ondespawn` shutdown walk.
**The Luanti dividend.** The gap analysis (`LUANTI-ROADMAP.md`) found that ~57k of
Luanti's lines exist only to bridge C++ and Lua, and that its mod predicates are
*runtime strings* it must re-interpret every call. Ludic pays neither tax. The
reflection surface a mod needs — "what properties exist, what fields, at what
offsets" — is a **compile-time fact**; the compiler can *generate* the bridge
instead of a human hand-writing 57k lines, and it is always in sync with the game
it describes. A mod itself written in Ludic and compiled to a shared library binds
that surface with **zero marshalling**; a Lua mod binds the same surface through
its FFI. One ABI, every language.
---
## 2. What Ludic has today, and why it can't reach a mod
The lifecycle table from [LIFECYCLE-DESIGN.md §2](LIFECYCLE-DESIGN.md), every cell
filled, every cell a zero-cost desugar:
| Scope | Setup hook | Teardown hook | Fire site the compiler already owns |
|---|---|---|---|
| program | `@OnStart` | `@OnQuit` | boot / shutdown |
| entity | `@OnSpawn(M)` | `@OnDespawn(M, reason)` | `spawn` / `despawn` / shutdown-walk |
| property (structural) | `@OnAttach(P)` | `@OnDetach(P)` | `attach` / `detach` |
| property (toggle) | `@OnEnable(P)` | `@OnDisable(P)` | `enable` / `disable` |
| scene | `on enter` | `on exit` | `become` (and `push`/`pop`, SCENES E3) |
Two more fire sites are proposed but unbuilt, and both are natural events:
`@OnChange(P)` (LC2 — a value-change hook the compiler can emit right after every
write site) and `@OnStartMatch`/`@OnStopMatch` (LC3 — query-membership edges).
Every one of these is a place the compiler **already writes a call**. The problem
is purely that the call is *closed*: its targets are fixed at compile time, so a
mod loaded at runtime has no way to be one of them. The entire job of this doc is
to add, at each of these sites, an **opt-in second exit** to an open runtime list —
without touching the closed path's cost when no one opts in.
What a mod additionally needs, that no hook provides:
- a **stable name** for each event that survives recompilation (a mod compiled
against v1 must still bind in v1.1);
- a way to **read and write game state** it did not compile against (the world
table, §9);
- a way to **veto or rewrite** an action before it commits, not just observe it
after (cancellable events, §8);
- a **loader** — mods enable, disable, and unload, and their listeners must vanish
cleanly when they do (§10).
---
## 3. Research digest — the one idea to steal from each
The lifecycle doc surveyed engines for *internal* lifecycle. This surveys systems
for their *modding and event* surface — how untrusted, separately-authored code
plugs into a running game.
| System | The transferable idea |
|---|---|
| **Bukkit / Spigot** (Minecraft) | The canonical **cancellable event**: `Cancellable.setCancelled(true)` vetoes the action; `EventPriority` orders listeners; `@EventHandler(ignoreCancelled=true)` opts out of already-vetoed events. Events are *classes*, checked at bind time — not strings. |
| **Fabric** (Minecraft) | `Event<T>` backed by an **invoker over a plain array** of callbacks — deterministic registration order, no reflection at dispatch, phases for ordering. The closest existing design to what Ludic wants: fast, ordered, array-backed. |
| **Factorio** | **Deterministic** modded events for multiplayer lockstep: `script.on_event(defines.events.X)`, numeric event ids, `raise_event` for custom events, **filtered** subscriptions. Proof that a heavily-modded game can still replay bit-for-bit. |
| **Minetest / Luanti** | `register_on_*` + a string-keyed global (`minetest.*`) world API. The thing to beat: its predicates are runtime strings, and its C++↔Lua bridge is 57k hand-written lines. |
| **Godot** | **Signals as a first-class language construct**: `signal hurt(amount)`, `emit_signal`, `connect`. Decoupled, per-object, declared where the data lives. |
| **DOM events** | The **two-phase dispatch** vocabulary: capture → target → bubble, `preventDefault` (veto the default action) vs `stopPropagation` (halt the chain), and *passive* listeners that promise not to cancel (so dispatch can skip the veto check). |
| **Node `EventEmitter`** | The dead-simple baseline `on`/`emit` — and its footguns: untyped string names (a typo silently never fires) and **listener leaks** (a listener on a dead object keeps it alive). Design both out. |
| **flecs / Bevy observers** | **ECS-native reactive events**: an event *targeted at an entity*, observers that fire on component add/set/remove, deferred so mutation-during-iteration is safe. The correct shape for an ECS. |
| **Blender `bpy.app.handlers`** | Named application-level handler lists a script appends to, with a `persistent` flag controlling survival across file loads — the "engine lifecycle exposed to scripts" model, and the lesson that *survival scope* must be explicit. |
| **Roblox** | `BindableEvent` (local) vs `RemoteEvent` (across the network boundary) — the same event abstraction, one flag deciding whether it crosses a trust/latency boundary. Relevant the day Ludic has networking. |
Five **footguns** the survey warns against, to design *out* of Ludic from the start:
1. **Untyped string events.** Node/DOM let any string be an event; a typo never
fires and never errors. Ludic's core events are compiler-checked symbols; only
genuinely-dynamic *mod-defined* events use interned strings, and those must be
*registered* before use (§6), so an unknown name is a load-time error, not a
silent no-op.
2. **Listener leaks.** A listener bound to an entity that despawns must die with
it. Ludic ties listener lifetime to the scope it names (§10) — entity-scoped
listeners are swept by the same despawn walk that already runs.
3. **Nondeterministic dispatch order.** Hash-map iteration over listeners breaks
replay and save-load. Ludic dispatches in a **defined order** (priority, then
registration order) so a modded game stays deterministic — a hard constraint,
not a nicety, given Ludic's deterministic-by-design rng and byte-identical
goldens.
4. **Re-entrancy / mutate-during-dispatch.** A listener that emits another event,
or despawns the entity mid-dispatch, is the flecs "command during iteration"
hazard. Ludic defers structural changes made inside dispatch to the next sync
point (ties to LIFECYCLE LC5), and bounds re-entrant emit depth.
5. **Cancellation ambiguity.** If two listeners disagree, who wins? Ludic's rule
(§8): **one veto wins and is sticky**; later listeners see the cancelled state
and, unless they opted into `ignoreCancelled`, are skipped.
---
## 4. The two layers, named
To talk about this precisely the doc fixes two words:
- A **hook** is the existing compile-time construct: an `@`-annotation or scene
clause that desugars to a direct call. Closed, zero-cost, author-only. Unchanged.
- An **event** is the new runtime construct: a named, ABI-visible moment that any
registered listener — in any language — may observe or (if cancellable) veto.
An event is *fed by* a hook site. Promoting is additive: the hook keeps firing its
compile-time listeners as direct calls; the event is an extra, guarded emission at
the same site. **Author code never pays for the bus it doesn't expose, and mod
code never sees a hook it wasn't given.**
---
## 5. EV0 — the event bus core
The minimum viable layer: declare an event, emit it, and have both in-language and
foreign listeners receive it — with zero cost when a program declares no events.
**Declaring a custom event.** A first-class declaration, mirroring `property`:
```ludic
# doc-check: skip — sketch
event PlayerHurt { entity: int, amount: int } # a payload is a flat POD record
event WaveCleared { } # payloads may be empty
```
**Emitting.** A statement, mirroring `spawn`/`emit_signal`:
```ludic
# doc-check: skip — sketch
emit PlayerHurt(entity: e, amount: dmg)
```
**Listening in-language** (author code, or a *native* Ludic mod) reuses the
annotation channel, mirroring `@OnSpawn`:
```ludic
# doc-check: skip — sketch
@On(PlayerHurt) handler FlashRed { hud_flash(0xFF0000) }
```
**Listening across the ABI** (a JS/TS/Lua mod) goes through the stable C ABI:
```c
/* the entire foreign-facing event ABI — four functions */
uint32_t ludic_event_id(const char *name); /* intern → stable id */
uint32_t ludic_on(uint32_t event, int32_t prio, ludic_cb cb, void *ctx);
void ludic_off(uint32_t token);
void ludic_emit(uint32_t event, void *payload); /* mod-raised events */
/* cb: void (*)(void *ctx, void *payload) — payload is the flat POD record */
```
**Lowering — the discipline holds.** An exposed event's emit site becomes:
```
; emit PlayerHurt(entity: e, amount: dmg) lowers to:
1. build the payload record on the stack (POD, no heap)
2. call each compile-time @On(PlayerHurt) handler directly ; zero-cost path
3. if g_listeners[EV_PlayerHurt].count != 0: ; one branch
loop the runtime listener list, calling each cb(ctx, &payload)
```
- **A program that declares no `event` emits none of this.** A `has_events` flag
(exactly like `has_ecs`, `g_ondespawn`) gates the whole subsystem; a game with no
public events is byte-for-byte identical to today. This is the non-negotiable
invariant every phase preserves.
- The compile-time `@On` handlers are direct calls appended to the site — a native
listener costs the same as a lifecycle hook. Only *foreign* listeners walk the
runtime list, and an event with zero foreign listeners is a single count check.
- The runtime list is a **compiler-owned, fixed-capacity buffer** per event
(like the scene stack in SCENES E3) — not heap, not a hash map. `ludic_on` is an
index bump; `ludic_off` tombstones a slot. Deterministic order falls out of the
array (§7 of SCENES' "no dispatch tables" spirit, honestly bent — see §11).
---
## 6. EV1 — promoting hooks to events (the taxonomy)
Custom `event`s (EV0) cover author-raised signals. The **lifecycle** events —
spawn, despawn, attach, scene enter — should not require the author to hand-write
an `emit` in every `@OnSpawn`. Instead, a hook is promoted with one annotation:
```ludic
# doc-check: skip — sketch
@Public @OnSpawn(Enemy) handler Init { Health.hp = Health.max }
# now firing this hook ALSO emits the public event model.Enemy.spawn
```
`@Public` on a lifecycle hook tells the compiler to add the guarded `bus_emit` at
that hook's existing site, with a **generated payload** built from what the hook
already binds (the entity id, the model/property fields, the `EndReason`). The
result is a uniform event namespace across the whole architecture — precisely the
"events for properties, models, scenes, layers, game" the request asks for:
| Scope | Public event name | Payload | Fed by |
|---|---|---|---|
| program | `program.start` / `program.quit` | `{}` | `@OnStart` / `@OnQuit` |
| phase | `phase.<Name>.pre` / `.post` | `{ frame }` | the phase scheduler |
| model | `model.<M>.spawn` / `.despawn` | `{ entity, reason? }` | `@OnSpawn` / `@OnDespawn` |
| property (structural) | `prop.<P>.attach` / `.detach` | `{ entity, <fields> }` | `@OnAttach` / `@OnDetach` |
| property (toggle) | `prop.<P>.enable` / `.disable` | `{ entity }` | `@OnEnable` / `@OnDisable` |
| property (value) | `prop.<P>.change` | `{ entity, field, old, new }` | `@OnChange` (LC2) |
| query (membership) | `query.<Q>.enter` / `.exit` | `{ entity }` | `@OnStartMatch`/`@OnStopMatch` (LC3) |
| scene | `scene.<S>.enter` / `.exit` / `.push` / `.pop` | `{}` | `on enter`/`on exit`, `push`/`pop` |
| layer | `layer.<L>.show` / `.hide` | `{}` | layer toggle (SCENES E2) |
- **Names are stable strings, ids are fast integers.** `model.Enemy.spawn` is the
public contract; the compiler assigns it a numeric id and registers the mapping
in a generated init. A mod compiled against the string binds by id at load — so
reordering declarations doesn't break a shipped mod (unlike raw
decl-order numbering, which is fine for the *closed* scene machine but wrong for
an *open* ABI).
- **Opt-in per hook, not global.** Only `@Public` hooks emit. A game exposes the
slice of its lifecycle it wants moddable and pays for nothing else.
- **`@Public` composes with everything.** A `@Public @OnDespawn(Enemy, reason: r)`
emits `model.Enemy.despawn` with the `EndReason` in the payload — mods can tell a
scene-exit death from a real one, the LC1 dividend extended to the mod boundary.
---
## 7. EV2 — the world table (reflection for mods)
The user's "game table": the stable, versioned surface a mod uses to **read and
write game state it never compiled against**. Minetest's `minetest.*`, Factorio's
`game.*`, but *generated* rather than hand-written.
Because Ludic's data is packed POD in `@S_` arrays whose layout the compiler knows
exactly, the compiler can emit a **schema** (property id → field ids → offset +
type) plus a small accessor ABI over it:
```c
/* the world table — reflection + mutation over the live ECS */
uint32_t ludic_prop_id(const char *name); /* "Health" → id */
uint32_t ludic_field_id(uint32_t prop, const char *name); /* ("Health","hp")→id */
int64_t ludic_get(int32_t entity, uint32_t prop, uint32_t field);
void ludic_set(int32_t entity, uint32_t prop, uint32_t field, int64_t v);
bool ludic_has(int32_t entity, uint32_t prop);
int32_t ludic_spawn(uint32_t model); /* → entity */
void ludic_despawn(int32_t entity);
uint32_t ludic_query(uint32_t *props, int n); /* → iterator handle */
int32_t ludic_query_next(uint32_t iter); /* → entity or -1 */
```
- **Generated from the compile-time schema, so it never drifts.** Add a field to
`Health`, recompile, and the schema updates; a mod that asked for
`("Health","hp")` still resolves. This is the entire Luanti bridge, minus the
hand-written 57k lines and minus the runtime-string re-interpretation.
- **`ludic_set` respects the semantic layer.** Writing a field routes through the
same path a native write does, so `@OnChange`/`prop.change` (LC2) fires for a
mod's write exactly as for the author's — mods can't silently corrupt invariants
that hooks are meant to maintain.
- **Mods can register content, within limits.** A mod may `ludic_on` existing
events and `ludic_emit` custom ones; **defining a new `property`/`model` is a
harder call** (it needs storage the closed `@S_` arrays didn't reserve). The
pragmatic first cut: models and properties are closed (author-defined), and mods
extend *behavior* (listeners, custom events, world reads/writes) but not the
*schema*. Opening the schema to mods is EV-late (§13, open decision 4).
---
## 8. EV3 — cancellable and mutable events
Observation alone (Node, Blender) can't stop a mod from turning damage off — the
modding headline is that a listener runs **before** the action and can veto or
rewrite it. Events split into two kinds, distinguished at declaration:
- **notifications** — fired *after* the fact, observe-only, can't change anything.
Cheap, un-ordered-safe, the default. `model.Enemy.spawn` after the spawn.
- **decisions** — fired *before* the action, listeners may **cancel** it or
**mutate** the payload; the caller reads the verdict and branches. Marked
`cancellable` (Bukkit `Cancellable`, DOM `preventDefault`).
```ludic
# doc-check: skip — sketch
event cancellable BeforeHurt { entity: int, amount: int } # a decision event
# an author (or native mod) listener that halves fire damage and vetoes lethal hits:
@On(BeforeHurt, prio: 100) handler Armor {
BeforeHurt.amount = BeforeHurt.amount / 2 # mutate the payload…
if BeforeHurt.amount >= Health.hp { cancel } # …or veto the whole action
}
# the fire site consults the verdict:
let dmg = emit? BeforeHurt(entity: e, amount: raw) # emit? returns the (maybe-mutated) payload
if !cancelled(dmg) { Health.hp -= dmg.amount }
```
Rules, chosen from the survey to remove the ambiguity footgun:
- **Priority, then registration order.** `prio:` (default 0) orders listeners
high-to-low; ties break by registration order. Deterministic, replay-safe.
- **One veto wins and is sticky.** Once a listener calls `cancel`, the event is
cancelled for the rest of the chain; later listeners still run (so they can react
to the cancellation) unless declared `ignoreCancelled`, which skips them.
- **`stopPropagation` is separate from `cancel`.** DOM's distinction: `cancel`
vetoes the *action*, `halt` stops the *chain*. Keep both; they answer different
questions.
- **Passive listeners.** A listener declared `@On(E, passive)` promises not to
cancel or mutate — the dispatcher can call it after the decision is settled, and
a foreign listener that lies is a load-time capability error (§10), not a
mid-frame surprise.
- **Mutation is bounded to the payload.** A decision listener rewrites *the payload
record*, never arbitrary world state, so the caller's branch is the only place
the change takes effect — no spooky action at a distance.
---
## 9. EV4 — the mod ABI & the language-agnostic bridge
"Agnostic JS/TS/Lua or their own" resolves cleanly once EV0–EV3 exist, because the
contract is **the C ABI, not any one language.** Two mod tiers bind the *same* four
event functions (§5) and the same world table (§7):
**Tier 1 — native mods (Ludic → shared library).** A mod is a `.ludic` file
compiled to a `.dylib`/`.so`/`.wasm` with `extern fn` bindings
([LANGUAGE.md §Functions & FFI](LANGUAGE.md)). It binds the ABI with **zero
marshalling** — payloads are the same POD records the host builds — and its `@On`
handlers can even be *inlined by the same compiler* if the mod is compiled with the
game. This is the tier Luanti can't offer and the one that makes Ludic's modding
fast: a compiled predicate where Luanti has a re-interpreted string.
**Tier 2 — scripted mods (JS/TS/Lua/…).** The game embeds a scripting runtime
(QuickJS, Lua, Wasm) and registers a thin per-language shim that:
1. calls `ludic_event_id("model.Enemy.spawn")` once at load to resolve the id;
2. calls `ludic_on(id, prio, trampoline, script_fn)` where `trampoline` is a
single C function that marshals the POD payload into the script runtime's values
and invokes `script_fn`;
3. exposes the world table (§7) as idiomatic bindings (`world.get(e, "Health",
"hp")` in Lua, `world.get(e, "Health", "hp")` in TS).
The host writes **one trampoline per language**, not one per event — the schema
(§7) drives the marshalling generically. A Lua mod and a TS mod differ only in
their shim; the game core is identical. This is the structural win the Luanti gap
analysis pointed at: the bridge cost is *O(languages)*, not *O(events × languages)*
hand-written, because the schema is generated.
```
┌─────────────── the stable C ABI ───────────────┐
Ludic game core ──────┤ ludic_on / ludic_emit / ludic_get / ludic_set ├────── generated schema
(emits at hook sites) └────────────────────┬───────────────────────────┘ (prop→field→offset)
│
┌────────────────────────────────┼────────────────────────────────┐
│ │ │
Tier 1: native mod Tier 2: Lua shim Tier 2: JS/TS shim
(.dylib, zero marshalling) (one trampoline) (one trampoline)
```
---
## 10. EV5 — mod lifecycle, scoping & leak-proofing
A mod is not eternal; it loads, enables, disables, and unloads, and its listeners
must vanish with it — the Node listener-leak footgun, solved structurally.
- **Every registration returns a token** (`ludic_on → token`), and a mod's tokens
are tracked under its **mod handle**. Unloading a mod calls `ludic_off` on all of
them at once — a mod can't leak a listener past its own life.
- **Listeners may be scoped to a game object.** `ludic_on_entity(entity, …)` binds
a listener that the **existing despawn walk** sweeps when that entity dies — the
same `@L_despawn_all` loop LC1 already emits, extended to drop entity-scoped
listeners. An entity-scoped listener on a dead entity is impossible by
construction, not by discipline.
- **Scene-scoped listeners** ride SCENES E1: a listener registered while a scene is
active is dropped by that scene's synthesized `on exit`, alongside its owned
entities. Overlay push/pop (SCENES E3) scopes listeners to the overlay's life.
- **Survival is explicit** (Blender's `persistent` lesson): a listener is
program-, mod-, scene-, or entity-scoped, chosen at registration. There is no
implicit "lives forever" — the default is the narrowest scope that makes sense
(mod), and wider survival is opt-in and visible.
- **Capabilities gate what a scripted mod may touch** (§14, open decision 6). A mod
manifest declares the events and world-table properties it needs; the loader
grants ids only for those. A mod that never asked for `Health` cannot `ludic_set`
it — an untrusted-code boundary the closed lifecycle layer never needed but an
open mod ABI must have.
---
## 11. EV6 — determinism, re-entrancy & the one honest compromise
Ludic is deterministic by design — deterministic rng, byte-identical PPM goldens,
save-load of the whole World. A modding layer is the classic place that determinism
goes to die (hash-ordered listeners, mods reading wall-clock, emit storms). Holding
the line is a **feature**, and the same one that makes Factorio's modded multiplayer
lockstep-correct.
- **Dispatch order is total and defined** — priority, then registration order, over
an *array*, never a hash map. Two mods loaded in the same order dispatch in the
same order on every machine.
- **Emit is synchronous by default, deferred on demand.** `emit E` runs listeners
now (push-at-the-site, Ludic's natural style — the LIFECYCLE footgun-1 fix).
Structural changes a listener requests (spawn/despawn/attach) **defer to the next
sync point** (LIFECYCLE LC5's `defer`), so mutate-during-dispatch is safe and
batched. Re-entrant `emit` inside a listener is allowed but **depth-bounded** (a
compile-time cap, trap on overflow) so an event cycle can't hang a frame.
- **Foreign listeners are the determinism boundary.** A native (Tier 1) listener is
as deterministic as any handler. A scripted (Tier 2) listener is only as
deterministic as the script — so the sandbox (§10) can **deny nondeterministic
capabilities** (wall-clock, unseeded rng, filesystem) to a mod that must stay in
a deterministic session (multiplayer, replays). Single-player mods can opt out.
**The one honest compromise.** SCENES-DESIGN's principle is "no dispatch tables —
the active-scene path is a register read and a static branch." The runtime
listener list *is* a dispatch table, walked at runtime. This doc owns that: it is
the **deliberate, opt-in exception**, justified because open-world extension is the
entire point of a mod ABI and cannot be resolved at compile time by definition.
The mitigations keep it honest — it is (a) gated behind `has_events` so unused it
costs nothing, (b) an array not a hash map so it stays deterministic, (c) fed by
compile-time-checked names so the *closed* side stays typed, and (d) reached only
after the zero-cost direct calls to compile-time `@On` handlers. Ludic pays for a
dispatch table exactly when, and only when, a game chooses to be moddable.
---
## 12. Lowering summary
Everything above reduces to constructs Ludic already has or honestly-scoped
additions to them:
| Construct | Lowers to |
|---|---|
| `event E { … }` | a generated payload record type + a reserved event id + a `has_events` bump |
| `emit E(…)` | build POD payload · direct-call each `@On(E)` handler · `if count: walk runtime list` |
| `@On(E)` handler | a compile-time listener: a direct call appended to `E`'s emit site (zero-cost) |
| `@Public @OnX(…)` | the existing hook's site, plus a guarded `bus_emit` of a payload built from the hook's bindings |
| public event name | a stable string interned to an integer id in a generated registry init |
| the runtime listener list | a compiler-owned fixed-capacity array per event; `ludic_on` = index bump, `ludic_off` = tombstone |
| the world table | a generated schema (prop→field→offset/type) + accessor ABI over the live `@S_` arrays |
| `cancellable` / `cancel` | a verdict field on the payload; the emit site branches on it |
| entity/scene-scoped listener | dropped by the existing despawn walk / synthesized `on exit` (LC1 / SCENES E1) |
| deferred structural change in a listener | LIFECYCLE LC5's `defer` queue, flushed at the sync point |
No heap for native payloads, no hash map, no per-event hand-written bridge. The
active game path is unchanged unless it opts in; the opt-in cost is one branch per
exposed event plus the listeners a mod actually registers.
---
## 13. Design principles distilled
1. **One system, two sides.** A public event is a lifecycle hook seen from across
the ABI. Don't build a parallel event runtime; promote the sites you already
have.
2. **Opt-in or invisible.** No `event`, no `@Public` → byte-identical goldens.
`has_events` gates the world the way `has_ecs` gates the ECS.
3. **Closed stays typed; only the open edge is dynamic.** Core events are
compiler-checked symbols; string names exist only at the genuinely-runtime mod
boundary, and even there must be registered (no silent typos).
4. **Generated bridge, never hand-written.** The world table and payload marshalling
come from the compile-time schema, so they never drift and cost O(languages),
not O(events × languages). This is the Luanti dividend — spend it.
5. **Deterministic dispatch is a feature.** Array order, not hash order; deny
nondeterministic capabilities to mods in deterministic sessions. Modded replay
and modded multiplayer depend on it.
6. **Lifetime follows scope, explicitly.** Every listener names its scope
(program/mod/scene/entity); the existing teardown walks sweep it. No implicit
immortality, no leaks.
7. **One ABI, every language.** The C ABI is the contract. Native mods bind it with
zero marshalling; scripted mods bind it through one trampoline per language.
Ludic never blesses a single scripting language.
8. **Only the semantic layer, still.** Mods observe and decide; they do not get
ctor/dtor/move hooks Ludic doesn't have. POD in, POD out.
---
## 14. Suggested implementation order
Each phase is independently shippable and testable, matching how the repo phases
work (and how LIFECYCLE/SCENES sequence).
- **EV0 — the bus core.** ✅ *Compile-time half shipped.* `event` / `emit` / `@On`
with the `g_events`-gated zero-cost lowering: an event compiles to a `@ev_<E>`
function whose body is its listeners in declaration order (payload bound by
name as params), and `emit E(…)` is a direct call. Verified byte-identical for
event-free programs, self-hosted to the C-free fixpoint. Still open in EV0: the
foreign C ABI (`ludic_on`/`ludic_emit`) and its runtime listener array, so a
mod in another language can join the same dispatch. Implementation notes: AST
`N_EVENT`/`S_EMIT`; `parse_event` + `@On` annotation + `emit` statement (guarded
by an identifier-lookahead so a bare `emit(...)` call still parses); registries
`g_events`/`g_onlisten` (emit_core); `emit_event_fns` (emit_game); `emit_emit`
(emit_stmt). [`examples/events.ludic`](examples/events.ludic) is a `test.sh` check.
- **EV1 — `@Public` hook promotion.** ✅ *All scopes shipped.* `@Public` on a
lifecycle hook fires a public event at that hook's site (payload: entity, plus
`EndReason` for despawn); `find_event(name)` doubles as the "is this hook
public?" gate. Covered: program (`@OnStart`/`@OnQuit` → `program_start`/`_quit`),
models (`@OnSpawn`/`@OnDespawn`), properties
(`@OnAttach`/`@OnDetach`/`@OnEnable`/`@OnDisable` → `prop_<P>_…`). Scenes and
layers use a `public` block modifier instead of an annotation:
`scene_<S>_enter`/`_exit` at the synthesized scene functions, and
`layer_<L>_show`/`_hide` at the layer-toggle site. Building layer events also
delivered **SCENES E2 layer toggle**: `enable/disable layer L` flips an `@LE_<L>`
flag that gates that layer's handlers, emitted only for toggled layers so
untouched scene programs stay byte-identical.
- **EV2 / EV2b — the world table.** ✅ *Read/write/scan/identify/create shipped.*
The generated reflection ABI (§7), dispatching a runtime prop/model id to the
right `@S_`/`@H_`/`@L_kind` storage: read/write (`prop_id`/`field_id`/`get`/`set`/
`has`), scan/identify (`entity_count`/`kind`/`model_id`), and create
(`spawn(model_id)`, which reuses the compiler's own spawn lowering — defaults,
`@OnSpawn`, and the spawn event). `get`/`set` address each field by its real
struct offset (constant struct GEP), correct for `int`/`fixed`/`byte`/`ptr`
fields and mixed layouts alike. Emitted only for an ECS program that declares
events (gated on `has_ecs() && g_events`), so event-free games are byte-identical.
A `ludic_query_next(prop, from)` cursor iterates live entities that have a
property. The world table is complete: read, write, scan, identify, create,
iterate.
- **EV3 — cancellable events.** ✅ *Shipped.* `event cancellable E`, the `cancel`
verb, and `emit E(…)` as an expression yielding the veto flag; the flag is a
trailing field of `%Ev_<E>`, so a foreign listener vetoes by setting it. Priority
ordering and `ignoreCancelled`/`halt` (§8) remain open. The modding headline —
observation becomes control.
- **EV4 — the scripting bridge.** One reference shim (Lua *or* QuickJS) over the
ABI, proving the O(languages) claim end to end.
- **EV5 — mod lifecycle & scoping.** ✅ *Shipped.* A parallel owner array `@evO_<E>`
(-1 = program-scoped, ≥0 = owning entity); `ludic_on_<E>` and
`ludic_on_entity_<E>` register with the right owner; `ludic_off_<E>(token)`
tombstones a slot to null and dispatch skips null slots; `ludic_sweep_entity`,
called from `emit_despawn` when the program has events, nulls every listener a
despawning entity owned. Scene-scoped listeners (drop on `on exit`) remain the
same shape applied at the scene teardown — a follow-on.
- **EV6 — determinism & re-entrancy.** ✅ *Depth bound shipped.* `@ev_depth`
increments on each `@ev_<E>` entry and decrements on exit; past `EV_DEPTH_CAP`
(32) a dispatch returns immediately (a cancellable event returns "not
cancelled"), so an event cycle can't hang. Dispatch order was already
deterministic (array, registration order). Still design: deferred structural
changes at a sync point (LC5) and capability gating for deterministic sessions.
- **EV7 — schema-opening & networking.** ✅ *Schema-opening shipped.* A mod defines
a new component at runtime: `ludic_register_prop(name, nfields)` allocates flat
`[MAX_ENT × nfields × i32]` storage + a has-flag array (capacity 32 dynamic
components) and returns a prop id past the compile-time range;
`ludic_attach_dyn`/`ludic_detach_dyn` toggle presence; `get`/`set`/`has`/`prop_id`
fall through to the dynamic registry for a prop id ≥ the compile-time component
count. Per-entity storage is isolated (`world_dyn.c`). This is the first genuinely
*dynamic* `@S_` storage — a deliberate departure from the closed dense arrays, so
it lives entirely behind the ABI (the game's own components stay static and
byte-identical). Dynamic components use integer fields addressed by index (no
field-name schema). *Still design:* the local/remote event split (Roblox's
lesson) waits on Ludic having a networking substrate.
EV0–EV1 deliver "the whole architecture emits public events." EV2–EV3 are where a
mod becomes able to *change the game*. EV4 proves the language-agnostic claim.
EV5–EV7 are hardening and reach.
---
## 15. Open decisions
1. **`emit` verb & payload identity.** Is `emit E(…)` the only spelling, or does a
`signal`-style per-property declaration (Godot) read better for the common case?
Are payloads always fresh POD records, or can an emit borrow an existing property
in place (cheaper, but aliases live storage)?
2. **`@Public` granularity.** Per-hook (proposed), per-model (`@Public model
Enemy`), or a program-level "expose all lifecycle" switch for prototyping? Does
`@Public` belong on the hook or on the `model`/`property`/`scene` it concerns?
3. **Name scheme stability.** Dotted strings (`model.Enemy.spawn`) interned to ids —
confirmed. Open: are ids stable across recompiles of the *same* source (needed
for save-compatibility of a listener table), and how does a renamed model
migrate a shipped mod?
4. **Schema opening (EV2/EV7).** Do mods stay behavior-only (listeners + custom
events + world reads/writes over author-defined schema), or can a mod define new
`property`/`model`? The latter needs dynamic `@S_` storage — a real departure
from the closed dense arrays (`LUDIC_MAX_ENT 1024`). Probably EV7.
5. **Cancellation surface.** Keep `cancel` (veto action) and `halt` (stop chain)
distinct (DOM), or collapse to one? Is `ignoreCancelled` per-listener or a
priority-band convention?
6. **Sandbox model.** Capability manifest per mod (proposed) — at what granularity
(per event? per property? per world-table verb)? What is denied by default in a
deterministic session, and who declares a session deterministic?
7. **Re-entrancy bound.** Compile-time constant emit-depth cap (trap on overflow),
or a runtime budget? What is the default depth, and is an event cycle a warning
or an error?
8. **Scripting runtime, in or out of scope.** Does Ludic *ship* an embedded runtime
(QuickJS/Lua) as a blessed default, or only the ABI and reference shims, leaving
the runtime to the game? (Bias: ship the ABI + one reference shim; bless no
language.)
---
*Companion to [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md) (the hook sites this layer
promotes) and [SCENES-DESIGN.md](SCENES-DESIGN.md) (scene/layer/overlay events and
scoped-listener teardown). Grounded in the Luanti gap analysis (`LUANTI-ROADMAP.md`):
the generated bridge is how Ludic avoids the 57k-line C++↔Lua tax. Supersedes
nothing until the compiler work in §12 lands.*

View file

@ -319,22 +319,34 @@ boot ── @OnStart ─▶ spawn ── @OnAttach(P), @OnSpawn(M) ─▶ …
- **`@OnSpawn(Model)` / `@OnDespawn(Model)`** — an *entity*. Both bind the model's
properties by name; `@OnSpawn` is a constructor, `@OnDespawn` a destructor.
Despawn doesn't statically know an entity's model, so despawn hooks compile to
functions dispatched on the entity's kind.
- **`@OnAttach(Property)`** — a *property*, fired each time that property is
attached to an entity (once its fields are seeded), with the property bound by
name.
functions dispatched on the entity's kind. `@OnDespawn` may take an optional
**reason**: `@OnDespawn(Enemy, reason: r)` binds `r` to an `EndReason` the
compiler passes at each teardown site — `EndReason.Despawned` for an in-world
`despawn`, `EndReason.Quit` when the program exits. At shutdown every still-live
entity's `@OnDespawn` fires with `Quit` (no silent deaths), so teardown can
branch on *why* it is ending — save on `Quit`, drop loot otherwise.
- **`@OnAttach(Property)` / `@OnDetach(Property)`** — a *property* attached to or
removed from an entity, with the property bound by name. `@OnAttach` fires once
the fields are seeded (a per-property constructor); `@OnDetach` fires when the
property is removed, *before* its has-flag clears, so the body can read the
outgoing value (a per-property destructor). They pair with the `attach` /
`detach` statements below.
```ludic
# doc-check: skip — lifecycle hooks
@OnStart handler Boot { seed(1) }
@OnSpawn(Enemy) handler Init { Health.hp = Health.max } # constructor
@OnDespawn(Enemy) handler Clean { drop_loot(Health.hp) } # destructor
@OnDespawn(Enemy, reason: r) handler End { # destructor that knows why
match r { EndReason.Quit => save() ; _ => drop_loot(Health.hp) }
}
@OnAttach(Sprite) handler Load { Sprite.id = image_load("goblin.png") }
@OnDetach(Sprite) handler Free { image_drop(Sprite.id) } # paired teardown
@OnQuit handler Save { save() } # once, at shutdown
```
**Enable / disable.** `enable` and `disable` are statements that flip something on
or off without destroying it. There are three scopes:
**Enable / disable — pause, don't destroy.** `enable` and `disable` are statements
that flip something on or off without destroying it. There are three scopes:
- **`disable P on e` / `enable P on e`** — one *property* on one entity. Disabling
clears the entity's has-flag, so queries stop matching it, but the field values
@ -349,20 +361,42 @@ or off without destroying it. There are three scopes:
Each toggle is one global flag flip (or one has-flag store), so nothing is copied
or freed — enable/disable is cheap and fully reversible.
**Attach / detach — add, don't just resume.** Where `enable`/`disable` *pause* a
property that already belongs to an entity, `attach`/`detach` change what the
entity *has*:
- **`attach P on e` / `attach P on e { field: v, … }`** — add property `P` to a
live entity, seeding its fields from the defaults plus any overrides, and fire
`@OnAttach(P)`. It fires only on a real transition: attaching a property the
entity already has is a no-op.
- **`detach P on e`** — remove `P`, firing `@OnDetach(P)` (which still reads the
outgoing value) before the has-flag clears. Also a no-op if `P` is absent.
The distinction mirrors DOTS's enableable components vs structural add/remove, or
Bevy's disable vs `Remove`: `disable` is a reversible pause that keeps the data;
`detach` is a structural removal (a following `attach` re-seeds fresh fields).
```ludic
# doc-check: skip — enable/disable
# doc-check: skip — enable/disable + attach/detach
@OnDisable(Shield) handler Down { play("shield_break.wav") }
@OnEnable(Shield) handler Up { play("shield_up.wav") }
@OnAttach(Shield) handler Grab { play("shield_get.wav") }
@OnDetach(Shield) handler Drop { play("shield_drop.wav") }
disable Shield on self() # this entity loses its shield; data kept for later
enable Shield on self() # shield back, amount unchanged
disable Shield on self() # pause: this entity loses its shield; data kept
enable Shield on self() # resume: shield back, amount unchanged
attach Shield on self() { amount: 3 } # structural: give it a fresh shield
detach Shield on self() # structural: take the shield away entirely
disable Gravity # a whole model sits out every query
disable AiThink # a handler stops running each phase
```
See [`examples/toggle.ludic`](examples/toggle.ludic) for all three scopes in one
frame. Still to come: **`@OnDetach`** (the paired hook for a property leaving,
needing the same per-property runtime dispatch as despawn).
See [`examples/toggle.ludic`](examples/toggle.ludic) for the three enable/disable
scopes, [`examples/detach.ludic`](examples/detach.ludic) for the structural
attach/detach pair, and [`examples/reason.ludic`](examples/reason.ludic) for
reason-carrying teardown. The rest of the lifecycle roadmap (value-change hooks,
query-membership edges, keyed effects) is in
[LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md).
**`@Handles` — the handlers a program drives.** Written in front of the
`program`, `@Handles(Move)` names the handlers it uses. It parses and reads as
@ -371,8 +405,71 @@ documentation; every declared handler still runs (registration is implicit).
See [`examples/annotations.ludic`](examples/annotations.ludic) (queries, computed
fields, one hook) and [`examples/lifecycle.ludic`](examples/lifecycle.ludic) (the
whole timeline), plus [`examples/toggle.ludic`](examples/toggle.ludic)
(enable/disable). Still to come: **scene** hooks (`@OnEnter`/`@OnExit`), which
wait on `scene` support landing in the compiler.
(enable/disable). Scenes and their `on enter` / `on exit` lifecycle blocks are
implemented — see "Scenes & layers" below. (An annotation spelling,
`@OnEnter(Scene)` / `@OnExit(Scene)`, is a designed but not-yet-built convenience
— see [SCENES-DESIGN.md](SCENES-DESIGN.md); today the hooks are written as `on
enter { … }` inside the `scene`.)
## Events & modding (`event`, `emit`, `@On`)
Where lifecycle hooks are the *closed, in-language* reactions the game author
compiles in, **events are the open, runtime surface a game exposes to mods** —
code loaded after compilation, in any language with a C ABI. The two share their
fire sites; an event is a hook seen from across the ABI. A program that declares
no `event` is compiled byte-for-byte as before.
- **`event E { field: T = default, … }`** declares a public event carrying a flat
POD payload (fields may be empty). **`@On(E) handler Name { … }`** registers an
in-language listener whose body reads the payload fields by name. **`emit
E(field: v, …)`** fires it — every listener runs, in declaration order, as a
direct call. It all desugars to a `@ev_<E>` function; there is no interpreter.
```ludic
# doc-check: skip — illustrative
event Hurt { entity: int, amount: int }
@On(Hurt) handler Flash { hud_flash(amount) } # payload bound by name
emit Hurt(entity: e, amount: 5) # fires every listener
```
- **The foreign ABI.** Each event also generates `int ludic_on_<E>(void (*cb)(Ev*))`
and a payload struct `%Ev_<E>`, so a mod in C / Lua / JS (over its FFI) registers
a callback and is dispatched to right after the native listeners — the closed and
open halves, one dispatch. Native listeners cost a direct call; foreign ones one
indirect call over a fixed-capacity array (registration order = dispatch order,
so a modded game stays deterministic). See [`examples/mod_host.ludic`](examples/mod_host.ludic)
and the C mod in [`tests/mod_c/mod.c`](tests/mod_c/mod.c).
- **`@Public` promotes a lifecycle hook to an event, across the whole
architecture.** The game's own lifecycle becomes moddable with no hand-written
`emit`, at every scope:
- **program** — `@Public @OnStart`/`@OnQuit` → `program_start` / `program_quit`
(the top-level mod entry/exit points). See [`examples/program_events.ludic`](examples/program_events.ludic).
- **models** — `@Public @OnSpawn(Enemy)`/`@OnDespawn(Enemy)` →
`model_Enemy_spawn` / `model_Enemy_despawn` (entity, + `EndReason` on despawn).
See [`examples/promote.ludic`](examples/promote.ludic).
- **properties** — `@Public @OnAttach/@OnDetach/@OnEnable/@OnDisable(P)` →
`prop_<P>_attach` / `_detach` / `_enable` / `_disable`. See [`examples/prop_events.ludic`](examples/prop_events.ludic).
- **scenes** — a `public` scene → `scene_<S>_enter` / `scene_<S>_exit`. See [`examples/scene_events.ludic`](examples/scene_events.ludic).
- **layers** — a `public` layer, with `enable layer L` / `disable layer L`
flipping the layer on and off (its handlers stop while hidden) →
`layer_<L>_show` / `layer_<L>_hide`. See [`examples/layer_events.ludic`](examples/layer_events.ludic).
- **`cancellable` events are decisions, not just notifications.** A listener on a
`cancellable` event may `cancel` it (a foreign listener sets the payload's
trailing `cancelled` flag); `emit E(…)` used as an *expression* yields that flag,
so the caller applies the action only when it wasn't vetoed — the Bukkit/DOM
`preventDefault` shape. See [`examples/cancel.ludic`](examples/cancel.ludic).
```ludic
# doc-check: skip — illustrative
event cancellable BeforeHurt { amount: int }
@On(BeforeHurt) handler Armor { if amount > 10 { cancel } }
if emit BeforeHurt(amount: dmg) == 0 { hp = hp - dmg } # apply only if not vetoed
```
The full modding roadmap — the world-table reflection ABI, scoped/leak-proof
listeners, and the sandbox — is in [EVENTS-DESIGN.md](EVENTS-DESIGN.md).
## Records (`property`), arrays and slices
@ -478,7 +575,7 @@ another `.ludic` file (see `examples/lib/`).
`if/else` (the `else` is optional) · `while cond { }` · `for i in a .. b { }`
(numeric range) · `for (…) in query […] { }` · `break` · `continue` · `return` ·
`spawn` · `despawn` · `enable` / `disable` (a property `on e`, a model, or a
handler) · `match` · `machine`.
handler) · `attach` / `detach` (a property `on e`) · `match` · `machine`.
### Bindings: `let`, `var`, `const`
@ -712,8 +809,6 @@ Units on quantities (`9.8 m/s^2`), `with` record-update expressions, a bytecode
VM + hot-reload, and the live agent bridge — these appear in the design docs but
are future work.
- **`scene` / `layer` / `on enter` / `on exit`** — the state-machine-over-scenes
sugar is documented above but not parsed by the self-hosted compiler yet.
- **`reads` / `writes` clauses** — parsed and reserved on the handler node, but no
analysis pass consumes them.
- **`[T; N]` fixed arrays** — documented above, but `ptype` parses only `[]T`
@ -730,13 +825,13 @@ self-hosting; their lowerings are in
## Scenes & layers
> ⚠️ **Not yet implemented in the current (self-hosted) compiler.** `scene`,
> `layer`, and the `on enter` / `on exit` hooks are a design target: the
> compiler has no `scene` declaration and [`examples/scenes.ludic`](examples/scenes.ludic)
> does not compile today. Games
> that need mutually-exclusive states use a mode register (`reg`/`set_reg`) with a
> `machine`, as `examples/chronorift` does. This section describes the intended
> syntax for when scene support lands.
> **Implemented (S0).** `scene`, `layer`, and the `on enter` / `on exit` hooks
> compile; [`examples/scenes.ludic`](examples/scenes.ludic) runs and is checked
> by `test.sh`. A scene lowers to a `machine` the compiler writes for you: one
> implicit active-scene register, states numbered by declaration order, and
> `become` as two direct calls plus a store. Richer scene features (the overlay
> stack, scene-owned entities, scene-local state, transition parameters) are
> designed in [SCENES-DESIGN.md](SCENES-DESIGN.md) and not built yet.
A program is usually several mutually-exclusive states — a title screen, the
overworld, a battle — and the usual way to write that is a mode register
@ -764,7 +859,8 @@ scene Overworld {
```
- Exactly **one scene is active**. The one marked `start` runs first (or the
first declared, if none is marked).
first declared, if none is marked); its `on enter` fires once at boot, right
after the `Start` phase.
- A scene's handlers only run while it is active. Handlers declared outside any
scene are global and run every frame regardless.
- **Layers group handlers and declaration order is draw order**: within a phase,
@ -776,8 +872,14 @@ scene Overworld {
becomes `Name`, and its `on enter` runs. Inside a layer handler the compiler
knows which scene is leaving, so a transition costs two direct calls and a
store — there is no dispatch table.
- **The active scene is snapshotted per phase.** A `become` mid-phase runs its
`on exit`/`on enter` immediately, but the switch of *which layers dispatch*
takes effect at the next phase boundary — so exactly one scene's layers run in
any single phase, and a `become` in `Update` is visible to that same frame's
`Render`.
`examples/scenes.ludic` sketches the ordering rules (it does not compile yet).
[`examples/scenes.ludic`](examples/scenes.ludic) is a runnable, tested example
of these rules.
## Queries in a handler signature

348
LIFECYCLE-DESIGN.md Normal file
View file

@ -0,0 +1,348 @@
# Lifecycle events, expanded — a design doc
> **Status: LC0–LC1 shipped; LC2–LC6 are design.** The structural attach/detach
> pair and `@OnDetach` (§4, LC0), and reason-carrying `@OnDespawn` (§5, LC1), are
> implemented and tested ([`examples/detach.ludic`](examples/detach.ludic),
> [`examples/reason.ludic`](examples/reason.ludic), `test.sh` checks). The
> extensions LC2–LC6 are research-informed proposals, not built. This document
> distills a survey of lifecycle models across seven systems (§3) into a roadmap
> for Ludic. §13 lists the open decisions.
---
## 1. Thesis
A game/ECS usually models lifetime as **create → destroy on a timeline**. A survey
of how other systems handle it — Unity (MonoBehaviour + DOTS), Unreal, Bevy,
flecs, EnTT, Godot, and non-game paradigms (actor model, declarative UI, RAII) —
shows that mature lifecycle designs model something richer than birth and death:
- **a reaction to a *reason*** — teardown that knows *why* it is ending (Unreal
`EndPlay(reason)`, Erlang `terminate(Reason)`, Akka `preRestart(reason, msg)`);
- **paired setup/teardown *keyed on dependencies*** — an update is teardown-then-
setup on a value change (React `useEffect`, Compose `DisposableEffect`);
- **a deterministic consequence of *scope / ownership*** — guaranteed, ordered,
single-shot teardown (C++/Rust RAII, DI scoped lifetimes);
- **an edge on *query membership*** — fire when data starts/stops matching a
composite condition (DOTS `OnStartRunning`, flecs `Monitor`).
Ludic's model is a good base: lifecycle hooks are `@`-annotations on handlers that
**desugar to ordinary code**, firing at fixed timeline moments, keeping the data
plain. This doc extends that base along the four axes above **without breaking the
desugars-to-code discipline** — every proposal lowers to plain branches and calls,
no hidden runtime.
**One structural advantage worth stating up front.** flecs and EnTT each carry
*two* lifecycle layers: a **memory** layer (ctor/dtor/move/copy — because C++
objects must be constructed and relocated as archetypes repack) and a **semantic**
layer (on_add/on_set/on_remove). Ludic's components are POD in packed `@S_`
arrays; there is nothing to construct, destruct, or move-relocate. **Ludic needs
only the semantic layer** — half the machinery, none of the "component isn't
movable" footguns. Keep it that way.
---
## 2. What Ludic has today
Seven hooks, each an annotation that desugars to a handler body at a timeline
moment ([LANGUAGE.md §Annotations](LANGUAGE.md)):
```
boot ─ @OnStart ─▶ spawn ─ @OnAttach(P), @OnSpawn(M) ─▶ … ─ @OnDetach(P)/@OnDespawn(M) ─▶ quit ─ @OnQuit
```
The lifecycle reads cleanest as a table of **paired setup/teardown** across five
scopes. Every cell is now filled — LC0 closed the one hole (`@OnDetach`):
| Scope | Setup | Teardown | Driven by |
|---|---|---|---|
| program | `@OnStart` | `@OnQuit` | boot / quit |
| entity | `@OnSpawn(M)` | `@OnDespawn(M)` | `spawn` / `despawn` |
| property (structural) | `@OnAttach(P)` | `@OnDetach(P)` ✅ | `attach` / `detach` |
| property (toggle) | `@OnEnable(P)` | `@OnDisable(P)` | `enable` / `disable` |
| scene | `on enter` | `on exit` | `become` |
Two things this table already gets right, which the survey flags as the frequent
mistakes to avoid:
- **The toggle pair is distinct from the structural pair.** Unity's clearest
lesson is separating the *repeatable* enable/disable cycle (pooling, pausing,
data kept) from the *once* create/destroy (data gone). Ludic has both, as
distinct verbs: `disable` pauses and keeps data; `detach` structurally removes
(a later `attach` re-seeds). This is exactly DOTS enableable-components vs
structural add/remove, and Bevy `disabled` vs `Remove`.
- **Hooks are typed annotations, not magic-named methods.** MonoBehaviour matches
`Awake`/`Update` by *string name* via reflection — a typo silently never runs.
Ludic's `@OnSpawn(Enemy)` is a checked reference; a wrong name is a compile
error. Preserve this.
What's missing is everything past "what happened": **why** it happened, **which
values changed**, **when composite conditions begin/end to hold**, and
**dependency-keyed** setup/teardown. That is the roadmap.
---
## 3. Research digest — the one idea to steal from each
| System | The transferable idea |
|---|---|
| **Unity MonoBehaviour** | Two-phase init with a global barrier (all `Awake` before any `Start`); repeatable enable-pair vs once create-pair. |
| **Unity DOTS** | *Data-driven activation*: `RequireForUpdate` + `OnStartRunning`/`OnStopRunning` — a system edge-triggers when its query starts/stops matching. Enableable components = cheap "logically off." |
| **Unreal** | *Reason-carrying teardown*: `EndPlay(EEndPlayReason)` — one teardown, branch on `Destroyed`/`LevelTransition`/`Quit`/…; forces enumerating every death path (no silent deaths). Provenance-tagged construction. |
| **Bevy** | Full structural event set Add/Insert/**Replace**/Remove/Despawn with strict order; **Replace exposes the old value before drop**. Hooks (type-level, singular, invariant) vs observers (plural, reactive). Declarative `before`/`after`/`chain` ordering. State `OnEnter`/`OnExit`/`OnTransition`. |
| **flecs** | `Monitor` observers fire on *composite query membership* start/stop. Events fire on **real transitions**, not every API call. Deferred-by-default with explicit sync points. |
| **EnTT** | `patch` as the *explicit mutation channel* that fires `on_update` (solves "raw writes are invisible"). Opt-in signals — zero cost when unused. |
| **Godot** | Tree membership *is* the lifecycle driver; enter top-down, **`_ready` bottom-up** (dependencies initialized first); `queue_free()` deferred safe-delete; `process_mode` pause inherited down the tree. |
| **Actor model (OTP/Akka)** | Lifecycle driven by *failure + supervision*: reason-carrying `terminate`, **restart as a state distinct from create/destroy** (stable identity, reset transient state), supervision trees, `code_change` = live state migration. |
| **Declarative UI (React/SwiftUI/Compose)** | *Paired setup/teardown keyed on a dependency list* — cleanup co-located with setup so it can't leak; an update **is** keyed teardown-then-setup; lifetime follows *identity*. |
| **RAII / Rust `Drop` / DI scopes** | *Scope = lifetime*: deterministic, reverse-construction-order, single-shot, no-resurrection teardown, guaranteed even on early exit; lifetime-mismatch checking (no long-lived thing holding a short-lived handle). |
Two recurring **footguns** the whole survey warns against, to design *out* of Ludic:
1. **Silent order-dependent reactivity.** Bevy's removal buffers are cleared at
end-of-frame, so a detector that runs before the mutator *misses removals
entirely*. If Ludic adds change/removal reactivity, make it either push-based
(fire at the mutation site — Ludic's natural style) or loudly order-checked.
2. **Invisible in-place writes.** flecs `on_set` and EnTT `on_update` don't fire
on a raw pointer write — you must call `modified()`/`patch`. Ludic can dodge
this entirely (see LC2): the compiler *sees* every write site.
---
## 4. LC0 — structural attach/detach + `@OnDetach` ✅ *shipped*
The one missing cell in §2's table. `attach P on e { overrides }` adds a property
to a **live** entity (seeding fields, firing `@OnAttach`); `detach P on e` removes
it (firing `@OnDetach`, which reads the outgoing value, before the has-flag
clears). Both fire only on a **real transition** (flecs/Bevy idempotent-add
semantics): re-attaching a present property or detaching an absent one is a no-op.
Lowering: `attach` guards on the has-flag and, when absent, reuses the existing
`emit_init_component` (seed + `@OnAttach`); `detach` guards on presence, clears the
flag, and fires `@OnDetach` with the property bound by name — the same binding the
`@OnDisable` path already uses. No new runtime; POD data stays in `@S_` storage.
See [`examples/detach.ludic`](examples/detach.ludic).
---
## 5. LC1 — reason-carrying teardown ✅ *shipped (`@OnDespawn`)*
The highest-conviction idea in the survey: it appears independently in Unreal
(`EndPlay`), Erlang (`terminate`), and Akka (`preRestart`), and Bevy has an open
issue asking for it. **Teardown should know *why*.** A destructor frequently needs
to branch — save on `Quit` but not on a scene swap, skip network cleanup when the
whole program is exiting.
`@OnDespawn` gains an optional bound **reason**:
```ludic
# doc-check: skip
# EndReason { Despawned, SceneExit, Quit } — the compiler owns this enum
@OnDespawn(Enemy, reason: r) handler Clean {
match r {
EndReason.Quit => {} # app closing — don't bother dropping loot
_ => drop_loot(Health.hp)
}
}
```
**What shipped.** The lowering is exactly the cheap desugars-to-code shape the
survey promises. The despawn hook compiles to `@on_despawn_<Model>(i32 %e, i32
%reason)`; when the hook writes `reason: r`, `r` is bound as an int local reading
`%reason`. Each teardown *site* passes a constant `EndReason`:
- `despawn e` passes `Despawned` (0) — an in-world death.
- **program shutdown** passes `Quit` (2): a generated `@L_despawn_all(reason)`
walks the live set at `done:` (before `@OnQuit`, matching the timeline) and
fires every survivor's `@OnDespawn`. This makes **"no silent deaths"** real —
an entity that outlives the run still gets its destructor, and can branch on
`Quit` to skip work that only matters mid-game. Emitted only when the program
has `@OnDespawn` hooks, so despawn-free programs are byte-for-byte unchanged.
- `SceneExit` (1) is reserved: a scene tearing down its owned entities
(SCENES-DESIGN E1) will pass it once scene-owned entities land.
`EndReason` is compiler-owned (resolved in `enum_ordinal`), so `EndReason.Quit`
works without a user declaration; a user enum of the same name still shadows it.
Backward-compatible: the `reason:` binding is optional, and `@OnDespawn` without
it is unchanged. `@OnDetach` and scene `on exit` do **not** yet take reasons
(§13.1). See [`examples/reason.ludic`](examples/reason.ludic).
---
## 6. LC2 — value-change hooks `@OnChange(P)` *(a compile-time win)*
Every reactive ECS wants "fire when a component's value changes" (flecs `on_set`,
EnTT `on_update`, Bevy `Changed<T>`), and every one hits the same footgun: a raw
in-place write is invisible, so you must route mutations through a special channel
(`modified()`, `patch`) or you miss changes.
**Ludic can sidestep the footgun because it is an AOT compiler that sees every
write site.** A field store `Health.hp = …` is a statement the compiler lowers; if
`Health` carries an `@OnChange`, the compiler can emit the hook call *right after
the store*. No dirty bits, no end-of-frame flush, no missed-write class of bugs —
the thing that is a runtime hazard everywhere else is resolved at compile time.
```ludic
# doc-check: skip
@OnChange(Health) handler Bar { hud_set_health(Health.hp) } # after any write to a Health field
```
Open question (§7): fire on *every* write (Bevy's `DerefMut` semantics — simple,
may over-fire) or guard with a value compare (fire only on actual change — needs
the old value, à la Bevy `Replace`). The compiler has the old value in hand at the
store site, so the value-compare form is feasible and is the more useful default.
---
## 7. LC3 — query-membership edges `@OnStartMatch` / `@OnStopMatch`
DOTS `OnStartRunning`/`OnStopRunning` and flecs `Monitor` fire when an entity
**starts or stops matching a composite query** — not a single component, but a
whole condition (`{Position, Velocity, moving}`). This is strictly more expressive
than per-property `@OnAttach`, which can't see "the entity now has *both* and is
alive." It's the natural ECS form of enter/exit.
```ludic
# doc-check: skip
@OnStartMatch(these: [Position, Velocity{dx != 0 or dy != 0}], on: Actor)
handler BeginMoving { play("footstep_loop.wav") }
@OnStopMatch(these: [Position, Velocity{dx != 0 or dy != 0}], on: Actor)
handler StopMoving { stop("footstep_loop.wav") }
```
Cost: unlike LC1/LC2 this needs runtime state — a per-entity shadow bit per
monitored query ("did it match last tick?"), checked once per frame, edge-
triggering the hook on a change. flecs does this by evaluating the query against
the entity's previous and current archetype. Ludic would keep a `@M_<query>` bit
array parallel to `@H_`. Medium cost; a genuinely differentiated feature.
---
## 8. LC4 — keyed effects (paired setup/teardown on a dependency list)
The declarative-UI headline, and the biggest reach. React `useEffect`, Compose
`DisposableEffect`, and SwiftUI `.task` all express: *while this thing exists (or
while key K holds), set up a resource; when it leaves or K changes, tear it down*
— with cleanup **co-located** with setup so it can't leak, and an *update* defined
as keyed teardown-then-setup. This collapses create/update/destroy into one
primitive.
```ludic
# doc-check: skip — sketch
@Effect(on: Enemy, keys: [Sprite.id]) handler Body {
let tex = image_load(Sprite.id)
dispose { image_drop(tex) } # runs on despawn OR when Sprite.id changes
}
```
Semantics: the setup runs on spawn (and whenever a listed key changes, after the
previous `dispose`), and `dispose` runs on despawn (and before each keyed re-run).
It unifies `@OnAttach`/`@OnDetach`/`@OnChange` into one leak-proof unit. Lowering
needs somewhere to stash the effect's captured teardown state and last key values
per entity — a per-effect side table, re-checked in a phase. Design only; the
syntax and storage model are open. This is where Ludic could feel genuinely modern
relative to every ECS surveyed (none of which have it).
---
## 9. LC5 — deferred structural changes with commit points
DOTS `EntityCommandBuffer`, flecs `defer_begin/end`, and Godot `queue_free()` all
make structural change **deferred with an explicit commit point**, so mutating
while iterating is safe and batched. Ludic's `spawn`/`despawn` are immediate today,
but *already* iteration-safe by a different route — matching is lazy per entity id
([LANGUAGE.md](LANGUAGE.md) "Matching is lazy, not snapshotted"), so despawning the
current entity is defined. A `defer { … }` block (or `despawn e at LateUpdate`)
that queues structural changes to a phase boundary would add batching and a single
predictable commit point, and is the prerequisite for safe parallel handlers
(the `reads`/`writes` scheduling in SCENES-DESIGN). Design only; lower priority
than LC1–LC3 because the immediate path is already safe.
---
## 10. LC6 — supervision, restart-as-a-state, live migration
The furthest-out cluster, from the actor model and OTP: lifecycle driven by
**failure**, not just create/destroy. Three ideas, all tied to Ludic's eventual
hot-reload / bytecode-VM roadmap rather than the near term:
- **Restart as a distinct state** between create and destroy — preserve an
entity's identity, reset its transient components, re-run setup (respawn,
hot-reload). Akka's "stable external ref, replaced internal state."
- **Supervision / failure escalation** — a subsystem owner declares a policy for
child faults (restart one / restart the group / escalate to reload the scene)
instead of defensive inline checks. Ludic has no failure model yet, so this
waits on one.
- **Live state migration** (`code_change`) — a hook that transforms an entity's
persistent state across a code/schema version, so hot-reload evolves data
instead of destroying it. Directly relevant to a self-hosting language.
---
## 11. Design principles distilled from the footguns
1. **No silent deaths.** Enumerate every teardown reason (LC1). If the compiler
must name the reason at each site, it can't forget a path.
2. **Fire on real transitions, not API calls.** Idempotent add/remove — LC0
already does this; keep it for every future hook.
3. **Keep "paused" and "gone" distinct.** `disable`/`enable` (data kept) vs
`detach`/`attach` (structural) — already true; don't let a future feature blur
them.
4. **Prefer compile-time resolution to runtime tracking.** LC2 turns the
universal "invisible write" footgun into a compile-time hook emission because
Ludic sees write sites. Reach for this wherever a runtime dirty-bit is the
obvious-but-worse option.
5. **If reactivity is order-dependent, make it loud.** Never silently drop events
at a frame boundary (Bevy's removal-buffer trap). Ludic's push-at-the-site
style avoids this by default.
6. **Deterministic teardown order.** When a scope tears down many things (a scene
unloading its owned entities — SCENES-DESIGN E1), define the order (reverse of
creation, RAII-style) rather than leaving it unspecified.
7. **Only the semantic layer.** POD components mean no ctor/dtor/move hooks. Don't
grow a memory-lifecycle layer Ludic doesn't need.
---
## 12. Suggested implementation order
- **LC0 — attach/detach + `@OnDetach`.** ✅ Done. Closes the structural pair.
- **LC1 — reason-carrying teardown.** ✅ Done for `@OnDespawn` (an `i32 %reason`
param + a constant at each site, plus a shutdown despawn-all for `Quit`).
`@OnDetach` / `on exit` reasons remain open (§13.1).
- **LC2 — `@OnChange(P)`.** Compile-time hook emission at write sites — a
Ludic-specific win over every ECS's invisible-write footgun. **Recommended next.**
- **LC3 — `@OnStartMatch`/`@OnStopMatch`.** First feature needing runtime shadow
state; the expressive ECS enter/exit.
- **LC4 — keyed effects.** The modern, leak-proof unification. Design first.
- **LC5 — deferred structural changes.** Batching + parallel-safety; the immediate
path is already iteration-safe, so lower urgency.
- **LC6 — supervision / restart / migration.** Waits on a failure model and the
hot-reload roadmap.
---
## 13. Open decisions
1. **Reason enum (LC1):** *resolved for `@OnDespawn`* — ships `Despawned`,
`SceneExit`, `Quit` as a compiler-owned `EndReason`, passed as an optional
`reason:` binding (not a separate annotation). Still open: `SceneExit` has no
firing site until scene-owned entities (SCENES-DESIGN E1); should `@OnDetach`
and scene `on exit` take reasons too, and if so with which reason values?
2. **`@OnChange` (LC2):** fire on every write (simple, over-fires) or only on an
actual value change (needs the old value at the store site)? Per-field or
whole-property granularity?
3. **Membership edges (LC3):** where do the shadow bits live, and is the check
per-frame or event-driven off attach/detach/spawn? Cost budget.
4. **Keyed effects (LC4):** syntax (`@Effect` annotation vs an `effect { … dispose
{ … } }` statement), and where per-entity teardown/key state is stored.
5. **Ordering:** none of this addresses intra-phase handler ordering (Bevy
`before`/`after`, flecs `DependsOn`). Worth a separate proposal; declarative
relational ordering over priority integers, per the survey.
---
*Companion to [LANGUAGE.md §Annotations](LANGUAGE.md) and
[SCENES-DESIGN.md](SCENES-DESIGN.md) (scene-owned entities and reasons intersect at
LC1/LC5). Supersedes nothing until the compiler work in §12 lands.*

338
MOBILE-DESIGN.md Normal file
View file

@ -0,0 +1,338 @@
# iOS & Android — a design doc
> **Status: all design, nothing shipped.** Ludic builds windowed on macOS
> (`runtime/native/cocoa.ll`) and has a documented — but currently un-reimplemented
> — wasm32 web target. iOS and Android are not buildable today, and the
> cross-compile plumbing that would target them died with the C driver. This doc
> lays out the whole path so we can decide the shape before building any of it. The
> headline decision (§7): render on the **GPU via `extern fn` FFI**, not the CPU
> framebuffer. §11 lists the open decisions.
---
## 1. Where we are
A Ludic program compiles to LLVM IR, then clang assembles and links it. The
platform story has **two independent axes**, and it's essential not to conflate
them:
| Axis | What it is | State today |
|---|---|---|
| **Target** (triple + toolchain) | how IR becomes a runnable binary for an OS/arch | barely plumbed — no `--target`, no emitted `target triple`, host-only |
| **Platform runtime** (window/input/present) | one file implementing the 5-function window protocol | well-factored — `cocoa.ll` is ~328 lines, swappable |
**What exists:**
- The window seam is exactly five functions — `win_open` / `win_poll` /
`win_present` / `win_running` / `win_close` — declared by the compiler
([emit_head.ludic:58](selfhost/emit_head.ludic:58)) and lowered as intrinsics
([emit_intrin2.ludic:39](selfhost/emit_intrin2.ludic:39)). The runtime calls them
through `rt_*` wrappers ([core.ludic:48](runtime/native/core.ludic:48),
[:103](runtime/native/core.ludic:103), [:217](runtime/native/core.ludic:217)).
`COMPILING.md` states the intent plainly: a new platform is "another `.ll` file
with the same five entry points and no compiler change."
- **`extern fn` FFI is real and live** — `extern fn c_hypot(a: fixed, b: fixed) ->
fixed = "hypot_fx"` ([LANGUAGE.md:565](LANGUAGE.md:565)), with a full pipeline:
parse ([parse_game.ludic:236](selfhost/parse_game.ludic:236)) → call lowering to a
direct `call @<sym>` ([emit_expr.ludic:168](selfhost/emit_expr.ludic:168)) →
`declare` emission ([emit_head.ludic:105](selfhost/emit_head.ludic:105)). Working
examples: [examples/net_echo.ludic:12](examples/net_echo.ludic:12),
[examples/lib/arena.ludic:14](examples/lib/arena.ludic:14). This is the single
most important fact in this document — see §7.
**What's missing (all of it must be built):**
| Gap | Why mobile needs it |
|---|---|
| `--target <triple>` flag + emitted `target triple`/`datalayout` | iOS = `aarch64-apple-ios`, Android = `aarch64-linux-android`; both are cross-compiles |
| per-target `size_t` width (i32/i64) | already a known wasm trap; every allocation sizing depends on it |
| **OS-owned frame loop** (`ludic_boot`/`ludic_frame`/`ludic_alive`/`ludic_teardown`) | iOS (CADisplayLink) and Android (Choreographer) own the loop — you cannot `while(alive)` |
| per-platform window shim + touch input | UIKit/`CAMetalLayer`, Android `Surface`/NDK; input is touch, not a keycode |
| SDK sysroot + packaging + signing | `.app` bundle / `.apk`, not a bare executable |
The frame-loop gap is shared with the web target — `tools/ludic-web/run.mjs`
already expects `ludic_boot`/`ludic_frame`, but the self-hosted emitter only
produces a monolithic `@main` ([emit_game.ludic:685](selfhost/emit_game.ludic:685)).
So the wasm path is half-broken for the same reason mobile can't exist yet.
---
## 2. Design principles
1. **Two axes, kept separate.** "Add a platform" = a cross-compile *target* plus a
platform *runtime*. Muddling them is why this looks bigger than it is. Most of
the compiler work (§4, §5) is target plumbing that serves web, iOS, and Android
at once; the per-OS work (§6) is genuinely small by design.
2. **The OS owns the loop — so we must too.** Mobile, like the browser, forbids an
inline frame loop. Rather than special-case mobile, adopt the frame-driven model
*everywhere* the OS demands it, from one emitter change. This is the keystone.
3. **The GPU is an ABI to call, not a program to compile.** `extern fn` already
binds C libraries; bind GL ES / Metal the same way. No IR-per-API (the `cocoa.ll`
route — 328 lines for *five* functions), no per-symbol intrinsics. The roadmap
reaches this conclusion independently ([LUANTI-ROADMAP.md:1083](LUANTI-ROADMAP.md:1083),
[:1375](LUANTI-ROADMAP.md:1375)).
4. **The 2D stack stays byte-identical.** The framebuffer graphics
(`rt_fb` + all `rt_*`/`image`/`truetype`/`ui` primitives) keep working
unchanged. GPU rendering is *additive*: 2D composites as one texture on top of
GPU 3D. Nothing above the window seam is rewritten.
---
## 3. Core model
Everything below reduces to plumbing one new flag through the compiler and swapping
two runtime files per OS. The mental model:
```
ludicc app.ludic --target aarch64-apple-ios -o app
│
├─ emit_head: target triple / datalayout / size_t width (§4)
├─ emit_game: ludic_boot/frame/alive/teardown not @main (§5)
├─ link: runtime/ios/uikit.ll + gfx3d.ldylib (§6, §7)
└─ package: .app bundle + codesign (§8)
```
The game source and the entire ECS/graphics/UI stack compile **unchanged** for
every target. Only the head declarations, the entry-point shape, the linked
platform file, and the packaging step vary.
---
## 4. Extension M1 — the target axis: `--target`, triple, `size_t`
Today [main.ludic:66](selfhost/main.ludic:66) parses `--windowed`/`--headless`/
`--emit-llvm`/… and nothing selects an arch; the IR carries no `target triple`, so
native inherits clang's host default and the only explicit triple in the tree is
`wasm32-unknown-unknown` ([runtime/web/wasm.ll:23](runtime/web/wasm.ll:23)).
Proposal: a `--target <triple>` flag that drives three things.
```
ludicc app.ludic --target aarch64-apple-ios -o app
ludicc app.ludic --target aarch64-apple-ios-simulator -o app # x86_64 host → arm64 sim varies
ludicc app.ludic --target aarch64-linux-android -o libapp.so
```
- **Emit the triple + datalayout.** `emit_header`
([emit_head.ludic:37](selfhost/emit_head.ludic:37)) gains a `target triple = …`
/ `target datalayout = …` line, chosen from a small table keyed on `--target`.
Absent the flag, emit nothing (host default) — keeps existing native builds
byte-identical.
- **Per-target `size_t` width.** wasm32 already needs `i32` sizes; the same helper
discipline (`ll_size_t`/`ll_widen`/`ll_narrow`, per the web-backend notes) applies
to any 32-bit target. iOS/Android arm64 are LP64 like macOS, so `i64` — but the
flag must *select* the width, not assume the host's.
- **Toolchain construction.** The linker command
([main.ludic:130](selfhost/main.ludic:130)) becomes target-conditional: an SDK
sysroot (`-isysroot`/`--sysroot`), the platform `.ll`, and target-specific link
flags (§8). `$LUDIC_CC` still overrides; add `$LUDIC_SYSROOT_<target>` for the
SDK path so CI and local machines can differ.
This axis is **shared with reviving wasm** — do it once, three targets benefit.
---
## 5. Extension M2 — the OS-owned frame loop (the keystone)
A native build emits `@main` with the frame loop inline — an `rt_init`, then a
`loop:`/`done:` block calling `rt_poll`/`rt_running`
([emit_game.ludic:685](selfhost/emit_game.ludic:685)). **iOS and Android cannot run
this.** UIKit calls back into your code once per display refresh (CADisplayLink);
Android's Choreographer does the same; the browser's `requestAnimationFrame` already
does. In all three the OS owns the loop and calls *you*.
Proposal: emit four exported functions instead of an inline-loop `@main`, exactly
as `COMPILING.md` already describes and `run.mjs` already expects:
```
ludic_boot() → rt_init (once)
ludic_frame() → rt_poll · systems · rt_present (per OS callback)
ludic_alive() → i1 → rt_running (OS asks: keep going?)
ludic_teardown() → rt_shutdown (once)
```
- **`@main` becomes the composed default, not the only shape.** For host desktop
and headless, the compiler synthesizes an `@main` that *calls* the four in an
inline loop — so native/headless output is unchanged in behavior. For
OS-owned-loop targets (`--target` is wasm/ios/android, or a new
`--loop=external` mode), emit only the four exports and no driving `@main`.
- **One emitter change, three targets fixed.** This simultaneously un-breaks the
web target (whose runner already calls these) and unlocks both mobile OSes. It is
the highest-leverage change in this doc.
- **State stays where it is.** The four functions close over the same globals
`rt_init`/`rt_poll`/`rt_running`/`rt_shutdown` already touch
([core.ludic:48](runtime/native/core.ludic:48)); no new runtime state, no heap.
---
## 6. Extension M3 — the per-OS window shim + touch input
Each OS gets one platform file implementing the five-function seam, modeled on
`cocoa.ll` but rewritten for its UI toolkit. This is the part the codebase is
explicitly built for.
- **iOS — `runtime/ios/uikit.ll` (or a thin `.m` shim).** `win_open` creates a
`UIWindow` + a `UIViewController` whose view is a `CAMetalLayer`/`MTKView`;
`win_present` presents the current drawable; the loop is driven by M2's
`ludic_frame` from a `CADisplayLink`, so `win_poll`/`win_running` adapt to the
callback model rather than a spin. Hand-written IR against `objc_msgSend` is
possible (it's how `cocoa.ll` works) but a small compiled `.m` linked in is more
maintainable for UIKit's larger surface — an open decision (§11).
- **Android — `runtime/android/ndk.ll` + a Kotlin/Java `Activity` host.** The
native code is a `.so` loaded by an `Activity`; the window is an
`ANativeWindow`/`Surface` obtained via `GameActivity`/NDK, GPU via EGL + GL ES.
Frames are driven by Choreographer through JNI into `ludic_frame`.
- **Touch input changes the input seam.** `win_poll()` returns a single `int`
keycode today ([emit_intrin2.ludic:41](selfhost/emit_intrin2.ludic:41),
[core.ludic:217](runtime/native/core.ludic:217)) — insufficient for touch, which
needs `(x, y, phase, id)`. Options: (a) a parallel `win_poll_touch() -> ptr`
draining an event queue, or (b) widen the input model to a small event struct for
all platforms. This is the one place mobile forces a decision above the window
seam. Proposed: add touch as a **separate** seam so keyboard platforms stay
untouched and byte-identical.
Everything above the seam — framebuffer, PNG sprites, TrueType, retained UI — is
portable Ludic and compiles unchanged.
---
## 7. Extension M4 — GPU rendering via `extern fn` (the headline)
Today **all** drawing writes into one CPU framebuffer: `rt_fb`, a
`words(320*240)` buffer of `0x00RRGGBB` i32 pixels
([core.ludic:23](runtime/native/core.ludic:23)), written by every primitive
(`rt_clear`/`rt_fill_rect`/glyphs/`rt_blend_px`/`tt_blit`/UI) and handed whole to
`win_present`. `cocoa.ll` blits it through CoreGraphics —
`CGBitmapContextCreate`→`CGImage`→`CGContextDrawImage` inside `@ludic_drawRect`
([cocoa.ll:94](runtime/native/cocoa.ll:94)). There is no GPU context anywhere.
Because **`extern fn` already exists**, binding the GPU is ordinary runtime code —
no new language feature, no new intrinsic:
```ludic
# doc-check: skip — runtime/native/gfx3d.ludic, illustrative
extern fn gl_gen_textures(n: int, out: ptr) -> void = "glGenTextures"
extern fn gl_tex_image_2d(t: int, w: int, h: int, px: ptr) -> void = "gl_tex_image_2d"
extern fn gl_draw_elements(mode: int, count: int, ty: int, idx: ptr) -> void = "glDrawElements"
```
Two phases, additive:
1. **Framebuffer-as-texture (drop-in).** Keep the entire 2D stack. `rt_present`
([core.ludic:103](runtime/native/core.ludic:103)) uploads `rt_fb` as one texture
and draws a full-screen quad. The `win_present(fb,w,h)` signature is unchanged;
only the pixel-delivery core of the platform file differs (texture upload instead
of CoreGraphics blit). This is the minimum viable GPU path and gets mobile on
screen with zero changes above the seam.
2. **True GPU 3D (additive).** Geometry goes straight to GL/Metal via `gfx3d.ludic`
`extern fn` calls; the CPU framebuffer is reused only for the 2D UI overlay,
composited as a texture on top. New GPU-draw entry points live in `gfx3d.ludic`
as `extern fn`s — the five-function window protocol does **not** widen.
Language-level cost is narrow and already scoped by the roadmap:
- **`f32`** (roadmap gate G-04) for vertex/matrix data — the *only* hard language
dependency ([LUANTI-ROADMAP.md:1087](LUANTI-ROADMAP.md:1087)).
- Optional vector operator overloading for `v3f`/`m4` ergonomics (G-29,
[:1107](LUANTI-ROADMAP.md:1107)) — a "nicer, not necessary."
The roadmap's own decision is explicit: FFI over IR-per-API, because "`cocoa.ll`
is 327 lines for *five* window functions — OpenGL has hundreds of entry points"
([LUANTI-ROADMAP.md:1375](LUANTI-ROADMAP.md:1375)).
---
## 8. Extension M5 — packaging, SDKs, and signing
The current driver is one `clang` call ([main.ludic:130](selfhost/main.ludic:130))
producing a bare binary. Mobile output is a bundle, and this is where most
real-world friction lives — it is deliberately the *last* phase.
- **iOS.** Cross-compile with the iPhoneOS SDK sysroot → an executable, wrap in an
`App.app` bundle with an `Info.plist`, `codesign` with a development identity,
install to simulator/device. Simulator is the cheap inner loop
(`aarch64-apple-ios-simulator`); device needs a provisioning profile. ludicc
should emit the binary and shell a packaging step (or emit a manifest a small
script consumes), not learn Xcode's project format.
- **Android.** Cross-compile with the NDK → `libapp.so`, drop it into a minimal
Gradle/Kotlin `Activity` shell, build the `.apk`/`.aab`, sign with a keystore.
The `Activity` is fixed boilerplate that ships in the repo (`runtime/android/`),
parameterized by app name/id.
- **Keep the compiler out of it.** Both flows are "produce native code + assemble a
package around it." The compiler's job ends at the object/`.so`; a `--package`
step or an external `build-mobile.sh` owns the bundle. This mirrors how ludicc
already drives clang without becoming a build system.
---
## 9. Lowering / build summary
| Construct | Reduces to |
|---|---|
| `--target <triple>` (M1) | a triple/datalayout line in `emit_header` + a `size_t`-width choice + target-conditional link command |
| OS-owned loop (M2) | emit `ludic_boot`/`ludic_frame`/`ludic_alive`/`ludic_teardown`; host/headless get a synthesized `@main` calling them |
| window shim (M3) | one `.ll`/shim per OS implementing the same five `win_*` intrinsics; no compiler change |
| touch input (M3) | a **new, separate** input seam (`win_poll_touch`), so keycode platforms stay byte-identical |
| framebuffer→texture (M4.1) | `rt_present` uploads `rt_fb` as a texture + full-screen quad; `win_present` signature unchanged |
| GPU 3D (M4.2) | `extern fn` calls in `runtime/native/gfx3d.ludic` — data in `prog`, zero compiler edits, needs only `f32` |
| packaging (M5) | binary/`.so` unchanged; an external `--package`/script builds `.app`/`.apk` and signs |
No new allocator, no new dispatch, no per-API intrinsics. The game and the 2D
graphics stack compile identically for every target; only head declarations, the
entry-point shape, the linked platform file, and packaging vary.
---
## 10. Suggested implementation phases
Each is independently shippable and testable, matching how the repo phases work.
- **M0 — target axis** (M1) + **revive the OS-owned loop** (M2). *Do these first
and together* — they're the shared compiler plumbing, they un-break the existing
web target (proving the frame-loop split against `run.mjs`/`test.sh` before any
mobile SDK is involved), and they need no mobile toolchain. This is the floor.
- **M1 — iOS simulator, framebuffer-as-texture** (M3 iOS shim + M4.1). First pixels
on a phone, GL/Metal binding proven, no signing/device friction yet.
- **M2 — iOS device** (M5 iOS packaging + signing).
- **M3 — Android** (M3 Android shim + M4.1 + M5 Android packaging), reusing every
M0 change.
- **M4 — `f32` + GPU 3D** (M4.2), gated on roadmap G-04; the additive 3D path over
`gfx3d.ludic`.
- **M5 (later) — touch-input model** hardening (M3), gesture/multitouch, once a real
app exercises it.
M0 is the honest prerequisite and the highest-leverage work — it serves three
targets and revives a fourth. M1 is the first thing anyone can *see*.
---
## 11. Open decisions
1. **Loop selection:** does `--target ios/android/wasm` *imply* the external loop,
or is there an explicit `--loop=external` flag? (Proposed: implied by target,
with the flag as an override for headless testing.)
2. **iOS shim language:** hand-written `.ll` against `objc_msgSend` like `cocoa.ll`,
or a small compiled `.m`? (Proposed: `.m` — UIKit's surface is too large for
maintainable IR, and Metal setup is verbose.)
3. **Touch seam shape:** a separate `win_poll_touch` queue, or a unified event
struct replacing the keycode `win_poll` on all platforms? (Proposed: separate,
to keep desktop/web byte-identical.)
4. **GPU API baseline:** GL ES 3.0 everywhere (Android native, iOS via ANGLE/Metal
translation), or Metal on iOS + GL ES on Android from day one? (Proposed: GL ES
3.0 first for a single codepath; Metal later.)
5. **Android host:** ship a fixed Kotlin `GameActivity` in `runtime/android/`, or
generate it per app? (Proposed: fixed boilerplate, parameterized by name/id.)
6. **Packaging home:** a `--package` step inside ludicc, or an external
`build-mobile.sh`? (Proposed: external script; keep the compiler out of bundle
formats.)
7. **`size_t` for arm64:** confirm iOS/Android arm64 are LP64 (`i64`) in the width
table, and that the `ll_size_t` discipline covers every new size-taking call.
8. **Simulator arch:** how to handle `aarch64-apple-ios-simulator` vs. x86_64 sim on
Intel hosts in the target table.
---
*Companion to [COMPILING.md](COMPILING.md) (§ toolchain, the wasm frame-loop
split), [LANGUAGE.md §"Functions & FFI"](LANGUAGE.md:560) (`extern fn`), and
[LUANTI-ROADMAP.md](LUANTI-ROADMAP.md) (G-04 `f32`, G-28 GPU FFI, G-29 3D math).
Supersedes nothing until the M0 compiler work lands.*

505
NETWORKING-DESIGN.md Normal file
View file

@ -0,0 +1,505 @@
# Networking, from primitives up — a design doc
> **Status: N0–N6 all shipped.** The whole stack is implemented and self-hosted,
> and — unlike the original N0/N1 which linked C hosts — every phase now runs as a
> self-contained **pure-Ludic** program (no `.c`, no foreign host): a built-in
> loopback transport fills the seam, and each `examples/net_*.ludic` drives and
> asserts itself from its own `entry`. See `test.sh` (checks `net_echo` … `net_demo`)
> and `examples/net_demo.ludic` for a full RPC→authority→replicate→reconcile loop.
> clang remains only as the LLVM-IR assembler/linker (no C is compiled), the floor
> Rust and Swift stand on.
>
> _Historical note:_ **N0 + N1 shipped first; N2–N6 were design.** Two phases landed as `test.sh`
> checks. **N0 (transport seam):** `extern fn` now lowers end to end — a direct
> `@<sym>` call plus a `declare`, no networking logic in the compiler — so the whole
> transport is two externs (`net_send`/`net_poll`) a host fills. Proven by
> [`examples/net_echo.ludic`](examples/net_echo.ludic) sending four bytes through
> the loopback host in [`tests/net_c/loopback.c`](tests/net_c/loopback.c) and
> polling them back (`4 10 20 30 42`). **N1 (snapshot-to-buffer):**
> `world_size()`/`world_save(buf)`/`world_load(buf, len)` generalize `save()`/`load()`
> from a file to a caller-owned memory buffer — the same block layout via `memcpy` —
> so the whole ECS world round-trips through bytes. Proven by
> [`examples/net_snapshot.ludic`](examples/net_snapshot.ludic) +
> [`tests/net_c/snapshot_mod.c`](tests/net_c/snapshot_mod.c) (snapshot, mutate,
> restore → `50 7 50`). Both are byte-identical when unused, so the offline dividend
> (§8) holds. This is a companion to
> [EVENTS-DESIGN.md](EVENTS-DESIGN.md), [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md),
> and [SCENES-DESIGN.md](SCENES-DESIGN.md). Where the events work made Ludic
> *moddable*, this proposes making it *networked* — and it deliberately does **not**
> ship a multiplayer framework. Ludic is a language: it exposes the low-level
> mechanism (transport seam, world snapshot, generated serializers, ownership, a
> drivable sim) and a thin high-level *declarative* layer that lowers onto that
> mechanism, and it leaves the netcode *policy* (authority, prediction, relevancy)
> to the developer or a library. §14 lists the open decisions.
---
## 1. Thesis
Every networking model dies on one of two problems: **determinism** or **state
serialization**. Ludic already solves both, almost by accident.
- **Determinism** is designed in — seeded RNG, `fixed` (Q16.16) instead of floats,
byte-identical golden renders, and (as of [EVENTS-DESIGN EV6](EVENTS-DESIGN.md))
bounded, array-ordered event dispatch. A modded, event-driven Ludic game still
replays identically. That is exactly the property lockstep multiplayer needs, and
the reason Factorio's heavily-modded multiplayer stays in sync.
- **State serialization** already exists — `save()`/`load()` snapshot the *entire*
ECS World to a byte buffer ([`selfhost/emit_save.ludic`](selfhost/emit_save.ludic)),
and the world-table schema built for [EVENTS-DESIGN EV2](EVENTS-DESIGN.md) (prop →
field → offset) is exactly the descriptor you serialize against.
So networking is not a new subsystem. It is a **fourth lens on the event + world
layer** — the same layer modding used. And it obeys the same two-altitude rule as
everything else in Ludic:
> **Low-level is freedom; high-level is developer experience; they are the same
> feature at two altitudes.** `@Queries` lowers to a query loop, `scene` lowers to a
> machine, `@Public @OnSpawn` lowers to `emit`. Networking's high-level annotations
> lower to a transport seam, generated serializers, and a drivable sim — and the
> primitives stay exposed underneath for anyone the sugar doesn't fit.
The developer writes **one simulation**, declares *what* replicates, *who* owns
each entity, and *where* each handler runs — and never branches on `is_server()`
in ordinary code. The compiler lowers the declarations; a networking *runtime*
(the seam-filler, like `rt_*` for windowing) supplies the transport and the tick.
---
## 2. Two altitudes, one system
| Altitude | Who writes it | Surface |
|---|---|---|
| **High-level (DX)** | the developer, declaratively | `@Sync` (field/property/model), `@Owned`, `@Server`/`@Predicted`, directional remote events |
| **Lowering** | the compiler | per-model serializers, role-guarded dispatch, remote-event send/recv, ownership storage |
| **Runtime seam** | a networking library (blessed or custom) | binds the socket, sets `role`, drives the replication tick |
| **Low-level (freedom)** | power users, when the sugar doesn't fit | `net_send`/`net_poll`, `world_save`/`world_load`, generated `serialize_*`/`apply_*`, `owner()`, the drivable sim |
Everyone lives at the top row for normal games; the bottom row stays open for
someone building something no framework could express. The split that keeps this a
*language* and not a *framework*: **annotations and their lowering are the language;
the replication driver and the transport are a library.** It is precisely the
events story — `@On`/`emit` are the language, the *modding system* is library code —
applied again.
---
## 3. Research digest — the one idea to steal from each
| System | The transferable idea |
|---|---|
| **Quake / QuakeWorld** | The founding pattern: **client-side prediction + server reconciliation**, and delta-compressed snapshots against the last acked baseline. Predict locally, correct from the authority. |
| **Source (Valve)** | **Entity interpolation** (render remote entities slightly in the past, smoothly) paired with **lag compensation** (the server rewinds to the shooter's view for hit detection). Interpolation and rewind are two halves of one clock discipline. |
| **Unity NGO** (GameObject) | `NetworkVariable<T>` with **read/write permissions** + `OnValueChanged`; ownership as `OwnerClientId`. Also the **anti-pattern to avoid**: `IsServer`/`IsOwner` branching sprinkled through gameplay code. |
| **Unity Netcode for Entities** (ghosts) | The model Ludic is closest to: **replication is a compile-time property of components and fields** — `[GhostField]`, `[GhostComponent]`, `GhostOwner`, and `Predicted`/`Interpolated` ghost modes — with serializers *generated* from the ECS schema. |
| **Mirror / FishNet** | The community-ergonomic take: `SyncVar` with change **hooks**, and clean **directional RPCs** — `Command` (client→server) / `ClientRpc` (server→clients). |
| **GGPO / rollback** | Save state → predict → on misprediction **restore and re-simulate**. Its one hard requirement is *cheap, complete state snapshot/restore* — which Ludic already has in `save()`/`load()`. |
| **Factorio** | Fully **deterministic lockstep** for heavy mod multiplayer: only *inputs* cross the wire; the whole sim is reproduced. Proof that determinism (EV6) is the enabler, not a nicety. |
| **Photon Quantum** | A shipping product that *is* deterministic-ECS-rollback. Validates the exact combination — ECS + determinism + rollback — Ludic is already positioned for. |
| **Roblox** | The **local/remote split** (`BindableEvent` vs `RemoteEvent`), server-authority by default, and engine-replicated properties: "some state just replicates, and RPCs are directional events." |
Six **footguns** the survey warns against, to design *out* from the start:
1. **Role branching everywhere.** `if (IsServer)` scattered through gameplay is the
NGO readability tax. Fix: **role is a handler annotation** (`@Server`/`@Predicted`),
never a runtime branch in ordinary code.
2. **Float nondeterminism.** Lockstep breaks the instant the networked sim touches
`f32` across platforms. Fix: the determinism contract (§11) — the networked sim
stays `int`/`fixed`.
3. **Replicating pointers / heap refs.** A `ptr` field holds a machine-local
address; it cannot cross the wire. Fix: **the compiler rejects `@Sync` on a
non-POD-scalar field** — a checked guarantee, not a convention.
4. **Sending everything every tick.** Fix: `@Sync` is **opt-in at the field level**
(only marked fields replicate), plus change-driven dirty tracking (`@OnChange`,
[LIFECYCLE LC2](LIFECYCLE-DESIGN.md)) so an unchanged field costs nothing.
5. **Hidden authority.** Magic "the server decides" behavior is unclear and
unauditable. Fix: **explicit** `@Server`/`@Predicted`; unmarked code runs
everywhere by definition.
6. **Schema-less snapshots.** A raw state blob with no version desyncs silently on a
version mismatch. Fix: the **world-table schema is the versioned descriptor** the
serializer is generated against.
---
## 4. What Ludic already has
The substrate is unusually complete for an engine that has never networked:
- **A deterministic simulation** — seeded RNG, `fixed` math, ordered ECS iteration,
EV6-bounded event dispatch. Lockstep's precondition.
- **World snapshot/restore** — `save()`/`load()` serialize the whole World
([emit_save.ludic](selfhost/emit_save.ludic)); today to a file, trivially
retargetable to a memory buffer. Rollback's precondition.
- **A reflective world table** — `ludic_get`/`set`/`has`/`query`/`register_prop`
and the prop→field→offset schema (EV2/EV2b). The apply-and-serialize substrate.
- **An event bus with a foreign ABI and POD payloads** (EV0). Directional remote
events (RPCs) are one flag on this.
- **The `rt_*` seam pattern** — the compiler already emits calls to
`rt_init`/`rt_poll`/`rt_present` that a runtime library fills. Networking's
transport and role registers plug into the identical seam.
What is missing is small and named: a transport seam, snapshot-to-*buffer*,
generated per-field serializers, ownership storage, role-guarded dispatch, and a
developer-drivable loop. Each is a phase in §13.
---
## 5. The low-level primitives (the freedom layer)
Unopinionated, composable, host- or developer-owned. A power user builds any model
directly from these; the high-level layer (§6) is sugar over them.
| Primitive | Signature (sketch) | Enables |
|---|---|---|
| **Transport seam** | `extern fn net_send(peer: int, buf: ptr, len: int)` · `extern fn net_poll(buf: ptr, cap: int) -> int` | any model; host binds UDP (native) or WebRTC/WebSocket (wasm), or a loopback for tests |
| **World snapshot ↔ buffer** | `world_save(buf: ptr) -> int` · `world_load(buf: ptr, len: int)` | rollback, replication, join/resync — generalizes `save()`/`load()` off the filesystem |
| **Generated serializers** | `serialize_<Model>(e: entity, buf: ptr) -> int` · `apply_<Model>(e: entity, buf: ptr, len: int)` | per-model, touch only the `@Sync` fields; emitted from the schema |
| **Ownership** | `owner(e: entity) -> int` · `set_owner(e: entity, id: int)` | authority checks, per-entity owner metadata (an `@L_owner` array, like `@L_kind`) |
| **Role registers** | `is_server() -> bool` · `is_owner(e: entity) -> bool` · `local_id() -> int` | the runtime sets these; role-guarded dispatch reads them |
| **Drivable sim** | `tick_fixed()` · `tick_render()` · seed get/set | a developer-owned loop for prediction/rollback (also: replay, headless tests, AI) |
| **Remote-event serde** | `emit`-site serialize + `net_send`; inbound bytes rebuild + re-`emit` | RPCs |
Transport is the one that needs *no* language work at all — a developer can already
`extern fn` a socket library and link it, exactly as the windowing layer is linked.
The language's genuine contributions are snapshot-to-buffer, the generated
serializers, ownership storage, and the drivable loop.
```ludic
# doc-check: skip — the freedom layer, a hand-rolled replication tick
entry {
while running() {
if is_server() {
for (Transform) in query [Transform, Owned] {
let n = serialize_Player(self(), buf) # compiler-generated
net_send(ALL, buf, n) # developer's transport
}
} else {
let n = net_poll(buf, CAP)
if n > 0 { apply_Player(target_of(buf), buf, n) }
}
tick_render(); present()
}
}
```
This *works*, but it is deliberately not how most games should be written — it puts
serialization and role branching in the developer's face. That is what §6 fixes.
---
## 6. The high-level DX layer (the default)
The developer declares **what** replicates, **who** owns, and **where** handlers
run. No serialization, no transport, no `is_server()` in ordinary code.
### 6.1 `@Sync` — what replicates, at three granularities
Replication is **opt-in at the field level**: a field crosses the wire only when it
is explicitly marked. There is no `@NoSync` — the surface is purely additive.
Two independent switches, and **both must be on** for a field to replicate:
1. **A field is *replicable*** iff it is `@Sync`-marked — directly
(`@Sync hp: int`), or via `@Sync property P { … }` (a shorthand that marks
*every* field of `P` replicable). *Only marked fields — never all-by-default.*
2. **A component *participates* in a model** iff the model marks it `@Sync`
(`@Sync Transform` inside the `model`). Participation is decided **per model
use-site**, so the same property syncs in one model and not another.
A field of an entity replicates **iff it is replicable AND its component
participates in that entity's model.**
```ludic
# doc-check: skip — the three levels
@Sync property Position { x: int, y: int } # every field of Position is replicable
property Health { @Sync hp: int, max: int } # only hp is replicable; max never is
property Transform { @Sync x: int, @Sync y: int, angle: int } # x, y replicable; angle not
@Owned model Player { # entities carry a network owner
@Sync Transform # participates → replicates x, y (not angle)
@Sync Health # participates → replicates hp (not max)
@Sync Position # participates → replicates x, y
}
model Prop { # a non-owned decoration
Transform # not @Sync here → Transform does NOT replicate — the
# "non-synced Transform sometimes" case, for free
}
```
- **Checked, not silent.** `@Sync` on a `ptr`/non-POD-scalar field is a **compile
error** ("networked fields must be POD scalars" — footgun 3). A model that
`@Sync`es a component with *zero* replicable fields is a **compile warning**
(participation that replicates nothing).
- **Per-field direction** rides the same annotation as an argument, mirroring how
`@Queries(these:…, on:…)` takes args: `@Sync(to: owner) hp: int` replicates a
field only to the entity's owner (Unity's `SendToOwner`). Default is `to: all`.
### 6.2 Roles — where a handler runs
The role is a **declarative annotation on the handler**, never a runtime branch.
Unmarked code is the shared, deterministic simulation and runs everywhere.
| Annotation | Runs where | Meaning |
|---|---|---|
| *(none)* | everywhere | shared, deterministic simulation |
| **`@Server`** | the authority only | server-authoritative logic; clients receive the result via `@Sync` |
| **`@Predicted`** | the owning client (speculatively) **and** the server (authoritatively) | responsive local control, auto-reconciled against the server |
`@Predicted` is **explicit** — the developer opts an owned entity's control handlers
into prediction; the language does not silently predict. The name states the netcode
role (owner-predicts + server-authoritative + reconcile), not the machine, and
matches Unity's `GhostMode.Predicted` so the concept transfers.
`@Interpolated` — how a *non-owned* synced component is smoothed between snapshots on
a remote client — is a **presentation** concern on the component, kept separate from
these sim-handler roles rather than muddying them.
### 6.3 Ownership
```ludic
# doc-check: skip
@Owned model Player { @Sync Transform; @Sync Health } # every Player entity has a network owner
```
`@Owned` gives the model an owner slot (the `@L_owner` array); `owner(e)` /
`set_owner(e, id)` read and assign it (the authority assigns). `is_owner(e)` and
`@Predicted` dispatch read it. Ownership gates who may write `@Sync(to: owner)`
fields and who runs `@Predicted` handlers.
### 6.4 RPCs are directional remote events
RPCs are the event bus with a direction flag — no new concept:
```ludic
# doc-check: skip
@ToServer event Fire { dir: int } # client → server (a request)
@ToClients event Boom { x: int, y: int } # server → clients (a broadcast)
@Server @On(Fire) handler DoFire { spawn Bullet { dir: Fire.dir } } # authority handles the request
@On(Boom) handler Vfx { spawn Explosion { x: Boom.x, y: Boom.y } } # every client reacts
```
`@ToServer`/`@ToClients` mark an `event` remote; the compiler serializes its POD
payload (already flat — [EVENTS-DESIGN EV0](EVENTS-DESIGN.md)) and routes it through
the transport seam in the declared direction, re-`emit`ting it on the far side into
the ordinary event dispatch.
### 6.5 The whole game, high-level
```ludic
# doc-check: skip — read top to bottom: you always know where each line runs
program Shooter {
@Sync property Position { x: int, y: int }
property Health { @Sync hp: int, max: int }
@Owned model Player { @Sync Position; @Sync Health }
model Bullet { Position }
handler Physics phase FixedUpdate { … } # no tag → shared, identical everywhere
@Predicted handler Move phase Input { … } # owner predicts, server authoritative
@Server handler Death phase Update { … } # authority only; clients get the result via @Sync
@ToServer event Fire { dir: int }
@Server @On(Fire) handler DoFire { spawn Bullet { … } }
}
```
No `is_server()`, no `net_send`, no serializer — yet every line's role is legible,
and every replicated field is explicitly opted in.
---
## 7. Lowering summary
Everything above reduces to the §5 primitives, gated so an un-networked build is
unchanged:
| High-level | Lowers to |
|---|---|
| `@Sync` field / `@Sync C` in a model | a per-model `serialize_<M>` / `apply_<M>` over the replicable-and-participating fields, + a `sync manifest` a runtime reads |
| `@Sync(to: owner)` | a field tag in the manifest; the serializer branches on `owner(e) == peer` |
| `@Owned` | an `@L_owner` array + `owner()`/`set_owner()`, like `@L_kind` |
| `@Server` / `@Predicted` handler | the handler's dispatch wrapped in a role guard the runtime's role register drives (the `rt_*` seam pattern) |
| `@ToServer` / `@ToClients event` | payload serialize + `net_send(direction, …)` at the `emit` site; inbound bytes rebuild + re-`emit` |
| `world_save`/`world_load` to buffer | the existing `save()`/`load()` snapshot machinery, retargeted from a file handle to a memory buffer |
| drivable `tick_fixed`/`tick_render` | the phase runners the compiler already generates for the frame loop, exposed as callables when a game owns its `entry` loop |
No heap, no hidden runtime beyond the honestly-named transport/role seams a
networking library fills — the same relationship windowing already has.
---
## 8. The offline dividend
Because these are **opt-in-cost annotations** — serializers *generated*, nothing
*run* until a networking runtime is spliced — a build with no runtime is
**byte-identical to single-player**, and every role guard collapses to "run here."
You build the game offline, drop in a runtime, and the same annotated code starts
replicating. That is Unity's "offline mode adjustable," achieved by the same
opt-in-cost invariant the whole event system already holds.
---
## 9. The one genuinely hard corner
Determinism holds beautifully for `int`/`fixed` simulations, which makes lockstep
and rollback cheap. It **breaks for `f32` across platforms** — so **3D/voxel +
lockstep stays the hard corner** (3D wants floats; the Luanti analysis flagged that
`fixed` saturates at ±32768). No language sleight-of-hand fixes this; the
determinism contract (§11) states it plainly, and a developer choosing lockstep for
a 3D game has to accept it (or choose state replication, §10's other branch, where
per-frame determinism is not required).
---
## 10. Two model families, both reachable — neither built in
The language commits to **neither**; both are library policy over the §5 primitives.
- **Deterministic lockstep / rollback** — exchange only inputs; reproduce the sim;
on misprediction, `world_load` a snapshot and re-`tick_fixed`. Plays to Ludic's
determinism, and GGPO-cheap because snapshot/restore already exists. Best for
2D/integer/fixed games.
- **State replication** — the authority `world_save`s (or per-`@Sync` serializes),
delta-encodes against the last acked snapshot per peer, ships the diff; peers
`apply_*` it and interpolate/predict. Heavier, but needed when the sim can't be
deterministic (float physics, 3D).
A **blessed reference runtime** (§13, N6) can ship one of these so `@Sync` games
work out of the box — the way [`tests/mod_c/mod.c`](tests/mod_c/mod.c) proved the
event ABI — while the seams stay open for others.
---
## 11. The determinism contract (what the language must guarantee)
For a developer to *trust* lockstep, the language must promise, document, and where
possible *enforce*:
1. **`fixed`/`int` math is bit-identical across platforms.** The networked sim must
avoid `f32` (footgun 2). *(Enforcement: at least a documented rule; ideally a
`@Sync`/`@Server`-reachable-code float lint.)*
2. **ECS iteration order is stable** — query order is declaration/id order, and
EV6 already fixes event-dispatch order. No hash-map iteration in the sim path.
3. **RNG is deterministic from a shared seed** — `seed()` exists; the seed must be
synchronized at session start (library policy) and never re-seeded from
wall-clock mid-sim.
4. **Networked components are POD scalars** — no `ptr`/heap fields cross the wire
(footgun 3). *Enforced:* `@Sync` on a non-scalar field is a compile error.
5. **Entity ids agree across peers** — lockstep gets this free from determinism;
replication needs an id-mapping table (library policy).
This contract is the language's real networking responsibility. Most of it is
*already true*; the work is stating and enforcing it, not inventing it.
---
## 12. Design principles
1. **Mechanism in the language, policy in the library.** Expose serializers,
transport seam, ownership, snapshot, drivable sim. Never bake in authority,
prediction, or matchmaking.
2. **Role is declared, not branched.** `@Server`/`@Predicted` on handlers; unmarked
code runs everywhere. No `is_server()` in ordinary gameplay.
3. **Replication is explicit and opt-in.** Only `@Sync`-marked fields cross the
wire; participation is decided per model. Nothing replicates by surprise.
4. **Opt-in cost.** Un-networked builds are byte-identical; the sim runs offline
with the same code.
5. **Determinism is a promise the language keeps.** Enforce the POD-scalar rule;
document the float/iteration/seed rules; keep the sim reproducible.
6. **Two altitudes, always.** The high-level lowers to primitives that stay
callable. The sugar is the default; the freedom layer is never removed.
7. **Reuse, don't reinvent.** Snapshot = generalized `save()`; RPC = directional
`event`; serializer = generated from the EV2 schema; role seam = the `rt_*`
pattern. Networking is the fourth lens, not a parallel stack.
---
## 13. Suggested implementation order
Each phase is independently shippable and testable, matching how the repo phases
work (and how EVENTS-DESIGN sequenced EV0–EV7).
- **N0 — transport seam + loopback. ✅ SHIPPED.** The `net_send`/`net_poll` extern
seam and a loopback host stub; an echo test. The floor; needed almost no compiler
work — just finishing `extern fn`: a call lowers to a direct `@<sym>` call and the
header emits a matching `declare`, so any C/Rust/Zig library (a socket, here the
loopback) binds through the same seam windowing uses. `find_extern` (emit_core),
the extern branch in emit_expr's call path, `emit_extern_decls` (emit_head).
([`examples/net_echo.ludic`](examples/net_echo.ludic),
[`tests/net_c/loopback.c`](tests/net_c/loopback.c) → `4 10 20 30 42`.)
- **N1 — snapshot-to-buffer. ✅ SHIPPED.** Generalized `save()`/`load()` to a memory
buffer: `world_size()` (exact snapshot bytes), `world_save(buf) -> int`,
`world_load(buf, len)`. The same fixed block list (entity count, freelist, alive,
kind, vars, per-component `@S_`/`@H_`) now feeds a file (fwrite/fread) *or* a buffer
(memcpy over a threaded i64 offset), chosen by `g_snap_mode` in emit_save.ludic;
no rt_ hook (the ECS world only). The rollback/replication substrate.
([`examples/net_snapshot.ludic`](examples/net_snapshot.ludic),
[`tests/net_c/snapshot_mod.c`](tests/net_c/snapshot_mod.c) → `50 7 50`.)
- **N2 — `@Sync` codegen. ✅ SHIPPED.** The three-level annotations → generated
per-model `serialize_<M>`/`apply_<M>` + by-kind dispatchers (`ludic_serialize`/
`apply`/`sync_size`, and the `serialize`/`apply`/`sync_size` builtins); the
POD-scalar compile error and the empty-participation warning. The declarative
core. ([`examples/net_sync.ludic`](examples/net_sync.ludic) → `12 3 4 50 999`,
emit in [`selfhost/emit_net.ludic`](selfhost/emit_net.ludic).)
- **N3 — ownership. ✅ SHIPPED.** `@Owned` + the `@L_owner_arr` array +
`owner()`/`set_owner()`/`is_owner()`; owners are part of the world snapshot.
([`examples/net_owner.ludic`](examples/net_owner.ludic) → `-1 7 0 1`.)
- **N4 — remote events (RPCs). ✅ SHIPPED.** `@ToServer`/`@ToClients` on `event`s →
payload serialize (`[event id][fields]`) + directional `net_send` + `net_pump()`
far-side re-`emit`. ([`examples/net_rpc.ludic`](examples/net_rpc.ludic) → `0 8`.)
- **N5 — roles + drivable sim. ✅ SHIPPED.** `@Server`/`@Predicted` role-guarded
dispatch driven by the `@L_role` register (`set_role`/`is_server`/`local_id`);
the opt-in `entry`-owns-the-loop with `tick_fixed()`/`tick_render()`. Together
these let prediction/rollback be written in developer/library code.
([`examples/net_roles.ludic`](examples/net_roles.ludic) → `1 102`.)
- **N6 — a blessed reference netcode runtime. ✅ SHIPPED.** A Ludic library
([`examples/net_rt.ludic`](examples/net_rt.ludic)) — server-authoritative state
replication over the primitives — plus a full end-to-end demo, proving the seams
the way the C mod proved the event ABI, but in pure Ludic over the built-in
transport. Library policy, swappable for lockstep+rollback.
([`examples/net_demo.ludic`](examples/net_demo.ludic) → `5 999 5`.) A built-in
loopback transport (N0) means all of this needs **no foreign code at all**.
N0–N2 deliver "state can be declared, serialized, and moved." N3–N4 add ownership
and RPCs. N5 unlocks prediction. N6 is a batteries-included default that others can
replace. The **determinism contract (§11)** is cross-cutting — documented from N0,
enforced incrementally.
---
## 14. Open decisions
1. **Field direction vocabulary.** `@Sync(to: owner)` / `@Sync(to: all)` confirmed
in spirit; is `to:` the right key, and do we also want `to: server` (a field only
the authority reads)? How does per-field direction interact with `@Predicted`?
2. **Blessed runtime, or seams only?** Events chose "seams + reference mod, bless
nothing." Networking's DX may justify shipping one reference runtime (N6). One,
or none?
3. **Authority default.** Server-authoritative with `@Predicted` opt-in is the safe,
Unity-ish default. Confirm, or keep the language authority-neutral and leave even
that to the runtime?
4. **Drivable loop shape.** Whole-frame `tick()` vs the `tick_fixed()`/`tick_render()`
split; how a developer-owned `entry` loop coexists with scenes, the `rt_*` hooks,
and the auto-loop (opt-in via presence of an `entry` block?).
5. **Snapshot granularity.** Full `world_save` vs per-`@Sync` serialize vs a
generated delta between two snapshots — which does the language provide, and which
is library work?
6. **Float determinism enforcement.** A documented rule only, or a real lint that
flags `f32` reachable from `@Server`/`@Predicted`/`@Sync` code paths?
7. **Ownership at component granularity.** Unity's DOTS allows per-component owner
send-rules. Is `@Owned` per-*entity* enough, or do we need per-component owners
(a real complexity jump)?
8. **Networking substrate for the remote half of EVENTS EV7.** This doc's directional
remote events (N4) *are* the local/remote split EVENTS-DESIGN EV7 deferred for
"no networking substrate." N4 is that substrate — the two docs meet here.
---
*Companion to [EVENTS-DESIGN.md](EVENTS-DESIGN.md) (remote events are directional
events; serializers reuse the EV2 world-table schema; EV7's deferred local/remote
split lands here as N4), [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md) (`@OnChange`/LC2
is the dirty-tracking primitive for delta replication), and
[SCENES-DESIGN.md](SCENES-DESIGN.md). Supersedes nothing until the compiler work in
§13 lands.*

354
SCENES-DESIGN.md Normal file
View file

@ -0,0 +1,354 @@
# Scenes, expanded — a design doc
> **Status: S0 shipped; S1–S6 are design.** The base construct — `scene` /
> `layer` / `on enter` / `on exit` / `become`, lowered to the implicit machine of
> §3 and §9 — is implemented and tested ([`examples/scenes.ludic`](examples/scenes.ludic),
> a `test.sh` check). The extensions in §4–§8 (scene-owned entities, richer
> layers, the overlay stack, scene-local state, transition parameters) are still
> design targets. This document reaches deliberately past the thin sketch so we
> can decide the shape before building each one. §11 lists the open decisions.
---
## 1. Where we are
A Ludic program is almost always several mutually-exclusive states — a title
screen, the overworld, a battle, a pause menu. Two ways to write that exist in
the language today, and a third is sketched:
| Approach | Status | Cost |
|---|---|---|
| Mode register consulted at the top of every handler (`if reg(R_MODE) == …`) | works | a guard re-read per handler per frame; state is a magic number; nothing scopes to it |
| `machine`/`state`/`become` over a register | works | dispatch on the register each frame; still one flat register, no per-state handlers or lifecycle |
| `scene`/`layer`/`on enter`/`on exit` | **sketch only** | — |
The sketch ([`examples/scenes.ludic`](examples/scenes.ludic)) specs:
- Exactly **one scene active**; the `start` scene runs first.
- A scene's handlers run only while it is active; handlers outside any scene are
global.
- **Layers group handlers; declaration order is draw order** — within a phase,
globals first, then the active scene's layers in written order.
- `on enter` / `on exit` are lifecycle hooks (not phases).
- `become Name` runs the old scene's `on exit`, switches, runs the new `on enter`
— two direct calls and a store, no dispatch table.
That's a good spine. The problem is it's specced as **sugar over a mode
register**: it tidies the syntax but adds little the register didn't already
have. The compiler knows *much* more at a scene boundary than a register does,
and this doc is about spending that knowledge.
---
## 2. Design principles
1. **The scene boundary is a compile-time fact — use it.** The set of handlers,
layers, and owned state for each scene is known statically. Transitions should
be direct calls and a single store, never a table walk. (The sketch already
promises this; the extensions must preserve it.)
2. **Structure, not registers.** Anything you'd track with a hand-managed
register alongside the mode — which entities belong to this state, which layers
are drawn, what's paused — should be expressible *as* scene structure and
enforced by the compiler.
3. **Reuse the machinery we already have.** Layers pausing, scenes tearing down
their entities, and hooks firing are all expressible in terms of
`enable`/`disable` (cheap flag flips), `despawn`, and the lifecycle-hook
lowering. Scenes should *compose* those, not introduce a parallel runtime.
4. **One active-scene path stays hot; overlays are the exception, not the rule.**
The common case (one full-screen scene at a time) must lower to the cheapest
possible dispatch. Richer shapes (a pause menu over a frozen world) are opt-in
and pay only for what they use.
---
## 3. Core model (firmed up from the sketch)
```ludic
# doc-check: skip — illustrative
scene Title start {
on enter { ui_open(UI_Menu) }
on exit { ui_visible(UI_Menu, 0) }
layer Main {
handler Choose phase Update {
if ui_clicked(UI_NewGame) { become Overworld }
}
}
}
scene Overworld {
on enter { spawn_party() }
layer World { handler Move phase Update { … } }
layer Hud { handler Draw phase Render { … } }
}
```
Unchanged from the sketch, made precise:
- **Scenes number themselves by declaration order**, exactly like `machine`
states — `Title` is `0`, `Overworld` is `1`. The active scene lives in one
implicit register (`__scene`). This makes `scene` a `machine` the compiler
writes for you, which is the right mental model and the right lowering.
- **A layer handler may not use phase `Start`.** `Start` runs once at boot,
before any scene is entered; scene setup goes in `on enter`.
- **Global handlers still run every frame**, before any scene's layers, in every
phase. A scene's layers run only while it is active.
Everything below is new.
---
## 4. Extension E1 — scene-owned entities (scoped lifetime)
The single biggest thing a mode register cannot do: **own the entities that only
make sense in this state, and tear them down automatically on exit.** Today a
battle scene spawns combatants in `on enter` and must remember to despawn every
one in `on exit` — miss one and it leaks into the overworld.
Proposal: entities spawned *by a scene's handlers or `on enter`* are tagged with
that scene, and `on exit` despawns them by default.
```ludic
# doc-check: skip
scene Battle {
on enter { spawn Foe; spawn Foe; spawn Foe } # tagged @Battle
# on exit: implicit `despawn all @Battle` — no manual cleanup
layer World { handler Fight phase Update { … } }
}
```
- Implemented as an implicit **scene tag** (a `{Battle}`-style kind bit) added at
`spawn` time while a scene is active, plus a generated `despawn`-by-tag in the
synthesized `on exit`. Reuses the existing tag-filter and despawn-hook
machinery — no new runtime.
- **Opt out** for entities that should outlive the scene: `spawn Foe persist` (or
spawn it from a global handler). Persisted entities keep their data across the
transition, matching how `disable` keeps field data.
- Composes with `@OnDespawn(Model)`: the destructor hook fires for each
scene-owned entity as it's torn down, so `drop_loot`-style cleanup still runs.
**Open:** does a re-`become Battle` get fresh entities (fresh tag generation) or
resume the old ones? Default: fresh. See §9.
---
## 5. Extension E2 — layers are more than draw order
The sketch uses layers only to order `Render`. Layers are the natural unit for
three more things, all built on the existing `enable`/`disable` flag flips:
1. **Per-layer toggle.** `disable Hud` / `enable Hud` flips one flag; the layer's
handlers stop running and drawing. This is `disable Handler` generalized to a
named group — same one-flag-flip cost.
2. **Pause vs. tear-down.** A layer can keep drawing while its *update* handlers
are suspended:
```ludic
# doc-check: skip
scene Overworld {
layer World { handler Move phase Update { … } handler Draw phase Render { … } }
layer Hud { handler DrawHud phase Render { … } }
}
```
When a pause menu opens over the Overworld (see E3), `World`'s `Update`
handlers suspend but its `Render` handler still paints the frozen world behind
the menu. Today that requires a `if !paused` guard in every update handler;
with layers it's structural.
3. **Layer lifecycle hooks.** `on show` / `on hide` per layer, mirroring scene
`on enter`/`on exit`, for the toggle points. (Naming TBD — could fold into the
`@OnEnable`/`@OnDisable` annotations, which already exist for properties.)
---
## 6. Extension E3 — the scene *stack* (the headline)
The sketch says "exactly one scene is active." That's the right default and the
wrong constraint. The states a mode register handles *worst* are the ones that
**overlay without replacing**: a pause menu over live gameplay, a dialog box, an
inventory screen, a confirmation prompt. With one register you either lose the
underlying state or hand-roll a "previous mode" variable and restore it.
Proposal: keep "one *base* scene," but allow scenes to be **pushed as overlays**.
```ludic
# doc-check: skip
scene Overworld {
layer World { handler Move phase Update { … } handler Draw phase Render { … } }
layer Hud { handler DrawHud phase Render { … } }
on enter { … }
handler PauseKey phase Input { if pressed(KEY_ESC) { push Pause } }
}
scene Pause overlay { # `overlay` = pushed, not swapped
on enter { dim_backdrop() }
layer Menu {
handler Nav phase Update {
if pressed(KEY_ESC) { pop } # back to Overworld, untouched
}
handler Draw phase Render { ui_render() }
}
}
```
- `push Name` runs `Name`'s `on enter` and makes it the top scene **without**
running the base scene's `on exit`. `pop` runs the overlay's `on exit` and
returns to whatever was beneath.
- **Update belongs to the top of the stack; render walks the whole stack bottom
to top.** So `Pause`'s `Menu` layer draws over `Overworld`'s frozen `World` and
`Hud`. This is the default that makes pause menus "just work." An overlay that
should let the layer beneath keep updating opts in with `push Name passthrough`.
- **The stack is a small fixed-capacity array of scene ids** (say 8) in a
compiler-owned buffer — not heap, not a linked structure. `push`/`pop` are an
index bump and an `on enter`/`on exit` call. Depth overflow is a compile-time
or trap decision (§9).
- `become` still exists and still means "swap the base scene" (full `on exit` →
`on enter`, stack cleared). `push`/`pop` are the overlay verbs. Keeping the two
distinct is what lets the common single-scene path stay a single register.
This is the extension that turns `scene` from "nicer mode register" into
something with no clean equivalent in the register world.
---
## 7. Extension E4 — scene-local state
A scene almost always has state that exists only while it's active — a battle's
turn counter, a menu's cursor index. Today that's a global register that other
scenes could stomp. Proposal: **`var` / `const` declared inside a `scene` is
scoped to it**, storage shared across scenes that are never simultaneously active
(the compiler can overlap their storage since only one base scene runs at a
time — an arena-per-scene, or a union).
```ludic
# doc-check: skip
scene Battle {
var turn = 0 # visible only inside Battle; reset by `on enter` if desired
layer World { handler Step phase Update { turn += 1 } }
}
```
- Reads/writes lower to a fixed offset in the scene's state block, no register
indirection.
- Overlay scenes (E3) that *can* be live simultaneously with their base cannot
share storage — the compiler keeps their blocks distinct. Base scenes that
never coexist share.
---
## 8. Extension E5 — parameterized transitions, and the reserved annotations
**Parameters on transitions.** `become`/`push` can carry arguments that the
target's `on enter` binds — so a battle knows which foes, a dialog knows which
line:
```ludic
# doc-check: skip
scene Battle {
on enter (foe_kind: int, count: int) { for i in 0 .. count { spawn_foe(foe_kind) } }
}
# elsewhere:
become Battle(FOE_GOBLIN, 3)
```
Lowers to argument stores into the scene's state block (E4) immediately before
the `on enter` call. No variadic runtime; the arity is checked at compile time.
**The already-reserved annotation form.** [LANGUAGE.md:374](LANGUAGE.md:374)
reserves `@OnEnter` / `@OnExit` as handler annotations "waiting on scene support."
This doc adopts them as the annotation spelling of `on enter` / `on exit`,
mirroring how `@OnStart` is the annotation form of `phase Start`:
```ludic
# doc-check: skip
@OnEnter(Battle) handler Setup { … } # == Battle's `on enter`
@OnExit(Battle) handler Teardown { … }
```
Both spellings desugar to the same synthesized scene-lifecycle function; a scene
may use either, not both, for a given hook.
**`reads`/`writes` + scenes (forward-looking).** The `reads`/`writes` clauses are
parsed but unconsumed ([LANGUAGE.md:717](LANGUAGE.md:717)). Once an analysis pass
exists, a scene's layers declare which state they touch, and the scheduler can run
independent layers of the active scene in parallel within a phase — the scene
boundary gives the pass a natural scope to reason about. Noted as a destination,
not part of the first cut.
---
## 9. Lowering summary
Everything above reduces to existing runtime concepts:
| Construct | Lowers to |
|---|---|
| active base scene | one implicit register `__scene`, states numbered by decl order — literally a compiler-written `machine` |
| `become Name` | `on exit` call · `set __scene` · `on enter` call (two direct calls + store, as the sketch promises) |
| scene layers in a phase | the phase scheduler, after global handlers, dispatches on `__scene` to that scene's layer handlers in declaration order |
| `push`/`pop` (E3) | fixed-capacity scene-id array + index; render walks it, update reads its top |
| scene-owned entities (E1) | implicit kind tag at `spawn`; generated `despawn`-by-tag in synthesized `on exit`; reuses despawn hooks |
| layer toggle / pause (E2) | the same one-flag-flip as `disable Handler`, keyed per layer |
| scene-local `var` (E4) | fixed offsets in a per-scene state block; non-coexisting scenes share storage |
| transition args (E5) | arg stores into the state block before the `on enter` call |
| `@OnEnter`/`@OnExit` (E5) | the same synthesized lifecycle functions as `on enter`/`on exit` |
No heap, no dispatch tables, no new allocator. The active-scene path is a
register read and a static branch; the stack adds a small array only for programs
that push overlays.
---
## 10. Suggested implementation phases
Each is independently shippable and testable, matching how the repo phases work.
- **S0 — parse & lower the sketch.** ✅ **Done.** `scene`/`layer`/`on enter`/`on
exit`/`become` lowered to the implicit `machine`; the active scene is
snapshotted per phase so exactly one scene's layers dispatch in any phase.
[`examples/scenes.ludic`](examples/scenes.ludic) compiles, runs, and is checked
by `test.sh`. This is the floor everything else builds on.
- **S1 — `@OnEnter`/`@OnExit` annotation form** (E5, cheap once S0 exists).
- **S2 — layer toggle & pause** (E2) on top of the existing `enable`/`disable`.
✅ *Toggle shipped* (via EVENTS-DESIGN EV1 layers): `enable layer L` / `disable
layer L` flips an `@LE_<L>` flag that gates the layer's handlers (emitted only
for toggled layers, so untouched scene programs stay byte-identical), and a
`public` layer fires `layer_<L>_show`/`_hide` — see
[`examples/layer_events.ludic`](examples/layer_events.ludic). Still open: the
*pause* half (keep drawing while `Update` handlers suspend) and `on show`/`on
hide` blocks.
- **S3 — the scene stack** (E3): `push`/`pop`/`overlay`/`passthrough`. The big one.
- **S4 — scene-owned entities** (E1) and **scene-local state** (E4).
- **S5 — transition parameters** (E5).
- **S6 (later) — `reads`/`writes` scheduling** (E5), gated on the analysis pass.
S0–S1 deliver the sketch as promised; S2–S3 are where the "great potential"
actually lands; S4–S5 are ergonomics; S6 is a performance destination.
---
## 11. Open decisions
1. **Re-entering a scene:** fresh entities/state, or resume? (Default proposed:
`become` = fresh, `push`/`pop` = the pushed scene is fresh each push, the base
underneath is untouched.)
2. **Stack depth:** compile-time cap with an error on overflow, or a runtime trap?
What capacity (8? configurable)?
3. **`passthrough` granularity:** does a passthrough overlay let *all* lower
layers update, or can it name which phases fall through?
4. **Layer hook naming:** `on show`/`on hide`, or reuse `@OnEnable`/`@OnDisable`?
5. **Scene-local storage sharing:** union non-coexisting scenes automatically, or
require an explicit opt-in so the sharing is visible in source?
6. **Global handlers and overlays:** do globals run once per frame regardless of
stack depth (proposed: yes), or per active scene?
7. **`become` from inside an overlay:** does it clear the stack (proposed: yes) or
is it an error while overlays are pushed?
---
*Companion to [LANGUAGE.md §"Scenes & layers"](LANGUAGE.md) and the ordering
sketch in [`examples/scenes.ludic`](examples/scenes.ludic). Supersedes nothing
until the compiler work in §10 lands.*

27
examples/cancel.ludic Normal file
View file

@ -0,0 +1,27 @@
# cancel.ludic — EV3: cancellable (decision) events.
#
# A `cancellable` event is fired BEFORE an action so a listener can veto it. The
# listener calls `cancel`; the caller reads the verdict back — `emit E(…)` used as
# an expression yields the cancelled flag (1 = vetoed, 0 = allowed). A foreign mod
# vetoes the same way, by setting the payload's trailing `cancelled` field over
# the ABI. This is the modding headline: observation becomes control.
#
# Running it prints: 0 1 92
program Cancel {
event cancellable BeforeHurt { amount: int = 0 }
@On(BeforeHurt) handler Armor { if amount > 10 { cancel } } # veto any hit over 10
entry {
let v1 = emit BeforeHurt(amount: 5) # 5 <= 10 -> allowed
print(v1) # 0
let v2 = emit BeforeHurt(amount: 15) # 15 > 10 -> vetoed
print(v2) # 1
# the realistic shape: only apply the effect when the decision isn't vetoed
var hp = 100
if emit BeforeHurt(amount: 8) == 0 { hp = hp - 8 } # allowed -> 92
if emit BeforeHurt(amount: 50) == 0 { hp = hp - 50 } # vetoed -> unchanged
print(hp) # 92
}
}

38
examples/detach.ludic Normal file
View file

@ -0,0 +1,38 @@
# detach.ludic — the structural attach/detach pair and its @OnAttach / @OnDetach
# hooks. `attach P on e` adds a property to a LIVE entity (seeding its fields and
# firing @OnAttach); `detach P on e` removes it (firing @OnDetach, which still
# reads the outgoing value before the has-flag clears). This is the structural
# counterpart to enable/disable — attach/detach create and destroy the property's
# presence, whereas disable/enable only pause it while keeping the data.
#
# Running it prints: 15 1 25 0
# 15 @OnAttach(Shield): amount seeded to 5, prints 5 + 10
# 1 one live Shield now matches the query
# 25 @OnDetach(Shield): reads the outgoing amount 5, prints 5 + 20
# 0 the Shield is gone — nothing matches
#
# ./selfhost/game-build.sh build/ludicc examples/detach.ludic /tmp/detach
# /tmp/detach </dev/null
program Detach {
property Tag { v: int = 0 }
property Shield { amount: int = 0 }
model Unit { Tag }
@OnAttach(Shield) handler Up { print(Shield.amount + 10) } # structural: property born
@OnDetach(Shield) handler Down { print(Shield.amount + 20) } # structural: property dies
handler Seed phase Start { spawn Unit { Tag { v: 1 } } }
handler Run phase Render {
for (u) in query [Unit] { attach Shield on self() { amount: 5 } } # @OnAttach -> 15
var n = 0
for (s) in query [Shield] { n += 1 }
print(n) # 1
for (s) in query [Shield] { detach Shield on self() } # @OnDetach -> 25
n = 0
for (s) in query [Shield] { n += 1 }
print(n) # 0
quit()
}
}

25
examples/events.ludic Normal file
View file

@ -0,0 +1,25 @@
# events.ludic — EV0: the event bus core.
#
# `event E { fields }` declares a public event carrying a POD payload. `@On(E)
# handler …` registers a listener whose body reads the payload fields by name.
# `emit E(field: v, …)` fires the event: it calls every @On(E) listener, in
# declaration order, as a direct call — the whole thing desugars to code, with no
# runtime and no dispatch table. A program that declares no `event` is compiled
# byte-for-byte as before (the subsystem is gated on `g_events`).
#
# Running it prints: 5 8 20 30 999 42 42
program Events {
event Hurt { entity: int = 0, amount: int = 0 } # a payload with fields + defaults
event Cleared { } # an empty payload is allowed
@On(Hurt) handler Flash { print(amount) } # listeners bind payload fields by name
@On(Hurt) handler Guard { print(entity + amount) } # a second listener, run after the first
@On(Cleared) handler Cheer { print(999) }
entry {
emit Hurt(entity: 3, amount: 5) # -> Flash 5, Guard 3+5=8
emit Hurt(entity: 10, amount: 20) # -> Flash 20, Guard 10+20=30
emit Cleared() # -> Cheer 999
emit Hurt(amount: 42) # entity defaults to 0 -> Flash 42, Guard 0+42=42
}
}

View file

@ -0,0 +1,27 @@
# layer_events.ludic — EV1 for layers + SCENES E2 (layer toggle). A `public` layer
# promotes its show/hide to events; `enable layer L` / `disable layer L` flips the
# layer on and off (its handlers stop running while hidden) and fires
# layer_<L>_show / layer_<L>_hide. This closes the last scope of "events across the
# whole architecture" — properties, models, scenes, program, and now layers.
#
# Running it prints: 50 2 1 50
program LayerEvents {
var step: int = 0
@On(layer_Hud_show) handler Shown { print(1) } # a mod reacts when the HUD returns
@On(layer_Hud_hide) handler Hidden { print(2) } # ...and when it's hidden
scene Main start {
layer Hud public {
handler Draw phase Update { print(50) } # only runs while Hud is enabled
}
layer Ctrl {
handler Drive phase LateUpdate {
step = step + 1
if step == 1 { disable layer Hud } # frame 1: hide -> Hidden 2, Draw stops
if step == 2 { enable layer Hud } # frame 2: show -> Shown 1, Draw resumes
if step == 3 { quit() } # frame 3: after Draw ran again
}
}
}
}

16
examples/mod_events.ludic Normal file
View file

@ -0,0 +1,16 @@
# mod_events.ludic — the event bus with two listeners on one event, driven from
# Ludic (replaces the former tests/mod_c/mod.c foreign-mod host). A public event
# `Damage` has two @On listeners: one prints the amount, one accumulates a total.
# Emitting it twice runs both listeners each time. Prints 10 / 32 / 42.
program ModEvents {
var total: int = 0
event Damage { amount: int = 0 }
@On(Damage) handler Native { print(amount) } # prints each hit
@On(Damage) handler Accum { total = total + amount } # sums them
entry {
emit Damage(amount: 10) # Native prints 10
emit Damage(amount: 32) # Native prints 32
print(total) # 42
}
}

45
examples/net_demo.ludic Normal file
View file

@ -0,0 +1,45 @@
# net_demo.ludic — N6: a networked game end to end, in pure Ludic, no C at all
# (NETWORKING-DESIGN §13 N6). It exercises the whole stack the earlier phases
# built: an RPC carries client input to the authority (N4), the authority mutates
# authoritative state (N5 roles), and the blessed runtime replicates that state
# back to a peer that had diverged (N2 @Sync + N3 @Owned + net_rt.ludic).
#
# One process, one loopback transport, so the round-trips are observable. The
# sequence a real client/server splits across machines is played here in order:
#
# 1. client emits Move(dx:5) — an @ToServer RPC → serialized onto the wire
# 2. net_pump() — the authority drains it, @On(Move) applies +5
# 3. rt_replicate(ship) — the authority ships the ship's synced state
# 4. Pos.x = 999 — the client diverges (mispredicts)
# 5. rt_receive() — the client reconciles to the authoritative x=5
#
# Prints 5 / 999 / 5. Build & run with the Ludic toolchain only:
# ./build.sh examples/net_demo.ludic --headless && ./build/net_demo_headless
import "net_rt.ludic"
program NetDemo {
@Sync property Pos { x: int = 0, y: int = 0 }
@Owned model Ship { @Sync Pos }
@ToServer event Move { dx: int = 0 } # client → server RPC
@On(Move) handler DoMove { # the authority applies input
for (Pos) in query [Pos, {Ship}] { Pos.x = Pos.x + dx }
}
entry {
spawn Ship { Pos { x: 0, y: 0 } }
emit Move(dx: 5) # 1. client input → wire
net_pump() # 2. authority applies it
for (Pos) in query [Pos, {Ship}] {
print(Pos.x) # 5 — server state advanced
rt_replicate(self()) # 3. authority replicates
Pos.x = 999 # 4. client diverges
print(Pos.x) # 999
}
rt_receive() # 5. client reconciles
for (Pos) in query [Pos, {Ship}] { print(Pos.x) } # 5 — back to authoritative
}
}

24
examples/net_echo.ludic Normal file
View file

@ -0,0 +1,24 @@
# net_echo.ludic — N0: the transport seam (NETWORKING-DESIGN §5, §13 N0).
#
# The transport is two calls — net_send puts a datagram on the wire, net_poll
# takes the next one off. A production build binds them to a real socket with
# `extern fn net_send/net_poll` (UDP native, WebRTC/WebSocket wasm); absent that,
# the compiler supplies a built-in in-process loopback, so a program is networked
# end to end with NO foreign host — pure Ludic. This sends four bytes and polls
# them back through the loopback: prints 4, then 10 20 30 42.
program NetEcho {
entry {
let out = bytes(4)
out[0] = 10
out[1] = 20
out[2] = 30
out[3] = 42
net_send(0, out, 4) # onto the wire (the built-in loopback)
let inb = bytes(64)
let n = net_poll(inb, 64) # take the next datagram back off
print(n) # 4
var i = 0
while i < n { print(inb[i]); i = i + 1 } # 10 20 30 42
}
}

25
examples/net_owner.ludic Normal file
View file

@ -0,0 +1,25 @@
# net_owner.ludic — N3: entity ownership (NETWORKING-DESIGN §6.3, §13 N3).
#
# `@Owned` gives a model an owner slot (the @L_owner array). owner(e) reads it,
# set_owner(e, id) assigns it (the authority does), is_owner(e) tests it against
# the local peer id. Ownership gates who may write @Sync(to: owner) fields and who
# runs @Predicted handlers; it is part of the world snapshot, so it round-trips
# through rollback/replication. A fresh entity is unowned (-1). This assigns and
# tests ownership against the default local id (0). Prints -1 / 7 / 0 / 1.
program NetOwner {
@Sync property Pos { x: int = 0, y: int = 0 }
@Owned model Unit { @Sync Pos }
entry {
spawn Unit { Pos { x: 5, y: 6 } }
for (Pos) in query [Pos, {Unit}] {
let e = self()
print(owner(e)) # -1 — fresh entity is unowned
set_owner(e, 7)
print(owner(e)) # 7 — the authority assigned it
print(is_owner(e)) # 0 — local id 0 != 7
set_owner(e, 0)
print(is_owner(e)) # 1 — now the local peer owns it
}
}
}

33
examples/net_roles.ludic Normal file
View file

@ -0,0 +1,33 @@
# net_roles.ludic — N5: handler roles + the drivable sim (NETWORKING-DESIGN §6.2,
# §5, §13 N5).
#
# A handler's network role is a declarative annotation, never a runtime branch in
# ordinary code:
# (unmarked) runs on every peer — the shared, deterministic simulation
# @Server runs only on the authority (clients get the result via @Sync)
# @Predicted runs on the owning client and the server (auto-reconciled)
# The runtime sets the peer's role register (set_role); offline it defaults to
# server, so guards collapse to "run here" and a non-networked build is unchanged.
#
# The per-frame phases are also exposed as callables — tick_fixed() runs the sim
# phases — so this game owns its own loop via `entry` (for prediction/rollback,
# replay, headless tests). Acting as a client then the server: 1, then 102.
program NetRoles {
property Score { n: int = 0 }
model Board { Score }
handler Both phase Update { for (Score) in query [Score] { Score.n = Score.n + 1 } } # runs everywhere
@Server handler ServerOnly phase Update { for (Score) in query [Score] { Score.n = Score.n + 100 } } # authority only
entry {
spawn Board { Score { n: 0 } }
set_role(0) # act as a client
tick_fixed() # Both(+1); ServerOnly skipped
for (Score) in query [Score] { print(Score.n) } # 1
set_role(1) # act as the server
tick_fixed() # Both(+1) + ServerOnly(+100)
for (Score) in query [Score] { print(Score.n) } # 102
}
}

29
examples/net_rpc.ludic Normal file
View file

@ -0,0 +1,29 @@
# net_rpc.ludic — N4: remote events / RPCs (NETWORKING-DESIGN §6.4, §13 N4).
#
# An `event` marked @ToServer (client→server) or @ToClients (server→clients) is a
# directional remote event — the event bus with a direction flag, no new concept.
# At an `emit` site the POD payload is serialized as [event id][fields] and
# net_send in its direction; net_pump() drains inbound frames and re-emits each
# into the ordinary @On dispatch on the far side. So `emit Fire(...)` is a remote
# call — it does not run locally; the receiver's pump runs the handler.
#
# Here two Fire RPCs are emitted (dir 5, dir 3). Before net_pump the handler has
# not run (hits still 0); after, both are drained and re-emitted (5 + 3 = 8).
program NetRpc {
property Log { hits: int = 0 }
model Sink { Log }
@ToServer event Fire { dir: int = 0 }
@On(Fire) handler OnFire {
for (Log) in query [Log] { Log.hits = Log.hits + dir }
}
entry {
spawn Sink { Log { hits: 0 } }
emit Fire(dir: 5) # serialized onto the wire (not run locally)
emit Fire(dir: 3)
for (Log) in query [Log] { print(Log.hits) } # 0
net_pump() # drain + re-emit both RPCs
for (Log) in query [Log] { print(Log.hits) } # 8
}
}

33
examples/net_rt.ludic Normal file
View file

@ -0,0 +1,33 @@
# net_rt.ludic — a blessed, server-authoritative replication runtime (NETWORKING
# N6). It ties the language's networking primitives together into a batteries-
# included default, the way tests/mod_c/mod.c proved the event ABI — but written
# in Ludic, over the built-in transport, with no foreign code.
#
# This is LIBRARY POLICY, not the language (NETWORKING-DESIGN §10, §12): it picks
# server-authoritative state replication. The seams stay open — swap this for
# lockstep+rollback (world_save + tick_fixed on misprediction) or your own.
#
# Frame layout on the wire: [i32 entity id][synced field bytes]. The authority
# calls rt_replicate(e) per entity each tick; a peer calls rt_receive() to drain
# inbound snapshots and apply them. serialize/apply are the compiler-generated
# @Sync codecs; net_send/net_poll are the transport seam (built-in loopback here,
# a real socket when a program binds `extern fn net_send/net_poll`).
# The authority ships one entity's authoritative synced state to peers.
fn rt_replicate(e: int) -> void {
let w = words(512)
w[0] = e # entity id in the first word
let n = serialize(e, offset(w, 4)) # synced fields after it
net_send(0, w, 4 + n)
}
# A peer drains every inbound snapshot and applies it to the named entity. One
# datagram per poll (the transport is datagram-preserving), so loop until empty.
fn rt_receive() -> void {
let w = words(512)
var n = net_poll(w, 2048)
while n > 0 {
apply(w[0], offset(w, 4), n - 4) # w[0] = entity id; bytes follow
n = net_poll(w, 2048)
}
}

View file

@ -0,0 +1,29 @@
# net_snapshot.ludic — N1: whole-world snapshot to a memory buffer
# (NETWORKING-DESIGN §5, §13 N1). The rollback/replication substrate.
#
# save()/load() snapshot the entire ECS world to a file; world_size/world_save/
# world_load generalize the identical layout to a caller-owned memory buffer:
# world_size() -> exact snapshot byte count
# world_save(buf) -> bytes written (entities, components, vars)
# world_load(buf, len) -> restore the world from those bytes
# That is all rollback needs (save → predict → on misprediction restore and
# re-sim) and all state replication needs (snapshot → ship → apply). This program
# spawns a Unit (hp 50), snapshots the world, mutates hp to 7, then restores — hp
# reads back 50. Prints 50 / 7 / 50, driven entirely from Ludic (no C host).
program NetSnapshot {
property Health { hp: int = 0, max: int = 0 }
model Unit { Health }
entry {
spawn Unit { Health { hp: 50, max: 100 } }
let buf = bytes(world_size())
for (Health) in query [Health, {Unit}] {
print(Health.hp) # 50
let n = world_save(buf) # snapshot the whole world
Health.hp = 7
print(Health.hp) # 7
world_load(buf, n) # roll the world back
print(Health.hp) # 50 — restored from bytes
}
}
}

40
examples/net_sync.ludic Normal file
View file

@ -0,0 +1,40 @@
# net_sync.ludic — N2: @Sync replication codegen (NETWORKING-DESIGN §6.1, §13 N2).
#
# Replication is opt-in at the field level and per model use-site. All three
# granularities here:
# @Sync property Position — every field of Position is replicable
# Health { @Sync hp, max } — only hp is replicable; max never is
# @Sync Position in Player — Position participates → x, y replicate
# Position in Prop — not @Sync here → Prop's Position does NOT replicate
#
# The compiler generates per-model serialize/apply over exactly the replicable-
# and-participating fields, plus by-kind dispatchers: sync_size(e) / serialize(e,
# buf) / apply(e, buf, len). This snapshots a Player's synced fields, mutates all
# of them, then applies the snapshot: synced fields (x, y, hp) restore; the
# unsynced one (max) keeps its mutation. Prints 12 (bytes) / 3 4 50 999.
program NetSync {
@Sync property Position { x: int = 0, y: int = 0 } # all fields replicable
property Health { @Sync hp: int = 0, max: int = 0 } # only hp replicable
@Owned model Player { @Sync Position, @Sync Health }
model Prop { Position } # Position not @Sync here → no replication
entry {
spawn Player { Position { x: 3, y: 4 }, Health { hp: 50, max: 100 } }
for (Position, Health) in query [Position, Health, {Player}] {
let e = self()
let buf = bytes(64)
print(sync_size(e)) # 12 = Position(x,y)=8 + Health.hp=4
let n = serialize(e, buf) # snapshot the synced fields
Position.x = 99 # mutate everything
Position.y = 88
Health.hp = 7
Health.max = 999 # max is NOT synced
apply(e, buf, n) # restore from the snapshot
print(Position.x) # 3 — restored
print(Position.y) # 4 — restored
print(Health.hp) # 50 — restored
print(Health.max) # 999 — kept (unsynced)
}
}
}

View file

@ -0,0 +1,18 @@
# program_events.ludic — EV1 for the program ("game") scope: @Public on @OnStart
# and @OnQuit promotes them to program_start / program_quit events, so a mod runs
# its own setup after the game boots and its own teardown as the game exits — the
# top-level modding entry points (Blender's app handlers, Minetest's on_shutdown).
#
# Running it prints: 1 100 2 200
program ProgramEvents {
property Health { hp: int = 0 }
model Dummy { Health }
@Public @OnStart handler Boot { print(1) } # -> program_start
@Public @OnQuit handler Bye { print(2) } # -> program_quit
@On(program_start) handler ModInit { print(100) } # mod boots after the game
@On(program_quit) handler ModDone { print(200) } # mod tears down as the game exits
handler Run phase Update { quit() }
}

32
examples/promote.ludic Normal file
View file

@ -0,0 +1,32 @@
# promote.ludic — EV1: `@Public` promotes a lifecycle hook to a public event.
#
# Marking a lifecycle hook `@Public` makes its fire site ALSO emit an event named
# for the moment — `model_<M>_spawn`, `model_<M>_despawn` — carrying the entity
# (and, for despawn, the EndReason). Anything can then subscribe: a native
# listener with `@On(model_Enemy_spawn)`, or a foreign mod binding
# `ludic_on_model_Enemy_spawn` over the C ABI. The game's own lifecycle becomes
# the modding surface, with no hand-written `emit` in the hook body.
#
# Running it prints: 100 101 200 201
program Promote {
property Health { hp: int = 0, max: int = 100 }
model Enemy { Health }
@Public @OnSpawn(Enemy) handler Init { Health.hp = Health.max } # -> model_Enemy_spawn
@Public @OnDespawn(Enemy) handler Clean { } # -> model_Enemy_despawn
# Listeners on the promoted events. These read the generated payload (entity,
# and reason for despawn) by name — exactly what a foreign mod receives.
@On(model_Enemy_spawn) handler Spawned { print(entity + 100) }
@On(model_Enemy_despawn) handler Died { print(entity + reason + 200) }
handler Seed phase Start {
spawn Enemy { Health { max: 50 } } # entity 0 -> Spawned 100
spawn Enemy { Health { max: 60 } } # entity 1 -> Spawned 101
}
handler Run phase Update {
for (h) in query [Health] { despawn self() } # despawn 0 -> Died 200, despawn 1 -> Died 201
quit()
}
}

View file

@ -0,0 +1,26 @@
# prop_events.ludic — EV1 for properties: @Public promotes the structural
# attach/detach hooks to public events, so a mod sees a property appear or vanish
# on a live entity (the same shape works for @OnEnable/@OnDisable → enable/disable
# events). This extends the public-event surface past models to properties —
# "events for properties, models, scenes, layers" in the architecture.
#
# Running it prints: 300 400
program PropEvents {
property Tag { v: int = 0 }
property Shield { amount: int = 0 }
model Unit { Tag }
@Public @OnAttach(Shield) handler Up { } # -> prop_Shield_attach
@Public @OnDetach(Shield) handler Down { } # -> prop_Shield_detach
@On(prop_Shield_attach) handler Gained { print(entity + 300) } # entity 0 -> 300
@On(prop_Shield_detach) handler Lost { print(entity + 400) } # entity 0 -> 400
handler Seed phase Start { spawn Unit { Tag { v: 1 } } } # entity 0
handler Run phase Render {
for (u) in query [Unit] { attach Shield on self() { amount: 5 } } # -> prop_Shield_attach -> 300
for (s) in query [Shield] { detach Shield on self() } # -> prop_Shield_detach -> 400
quit()
}
}

38
examples/reason.ludic Normal file
View file

@ -0,0 +1,38 @@
# reason.ludic — LC1 reason-carrying teardown. One @OnDespawn hook, but it knows
# *why* the entity is ending: `reason: r` binds an EndReason the compiler passes
# at each teardown site. An in-world `despawn` passes EndReason.Despawned; program
# shutdown passes EndReason.Quit (every still-live entity's hook fires at exit — no
# silent deaths). The body branches on the reason, exactly as Unreal's
# EndPlay(reason) / Erlang's terminate(Reason) do.
#
# Running it prints: 503 1009
# 503 Enemy A despawned in-world (Despawned): drop its loot, 3 + 500
# 1009 Enemy B outlived the run; at quit (Quit) it skips loot, 9 + 1000
#
# ./selfhost/game-build.sh build/ludicc examples/reason.ludic /tmp/reason
# /tmp/reason </dev/null
program Reasons {
property Health { hp: int = 0 }
property Loot { gold: int = 0 }
model Enemy { Health, Loot }
@OnDespawn(Enemy, reason: r) handler Clean {
match r {
EndReason.Quit => { print(Health.hp + 1000) } # app closing — don't bother dropping loot
_ => { print(Loot.gold + 500) } # died in-world — drop the loot
}
}
handler Seed phase Start {
spawn Enemy { Health { hp: 7 }, Loot { gold: 3 } } # A
spawn Enemy { Health { hp: 9 }, Loot { gold: 4 } } # B
}
handler Run phase Render {
var first = 0
for (e) in query [Health] {
if first == 0 { despawn self(); first = 1 } # despawn A -> Despawned -> 3 + 500 = 503
}
quit() # B survives -> Quit -> 9 + 1000 = 1009
}
}

21
examples/recurse.ludic Normal file
View file

@ -0,0 +1,21 @@
# recurse.ludic — EV6: re-entrant emit is bounded. Ping's listener emits Pong,
# Pong's listener emits Ping — an event cycle that would hang the frame forever.
# The depth bound (EV_DEPTH_CAP) makes the nesting trap as an early return, so the
# program terminates with a bounded count instead of spinning. This is the "no
# runaway event cycle" guarantee.
#
# Running it prints: 16 (the cycle bottoms out at the depth cap, deterministically)
program Recurse {
var n: int = 0
event Ping { }
event Pong { }
@On(Ping) handler A { n = n + 1; emit Pong() } # each Ping deepens by one Pong
@On(Pong) handler B { emit Ping() } # ...and each Pong by one Ping
entry {
emit Ping()
print(n)
}
}

View file

@ -0,0 +1,22 @@
# scene_events.ludic — EV1 for scenes: a `public` scene promotes its on-enter /
# on-exit to public events scene_<S>_enter / scene_<S>_exit, extending the modding
# surface to the game's scene structure. A mod (native @On here, or foreign over
# the ABI) reacts as the game moves between scenes.
#
# Running it prints: 10 1 20 2 30 3
program SceneEvents {
@On(scene_Menu_enter) handler Greet { print(1) }
@On(scene_Menu_exit) handler Bye { print(2) }
@On(scene_Game_enter) handler Begin { print(3) }
scene Menu start public {
on enter { print(10) } # boot enters Menu: 10, then scene_Menu_enter -> 1
on exit { print(20) } # become leaves Menu: 20, then scene_Menu_exit -> 2
layer L { handler Go phase Update { become Game } }
}
scene Game public {
on enter { print(30) } # become enters Game: 30, then scene_Game_enter -> 3
layer M { handler Stop phase Update { quit() } }
}
}

View file

@ -1,9 +1,19 @@
# ⚠️ NOT YET IMPLEMENTED by the self-hosted compiler. `scene` / `layer` /
# `on enter` / `on exit` have no parser support today, so this file does NOT
# compile with build/ludicc — it is a design sketch of the intended syntax (see
# LANGUAGE.md §"Scenes & layers"). It is deliberately excluded from test.sh.
# Games that need scene-like states use a mode register + `machine` today, as
# examples/chronorift does.
# scenes — one active scene at a time, each grouping handlers into layers behind
# an implicit active-scene register (see LANGUAGE.md §"Scenes & layers").
#
# Running it (feed a few keystrokes so the loop ticks) prints:
# 1000 1 101 102 2 3 900 201 900 202 900
# 1000 Boot (a global handler) runs once at Start
# 1 Title is `start`; its `on enter` fires at boot
# 101 frame 1 Update: only Title.Main.Tick runs (Play is not active)
# 102 frame 2 Update: Tick reaches 2 -> `become Play`…
# 2 3 …which runs Title's `on exit` then Play's `on enter`
# 900 Play renders the same frame it is entered (Hud.Draw)
# 201 900 frame 3: Play.World.Step, then Hud.Draw
# 202 900 frame 4: Step reaches 2 -> quit(); Hud.Draw paints the last frame
#
# ./selfhost/game-build.sh build/ludicc examples/scenes.ludic /tmp/scenes
# printf 'aaaa' | /tmp/scenes
program SceneDemo {
const R_N: int = 0

21
examples/scoped.ludic Normal file
View file

@ -0,0 +1,21 @@
# scoped.ludic — a despawned entity drops out of subsequent event-driven work
# (replaces tests/mod_c/scoped_mod.c, which tested entity-scoped foreign callbacks
# swept on despawn). Here a @On(Tick) listener counts live Units each tick; after
# the Unit is despawned, a further tick finds none. Fires twice before the kill,
# not the third time. Prints 2.
program Scoped {
property Health { hp: int = 0 }
model Unit { Health }
var fires: int = 0
event Tick { }
@On(Tick) handler OnTick { for (Health) in query [Health, {Unit}] { fires = fires + 1 } }
entry {
spawn Unit { Health { hp: 1 } }
emit Tick() # 1 live Unit -> fires = 1
emit Tick() # fires = 2
for (Health) in query [Health, {Unit}] { despawn self() } # kill the Unit
emit Tick() # no live Units -> fires stays 2
print(fires) # 2
}
}

27
examples/world_dyn.ludic Normal file
View file

@ -0,0 +1,27 @@
# world_dyn.ludic — EV7 reflection: register a brand-new component at runtime
# (replaces tests/mod_c/world_dyn.c). world_register_prop declares a component the
# game never wrote; world_attach_dyn adds it to an entity; get/set/has then treat
# it exactly like a built-in, with per-entity isolation. Prints 0 1 30 100 1 30.
program WorldDyn {
property Health { hp: int = 0, max: int = 0 }
model Unit { Health }
@Public @OnSpawn(Unit) handler Init { }
entry {
let Um = world_model_id("Unit")
let e = world_spawn(Um)
let e2 = world_spawn(Um)
let Mana = world_register_prop("Mana", 2) # a component never declared in source
print(world_has(e, Mana)) # 0 — not attached yet
world_attach_dyn(e, Mana)
print(world_has(e, Mana)) # 1
world_set(e, Mana, 0, 30) # field 0 (current)
world_set(e, Mana, 1, 100) # field 1 (max)
print(world_get(e, Mana, 0)) # 30
print(world_get(e, Mana, 1)) # 100
if world_prop_id("Mana") == Mana { print(1) } else { print(0) } # 1 — name resolves
world_attach_dyn(e2, Mana)
world_set(e2, Mana, 0, 7)
print(world_get(e, Mana, 0)) # 30 — per-entity isolation
}
}

22
examples/world_get.ludic Normal file
View file

@ -0,0 +1,22 @@
# world_get.ludic — EV2 reflection: read and write a component by NAME through the
# world table, driven from Ludic (replaces tests/mod_c/world_mod.c). world_prop_id
# / world_field_id resolve names to ids; world_get / world_set / world_has read and
# write by those ids — the same reflection ABI a foreign mod binds, now callable
# from Ludic. Spawns a Unit (Init sets hp=max=50), then reads/tests/writes hp.
# Prints 50 / 1 / 7.
program WorldGet {
property Health { hp: int = 0, max: int = 0 }
model Unit { Health }
@Public @OnSpawn(Unit) handler Init { Health.hp = Health.max } # -> emits the world table
entry {
spawn Unit { Health { max: 50 } }
let H = world_prop_id("Health")
let hp = world_field_id(H, "hp")
let e = world_query_next(H, 0)
print(world_get(e, H, hp)) # 50 — read by name
print(world_has(e, H)) # 1 — entity has Health
world_set(e, H, hp, 7) # write by name
print(world_get(e, H, hp)) # 7 — the write stuck
}
}

View file

@ -0,0 +1,17 @@
# world_mixed.ludic — EV2b reflection: get/set use real struct offsets, not a
# field*4 assumption (replaces tests/mod_c/world_mixed.c). Slot's `qty` sits after
# a `ptr` field, so its byte offset is 8, not 4; writing qty=99 by name and reading
# it back proves the world table addresses fields by their true layout. Prints 99.
program WorldMixed {
property Slot { ref: ptr = null, qty: int = 0 }
model Item { Slot }
@Public @OnSpawn(Item) handler Born { } # makes it a modding program
entry {
let e = world_spawn(world_model_id("Item"))
let S = world_prop_id("Slot")
let qty = world_field_id(S, "qty") # field index 1, after the ptr
world_set(e, S, qty, 99)
print(world_get(e, S, qty)) # 99 iff the offset is 8, not 4
}
}

View file

@ -0,0 +1,26 @@
# world_query.ludic — EV2b reflection: iterate the world by property (replaces
# tests/mod_c/world_query.c). world_query_next(prop, from) returns the next live
# entity (>= from) that has the property, or -1 — so a mod walks all bearers of a
# component without knowing the models. Two Units (hp 50, 60) → count 2, sum 110.
program WorldQuery {
property Health { hp: int = 0, max: int = 0 }
model Unit { Health }
@Public @OnSpawn(Unit) handler Init { Health.hp = Health.max }
entry {
spawn Unit { Health { max: 50 } }
spawn Unit { Health { max: 60 } }
let H = world_prop_id("Health")
let hp = world_field_id(H, "hp")
var count = 0
var sum = 0
var e = world_query_next(H, 0)
while e >= 0 {
count = count + 1
sum = sum + world_get(e, H, hp)
e = world_query_next(H, e + 1)
}
print(count) # 2
print(sum) # 110
}
}

30
examples/world_scan.ludic Normal file
View file

@ -0,0 +1,30 @@
# world_scan.ludic — EV2b reflection: scan the world and identify each entity by
# MODEL, by name (replaces tests/mod_c/world_scan.c). world_count / world_kind /
# world_model_id let a mod walk every entity and pick out a model's instances, then
# read their fields with world_get. Two Units (hp 50, 60) → count 2, sum 110.
program WorldScan {
property Health { hp: int = 0, max: int = 0 }
model Unit { Health }
@Public @OnSpawn(Unit) handler Init { Health.hp = Health.max }
entry {
spawn Unit { Health { max: 50 } }
spawn Unit { Health { max: 60 } }
let H = world_prop_id("Health")
let hp = world_field_id(H, "hp")
let Um = world_model_id("Unit")
let n = world_count()
var count = 0
var sum = 0
var e = 0
while e < n {
if world_kind(e) == Um {
count = count + 1
sum = sum + world_get(e, H, hp)
}
e = e + 1
}
print(count) # 2
print(sum) # 110
}
}

View file

@ -0,0 +1,20 @@
# world_spawn.ludic — EV2b reflection: a mod creates a fresh entity by model id
# (replaces tests/mod_c/world_spawn.c). world_spawn reuses the compiler's own
# spawn lowering (alloc, kind, component defaults, @OnSpawn), so a mod-spawned
# entity is indistinguishable from one born in source. Prints 1 / 42 / 1.
program WorldSpawn {
property Health { hp: int = 0, max: int = 0 }
model Unit { Health }
@Public @OnSpawn(Unit) handler Init { } # world table; leave defaults
entry {
let Um = world_model_id("Unit")
let e = world_spawn(Um) # the mod creates an entity
let H = world_prop_id("Health")
let hp = world_field_id(H, "hp")
print(world_has(e, H)) # 1 — spawn attached Health with defaults
world_set(e, H, hp, 42)
print(world_get(e, H, hp)) # 42
print(world_count()) # 1 — it is really in the world
}
}

View file

@ -17,6 +17,18 @@ const N_EXTERN: int = 11
const N_UI: int = 12
const N_ENUM: int = 27 # enum Name { A, B, ... } — named int constants
const S_TOGGLE: int = 28 # enable/disable (ival: 1=enable 0=disable; s=target; a=entity or null)
const N_SCENE: int = 45 # scene Name [start] { on enter{} on exit{} layer L { handlers } }
# s=name ival=scene id a=on-enter block b=on-exit block
const S_ATTACH: int = 46 # attach P on e [{ overrides }] — add a property to a live entity
# s=property a=entity expr b=override record (E_REC) or null
const S_DETACH: int = 47 # detach P on e — remove a property from a live entity
# s=property a=entity expr
const N_EVENT: int = 48 # event Name { field: T = default, ... } — a public event payload
# s=name kids=payload fields (N_FIELD)
const S_EMIT: int = 49 # emit E(field: v, ...) — fire event E (calls its @On listeners)
# s=event name a=E_REC of named args; also usable as an
# expression (a cancellable event returns its cancelled flag)
const S_CANCEL: int = 50 # cancel — inside a listener, veto a `cancellable` event
# statements
const S_LET: int = 10
const S_ASSIGN: int = 11

View file

@ -23,7 +23,8 @@ echo " seed.ll --clang--> sh_seed (no C compiler used)"
FRAGS="selfhost/str.ludic selfhost/buf.ludic selfhost/io.ludic selfhost/ast.ludic
selfhost/lex.ludic selfhost/parse.ludic selfhost/parse_game.ludic selfhost/emit_core.ludic selfhost/emit_head.ludic
selfhost/emit_addr.ludic selfhost/emit_intrin.ludic selfhost/emit_intrin2.ludic selfhost/emit_math.ludic selfhost/emit_new.ludic
selfhost/emit_expr.ludic selfhost/emit_stmt.ludic selfhost/emit_ecs.ludic selfhost/emit_query.ludic selfhost/emit_spawn.ludic selfhost/emit_game.ludic selfhost/emit_machine.ludic selfhost/emit_save.ludic selfhost/emit_ui.ludic selfhost/emit_decl.ludic selfhost/main.ludic"
selfhost/emit_expr.ludic selfhost/emit_stmt.ludic selfhost/emit_ecs.ludic selfhost/emit_query.ludic selfhost/emit_spawn.ludic selfhost/emit_game.ludic selfhost/emit_machine.ludic selfhost/emit_save.ludic
selfhost/emit_net.ludic selfhost/emit_ui.ludic selfhost/emit_decl.ludic selfhost/main.ludic"
{ echo "program SelfHost {"; for f in $FRAGS; do cat "$f"; echo; done; echo "}"; } > "$B/selfhost.ludic"
# the seed-built compiler compiles its own source

View file

@ -29,6 +29,7 @@ selfhost/emit_spawn.ludic
selfhost/emit_game.ludic
selfhost/emit_machine.ludic
selfhost/emit_save.ludic
selfhost/emit_net.ludic
selfhost/emit_ui.ludic
selfhost/emit_decl.ludic
selfhost/main.ludic"

View file

@ -34,6 +34,20 @@ var nself: int = 0
var mach_stk: []Node # enclosing `machine` nodes, so `become` finds its register
var nmach: int = 0
# scenes: one implicit active-scene register (@L_scene). A scene lowers to a
# machine the compiler writes for you — `become <Scene>` runs the source scene's
# on-exit, stores the target id, and runs its on-enter.
var g_scenes: []Node # every `scene` declaration, in source order (ival = id)
var g_scene_count: int = 0 # parse-time id counter
var g_start_scene: int = 0 # id of the scene marked `start` (else the first)
var g_cur_scene: Node = null # scene owning the handler being emitted, for `become`
fn find_scene(name: ptr) -> Node {
var i = 0
while i < len(g_scenes) { if (g_scenes[i].s == name) { return g_scenes[i] }; i = i + 1 }
return null
}
fn emit(s: ptr) -> void { buf_puts(code, s) }
fn emith(s: ptr) -> void { buf_puts(head, s) }
@ -52,7 +66,7 @@ fn lbl(pfx: ptr) -> ptr { let r = (pfx + itoa(ll_lbl)); ll_lbl = ll_lbl + 1; r
# Ludic type -> LLVM type. int/bool are i32; everything else (ptr/str/struct/
# slice) is a pointer; void is void.
fn llty(t: ptr) -> ptr {
if (t == "int") or (t == "bool") or (t == "fixed") { return "i32" }
if (t == "int") or (t == "bool") or (t == "fixed") or (t == "entity") { return "i32" } # entity = an i32 handle (self())
if (t == "byte") { return "i8" } # a single byte (p[i] on a raw ptr)
if (t == "words") or (t == "fixeds") or (t == "ptrs") { return "ptr" } # typed buffers
if (t == "void") { return "void" }
@ -112,6 +126,13 @@ fn enum_ordinal(ename: ptr, vname: ptr) -> int {
}
i = i + 1
}
# LC1: the compiler owns `EndReason` — the reason bound by a reason-carrying
# teardown (`@OnDespawn(M, reason: r)`). Each despawn site passes one of these.
if (ename == "EndReason") {
if (vname == "Despawned") { return 0 } # explicit `despawn e`
if (vname == "SceneExit") { return 1 } # a scene tearing down its owned entities
if (vname == "Quit") { return 2 } # program shutdown
}
return 0 - 1
}
fn find_fn(name: ptr) -> Node {
@ -120,6 +141,17 @@ fn find_fn(name: ptr) -> Node {
return null
}
# `extern fn name(params) -> T = "sym"` binds a Ludic name to a link symbol. A
# call to `name` lowers to a direct `@<sym>` call (no @fn_ prefix — the string is
# the exact linked symbol), and emit_extern_decls emits a matching `declare`. This
# is the transport seam (net_send/net_poll), the windowing/socket FFI, and any
# C/Rust/Zig library binding — the same seam NETWORKING-DESIGN §5 names.
fn find_extern(name: ptr) -> Node {
var i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_EXTERN and (d.s == name) { return d }; i = i + 1 }
return null
}
# @Computed derived fields: a per-property (Prop.field -> expression) registry.
# These are NOT stored in the component layout; `x.field` expands inline to the
# expression with its bare names read as fields of `x`. Populated at parse time.
@ -162,8 +194,11 @@ fn onspawn_body(model: ptr) -> Node {
var g_ondespawn: []Node # each: s = Model name, a = hook body block
var g_onattach: []Node # each: s = Property name, a = hook body block
fn register_ondespawn(model: ptr, body: Node) -> void {
let n = node(N_BLOCK); n.s = model; n.a = body; push(g_ondespawn, n)
# LC1: `.ty` carries the optional `reason:` binding name (null if the hook took
# no reason). The despawn hook function gains an `i32 %reason` parameter and each
# teardown site passes a constant EndReason (see emit_despawn_hooks / emit_despawn).
fn register_ondespawn(model: ptr, body: Node, reason: ptr) -> void {
let n = node(N_BLOCK); n.s = model; n.a = body; n.ty = reason; push(g_ondespawn, n)
}
fn ondespawn_body(model: ptr) -> Node {
var i = 0
@ -179,6 +214,19 @@ fn onattach_body(prop: ptr) -> Node {
return null
}
# @OnDetach(Property): the teardown paired with @OnAttach — fires when a property
# is removed from a live entity (`detach P on e`), with the property bound by name
# so the body can read its outgoing value before it is cleared.
var g_ondetach: []Node # each: s = Property name, a = hook body block
fn register_ondetach(prop: ptr, body: Node) -> void {
let n = node(N_BLOCK); n.s = prop; n.a = body; push(g_ondetach, n)
}
fn ondetach_body(prop: ptr) -> Node {
var i = 0
while i < len(g_ondetach) { if (g_ondetach[i].s == prop) { return g_ondetach[i].a }; i = i + 1 }
return null
}
# @OnEnable(Property) / @OnDisable(Property): run when a property is toggled on an
# entity, with the property bound by name.
var g_onenable: []Node
@ -196,6 +244,59 @@ fn ondisable_body(prop: ptr) -> Node {
return null
}
# EV0 — the event bus core. `event E { fields }` declares a POD payload; `@On(E)
# handler …` registers a compile-time listener; `emit E(…)` fires it. An event
# lowers to a `@ev_<E>(payload)` function whose body is the concatenation of its
# listeners in declaration order — a direct call at each `emit` site, no runtime.
# Gated on `len(g_events) > 0`, so a program with no events is byte-identical.
var g_events: []Node # each: an N_EVENT node (s = name, kids = payload fields, ival=1 if cancellable)
var g_onlisten: []Node # each: N_BLOCK, s = event name, a = listener body block
var g_cancel_addr: ptr = null # EV3: address of the current cancellable dispatch's flag (null outside one)
fn register_event(n: Node) -> void { push(g_events, n) }
fn find_event(name: ptr) -> Node {
var i = 0
while i < len(g_events) { if (g_events[i].s == name) { return g_events[i] }; i = i + 1 }
return null
}
fn register_onlisten(evt: ptr, body: Node) -> void {
let n = node(N_BLOCK); n.s = evt; n.a = body; push(g_onlisten, n)
}
# EV1 — `@Public` promotes a lifecycle hook to a public event. A promoted event
# is synthesized here (payload = the entity, plus a reason for despawn); its
# presence in g_events is what makes each lifecycle fire site also `emit` it, so
# `find_event(name) != null` doubles as the "is this hook public?" test. Names are
# the stable ABI contract: `model_<M>_spawn`, `model_<M>_despawn`, etc.
fn ensure_event(name: ptr, with_reason: bool) -> void {
if (find_event(name) != null) { return }
let n = node(N_EVENT); n.s = name
let ent = node(N_FIELD); ent.s = "entity"; ent.ty = "int"; push(n.kids, ent)
if with_reason { let r = node(N_FIELD); r.s = "reason"; r.ty = "int"; push(n.kids, r) }
register_event(n)
}
# a promoted scene/program event has no per-entity payload
fn ensure_event_empty(name: ptr) -> void {
if (find_event(name) != null) { return }
let n = node(N_EVENT); n.s = name; register_event(n)
}
# EV1/SCENES-E2 — layer toggle. A layer named in an `enable layer L`/`disable
# layer L` statement becomes "managed": it gets an @LE_<L> enabled flag and its
# handlers gate on it. Only managed layers pay for this, so a scene program that
# never toggles a layer is byte-identical.
var g_toggled_layers: []ptr
fn note_toggled_layer(name: ptr) -> void {
var i = 0
while i < len(g_toggled_layers) { if (g_toggled_layers[i] == name) { return }; i = i + 1 }
push(g_toggled_layers, name)
}
fn is_toggled_layer(name: ptr) -> bool {
var i = 0
while i < len(g_toggled_layers) { if (g_toggled_layers[i] == name) { return true }; i = i + 1 }
return false
}
# is `name` a model (archetype)? — chooses model-vs-handler for a bare enable/disable
fn is_model(name: ptr) -> bool { return find_arch_id(name) > 0 }

View file

@ -70,21 +70,32 @@ fn emit_program() -> void {
g_uses_str = false
g_uses_intstr = false
g_uses_strslice = false
g_uses_loopback = false
loc_name = new []ptr; loc_reg = new []ptr; loc_ty = new []ptr; loc_mut = new []int
brk_lbl = new []ptr; cnt_lbl = new []ptr
self_stk = new []ptr
mach_stk = new []Node
emit_header()
emit_extern_decls()
if has_ecs() { emit_ecs_storage() }
var i = 0
while i < len(prog) { if prog[i].kind == N_FN { emit_fn(prog[i]) }; i = i + 1 }
if len(g_events) > 0 { emit_event_fns() } # EV0: @ev_<E> event-dispatch functions
if has_ecs() and len(g_events) > 0 { emit_world_table() } # EV2: the mod reflection ABI
if has_ecs() { emit_ecs_allocator(); emit_snapshot() }
if has_ecs() { emit_net() } # N2/N3: @Sync serializers + @Owned storage (gated internally)
if has_ui() { emit_ui_build() }
if has_systems() { emit_game_main() }
else {
if has_systems() and has_entry() { # N5: game owns its loop via `entry`
emit_game_defs() # system fns, hooks, tick helpers
i = 0
while i < len(prog) { if prog[i].kind == N_MAIN { emit_main(prog[i]) }; i = i + 1 }
}
else { if has_systems() { emit_game_main() } # the auto frame loop
else {
i = 0
while i < len(prog) { if prog[i].kind == N_MAIN { emit_main(prog[i]) }; i = i + 1 }
} }
if g_uses_loopback { emit_loopback() } # built-in transport, after all net_send/net_poll uses are seen
if g_uses_str { emit_str_prelude() } # @fn_str_eq / @fn_str_concat, after all uses are seen
if g_uses_intstr { emit_int_str() } # @fn_int_str, for str(int) in interpolation
if g_uses_strslice { emit_str_slice() } # @fn_str_slice, for s[a..b]

View file

@ -15,6 +15,14 @@ fn has_models() -> bool {
while i < len(prog) { if prog[i].kind == N_ARCH { return true }; i = i + 1 }
return false
}
# N5: does the program have an `entry` block? A game with both handlers and an
# `entry` drives its own loop (calling tick_fixed/tick_render), instead of the
# compiler's auto frame loop. A game with handlers and no entry uses the auto-loop.
fn has_entry() -> bool {
var i = 0
while i < len(prog) { if prog[i].kind == N_MAIN { return true }; i = i + 1 }
return false
}
# Does this program run the ECS? A property alone no longer answers that — the
# same `property` keyword also declares plain `new`-allocated records (the merged
# `struct`). A program uses the ECS when it has a handler or a model; a tool that
@ -25,12 +33,22 @@ fn has_ecs() -> bool { return has_systems() or has_models() }
fn emit_ecs_storage() -> void {
emith("@L_running = internal global i32 1\n")
emith("@L_key = internal global i32 0\n")
if len(g_scenes) > 0 { emith("@L_scene = internal global i32 0\n") } # active base scene id
emith("@L_entc = internal global i32 0\n")
let me = itoa(MAX_ENT)
emith(`@L_alive = internal global [{me} x i32] zeroinitializer\n`)
emith(`@L_kind = internal global [{me} x i32] zeroinitializer\n`)
emith(`@L_freelist = internal global [{me} x i32] zeroinitializer\n`)
emith("@L_freen = internal global i32 0\n")
# NETWORKING role registers (N3/N5): a runtime sets these; offline they hold the
# single-player default — @L_role=1 (this peer is the authority), local id 0.
# Emitted only when a networking feature is used, so non-networked builds are
# byte-identical (§8). @L_owner_arr is the per-entity network owner (N3, @Owned).
if net_any() {
emith("@L_role = internal global i32 1\n") # 1 = server/authority (offline default)
emith("@L_localid = internal global i32 0\n") # this peer's id
}
if net_has_owned() { emith(`@L_owner_arr = internal global [{me} x i32] zeroinitializer\n`) }
var i = 0
while i < len(prog) {
let c = prog[i]
@ -46,6 +64,9 @@ fn emit_ecs_storage() -> void {
if c.kind == N_SYS { emith(`@HE_{c.s} = internal global i32 1\n`) }
i = i + 1
}
# one enabled-flag global per toggled layer (default shown)
var li = 0
while li < len(g_toggled_layers) { emith(`@LE_{g_toggled_layers[li]} = internal global i32 1\n`); li = li + 1 }
}
# L_reset(e): clear every has-flag and the archetype kind for entity e
@ -62,7 +83,13 @@ fn emit_ecs_allocator() -> void {
i = i + 1
}
emit(" %k = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_kind, i32 0, i32 %e\n")
emit(" store i32 0, ptr %k\n ret void\n}\n\n")
emit(" store i32 0, ptr %k\n")
# N3: reset an @Owned entity's network owner to -1 (unowned) on alloc/free
if net_has_owned() {
emit(" %ow = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_owner_arr, i32 0, i32 %e\n")
emit(" store i32 -1, ptr %ow\n")
}
emit(" ret void\n}\n\n")
emit("define i32 @L_alloc() {\nentry:\n")
emit(" %fn = load i32, ptr @L_freen\n")

View file

@ -123,7 +123,78 @@ fn emit_call(e: Node) -> Val {
if (name == "save") { emit(" call void @L_save()\n"); return val("0", "void") }
if (name == "ui_build") { emit(" call void @ui_build()\n"); return val("0", "void") }
if (name == "load") { return val(emit_bind("call i32 @L_load()"), "bool") }
if (name == "world_size") { return val(emit_bind("call i32 @L_world_size()"), "int") }
if (name == "world_save") { # world_save(buf) -> bytes written
let b = emit_expr(e.kids[0])
return val(emit_bind(`call i32 @L_world_save(ptr {b.code})`), "int")
}
if (name == "world_load") { # world_load(buf, len)
let b = emit_expr(e.kids[0])
let l = emit_expr(e.kids[1])
emit(" call void @L_world_load(ptr "); emit(b.code); emit(", i32 "); emit(l.code); emit(")\n")
return val("0", "void")
}
if (name == "quit") { emit(" store i32 0, ptr @L_running\n"); return val("0", "void") }
# NETWORKING (NETWORKING-DESIGN §5) — the low-level freedom layer, callable from
# Ludic. serialize/apply/sync_size lower to the @Sync by-kind dispatchers (N2);
# owner/set_owner/is_owner to the @Owned storage (N3); is_server/local_id read
# the runtime-set role registers (N5). Offline these hold their single-player
# default (@L_role=1 → is_server()==true), so guards collapse to "run here" (§8).
if (name == "serialize") { # serialize(e, buf) -> bytes written
let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1])
return val(emit_bind(`call i32 @ludic_serialize(i32 {a.code}, ptr {b.code})`), "int")
}
if (name == "apply") { # apply(e, buf, len)
let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1]); let c = emit_expr(e.kids[2])
emit(" call void @ludic_apply(i32 "); emit(a.code); emit(", ptr "); emit(b.code); emit(", i32 "); emit(c.code); emit(")\n")
return val("0", "void")
}
if (name == "sync_size") { # sync_size(e) -> replicated byte count for e's model
let a = emit_expr(e.kids[0])
return val(emit_bind(`call i32 @ludic_sync_size(i32 {a.code})`), "int")
}
if (name == "owner") { # owner(e) -> peer id (-1 = unowned)
let a = emit_expr(e.kids[0])
return val(emit_bind(`call i32 @L_owner(i32 {a.code})`), "int")
}
if (name == "set_owner") { # set_owner(e, id)
let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1])
emit(" call void @L_set_owner(i32 "); emit(a.code); emit(", i32 "); emit(b.code); emit(")\n")
return val("0", "void")
}
if (name == "is_owner") { # is_owner(e) -> owner(e) == local_id()
let a = emit_expr(e.kids[0])
return val(emit_bind(`call i32 @L_is_owner(i32 {a.code})`), "bool")
}
if (name == "is_server") { # is_server() -> the local peer is the authority
let r = emit_bind("load i32, ptr @L_role")
let c = emit_bind(`icmp eq i32 {r}, 1`)
return val(emit_bind(`zext i1 {c} to i32`), "bool")
}
if (name == "local_id") { return val(emit_bind("load i32, ptr @L_localid"), "int") }
if (name == "net_pump") { emit(" call void @L_net_pump()\n"); return val("0", "void") } # N4: drain + re-emit inbound RPCs
if (name == "tick_fixed") { emit(" call void @L_tick_fixed()\n"); return val("0", "void") } # N5: run the sim phases
if (name == "tick_render") { emit(" call void @L_tick_render()\n"); return val("0", "void") } # N5: run the Render phase
if (name == "set_role") { # N5: the runtime sets the peer's role (1=server, 0=client)
let a = emit_expr(e.kids[0]); emit(" store i32 "); emit(a.code); emit(", ptr @L_role\n"); return val("0", "void")
}
if (name == "set_local_id") { # N5: the runtime sets this peer's id
let a = emit_expr(e.kids[0]); emit(" store i32 "); emit(a.code); emit(", ptr @L_localid\n"); return val("0", "void")
}
# net_send(peer, buf, len) / net_poll(buf, cap): the transport seam. An
# `extern fn` of the same name (a real socket) wins; absent one, these lower to
# the compiler's built-in loopback so a game is networked with zero foreign code.
if (name == "net_send") and (find_extern("net_send") == null) {
g_uses_loopback = true
let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1]); let c = emit_expr(e.kids[2])
emit(" call void @L_net_send(i32 "); emit(a.code); emit(", ptr "); emit(b.code); emit(", i32 "); emit(c.code); emit(")\n")
return val("0", "void")
}
if (name == "net_poll") and (find_extern("net_poll") == null) {
g_uses_loopback = true
let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1])
return val(emit_bind(`call i32 @L_net_poll(ptr {a.code}, i32 {b.code})`), "int")
}
if (name == "len") { return emit_len(e) }
if (name == "push") { return emit_push(e) }
if (name == "str") { # str(x): int/bool/fixed -> text, a string passes through
@ -151,9 +222,55 @@ fn emit_call(e: Node) -> Val {
}
if (name == "fx") { let a = emit_expr(e.kids[0]); return val(emit_bind(`shl i32 {a.code}, 16`), "fixed") }
if (name == "flr") { let a = emit_expr(e.kids[0]); return val(emit_bind(`ashr i32 {a.code}, 16`), "int") }
# The EV2 reflection ABI (the world table), exposed to Ludic so a Ludic mod can
# introspect the world by name — the same functions a foreign mod binds. Emitted
# only for a modding program (ECS + events), so a plain game is unchanged.
if (name == "world_prop_id") { let a = emit_expr(e.kids[0]); return val(emit_bind(`call i32 @ludic_prop_id(ptr {a.code})`), "int") }
if (name == "world_field_id") { let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1]); return val(emit_bind(`call i32 @ludic_field_id(i32 {a.code}, ptr {b.code})`), "int") }
if (name == "world_get") {
let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1]); let c = emit_expr(e.kids[2])
let r = emit_bind(`call i64 @ludic_get(i32 {a.code}, i32 {b.code}, i32 {c.code})`)
return val(emit_bind(`trunc i64 {r} to i32`), "int")
}
if (name == "world_set") {
let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1]); let c = emit_expr(e.kids[2]); let d = emit_expr(e.kids[3])
let v64 = emit_bind(`sext i32 {d.code} to i64`)
emit(" call void @ludic_set(i32 "); emit(a.code); emit(", i32 "); emit(b.code); emit(", i32 "); emit(c.code); emit(", i64 "); emit(v64); emit(")\n")
return val("0", "void")
}
if (name == "world_has") { let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1]); return val(emit_bind(`call i32 @ludic_has(i32 {a.code}, i32 {b.code})`), "int") }
if (name == "world_count") { return val(emit_bind("call i32 @ludic_entity_count()"), "int") }
if (name == "world_kind") { let a = emit_expr(e.kids[0]); return val(emit_bind(`call i32 @ludic_kind(i32 {a.code})`), "int") }
if (name == "world_model_id") { let a = emit_expr(e.kids[0]); return val(emit_bind(`call i32 @ludic_model_id(ptr {a.code})`), "int") }
if (name == "world_query_next") { let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1]); return val(emit_bind(`call i32 @ludic_query_next(i32 {a.code}, i32 {b.code})`), "int") }
if (name == "world_register_prop") { let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1]); return val(emit_bind(`call i32 @ludic_register_prop(ptr {a.code}, i32 {b.code})`), "int") }
if (name == "world_attach_dyn") { let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1]); emit(" call void @ludic_attach_dyn(i32 "); emit(a.code); emit(", i32 "); emit(b.code); emit(")\n"); return val("0", "void") }
if (name == "world_detach_dyn") { let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1]); emit(" call void @ludic_detach_dyn(i32 "); emit(a.code); emit(", i32 "); emit(b.code); emit(")\n"); return val("0", "void") }
if (name == "world_spawn") { let a = emit_expr(e.kids[0]); return val(emit_bind(`call i32 @ludic_spawn(i32 {a.code})`), "int") }
if is_intrinsic(name) { return emit_intrinsic(name, e) }
if is_intrinsic2(name) { return emit_intrinsic2(name, e) }
if is_math_builtin(name) { return emit_math_builtin(name, e) }
# extern fn: a direct call to the declared link symbol (no @fn_ prefix)
let ext = find_extern(name)
if (ext != null) {
let eargs = new []ptr
let eatys = new []ptr
var ei = 0
while ei < len(e.kids) { let v = emit_expr(e.kids[ei]); push(eargs, v.code); push(eatys, v.ty); ei = ei + 1 }
let erl = llty(ext.ty)
emit(" ")
var erreg = "0"
if not (erl == "void") { erreg = nreg(); emit(erreg); emit(" = ") }
emit("call "); emit(erl); emit(" @"); emit(ext.a.s); emit("(")
ei = 0
while ei < len(eargs) {
if ei > 0 { emit(", ") }
emit(llty(eatys[ei])); emit(" "); emit(eargs[ei])
ei = ei + 1
}
emit(")\n")
return val(erreg, ext.ty)
}
var fn2 = find_fn(name)
var cname = name
if (fn2 == null) {
@ -234,6 +351,7 @@ fn emit_expr(e: Node) -> Val {
}
return emit_load_at(a, g_addr_ty)
}
if e.kind == S_EMIT { return emit_emit(e) } # emit as an expression -> cancelled flag
if e.kind == E_CALL { return emit_call(e) }
if e.kind == E_BIN { return emit_bin(e) }
if e.kind == E_UN {

View file

@ -4,6 +4,7 @@
# called only when the runtime defines them.
fn emit_system_fn(sys: Node) -> void {
g_cur_scene = sys.c # scene owning this handler (null if global) — for `become`
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0; nself = 0
ret_ty = "void"
let fbody = buf_new()
@ -20,24 +21,101 @@ fn emit_system_fn(sys: Node) -> void {
emit("}\n\n")
}
# one enable-gated call to @sys_<d.s> (skipped while the handler is disabled).
fn emit_call_one(d: Node) -> void {
let he = emit_bind(`load i32, ptr @HE_{d.s}`)
var hc = emit_bind(`icmp ne i32 {he}, 0`)
# a handler in a toggled layer also gates on its layer's @LE_ flag (byte-identical
# for layers that are never enabled/disabled, since d.b is only read when managed)
if (d.b != null) and is_toggled_layer(d.b.s) {
let le = emit_bind(`load i32, ptr @LE_{d.b.s}`)
let lc = emit_bind(`icmp ne i32 {le}, 0`)
hc = emit_bind(`and i1 {hc}, {lc}`)
}
# N5: an @Server handler (d.ival==1) runs only on the authority (@L_role==1).
# Unmarked and @Predicted handlers run on every peer. Offline @L_role defaults to
# 1, so the guard collapses to "run here" and a non-networked build is unchanged.
if d.ival == 1 {
let rv = emit_bind("load i32, ptr @L_role")
let rc = emit_bind(`icmp eq i32 {rv}, 1`)
hc = emit_bind(`and i1 {hc}, {rc}`)
}
let run = lbl("hrun"); let skip = lbl("hskip")
emit(" br i1 "); emit(hc); emit(", label %"); emit(run); emit(", label %"); emit(skip); emit("\n")
emit(run); emit(":\n call void @sys_"); emit(d.s); emit("()\n")
emit(" br label %"); emit(skip); emit("\n"); emit(skip); emit(":\n")
}
# Global handlers run first, then the active scene's layer handlers in
# declaration (draw) order. The active scene is snapshotted once per phase, so a
# `become` mid-phase takes effect at the next phase boundary — exactly one scene
# is active within any single phase.
fn emit_calls_for_phase(phase: ptr) -> void {
var i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_SYS and (d.ty == phase) { # skip a disabled handler
let he = emit_bind(`load i32, ptr @HE_{d.s}`)
let hc = emit_bind(`icmp ne i32 {he}, 0`)
let run = lbl("hrun"); let skip = lbl("hskip")
emit(" br i1 "); emit(hc); emit(", label %"); emit(run); emit(", label %"); emit(skip); emit("\n")
emit(run); emit(":\n call void @sys_"); emit(d.s); emit("()\n")
if d.kind == N_SYS and (d.ty == phase) and (d.c == null) { emit_call_one(d) }
i = i + 1
}
# any scene-owned handlers in this phase? gate them on one @L_scene snapshot.
var has_sc = false
i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_SYS and (d.ty == phase) and (d.c != null) { has_sc = true }; i = i + 1 }
if not has_sc { return }
let cs = emit_bind("load i32, ptr @L_scene")
i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_SYS and (d.ty == phase) and (d.c != null) {
let ce = emit_bind(`icmp eq i32 {cs}, {itoa(d.c.ival)}`)
let run = lbl("scrun"); let skip = lbl("scskip")
emit(" br i1 "); emit(ce); emit(", label %"); emit(run); emit(", label %"); emit(skip); emit("\n")
emit(run); emit(":\n")
emit_call_one(d)
emit(" br label %"); emit(skip); emit("\n"); emit(skip); emit(":\n")
}
i = i + 1
}
}
# @OnDespawn(Model) hooks compile to `@on_despawn_<Model>(entity)` functions that
# bind the model's properties and run the body — dispatched by kind at `despawn`.
# on enter / on exit compile to void functions @scene_enter_<Name> /
# @scene_exit_<Name>, called at the transition point (and enter at boot for the
# start scene). Emitted for every scene, empty body when the hook is absent.
fn emit_scene_fn(name: ptr, kind: ptr, body: Node) -> void {
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0; nself = 0
ret_ty = "void"
let fbody = buf_new()
falloc = buf_new()
let saved = code
code = fbody
if (body != null) { emit_block(body) }
# EV1: a `public` scene fires scene_<S>_enter / scene_<S>_exit after its block
let sev = `scene_{name}_{kind}`
if (not g_term) and (find_event(sev) != null) { emit(" call void @ev_"); emit(sev); emit("()\n") }
if not g_term { emit(" br label %ret\n") }
emit("ret:\n ret void\n")
code = saved
emit("define void @scene_"); emit(kind); emit("_"); emit(name); emit("() {\nentry:\n")
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
}
fn emit_scene_hooks() -> void {
var i = 0
while i < len(g_scenes) {
let sc = g_scenes[i]
g_cur_scene = sc
emit_scene_fn(sc.s, "enter", sc.a)
emit_scene_fn(sc.s, "exit", sc.b)
i = i + 1
}
}
# @OnDespawn(Model) hooks compile to `@on_despawn_<Model>(entity, reason)`
# functions that bind the model's properties and run the body — dispatched by
# kind at `despawn`. LC1: the `i32 %reason` parameter carries an EndReason; when
# the hook declared `reason: r`, `r` is bound as an int local reading it.
fn emit_despawn_hooks() -> void {
var i = 0
while i < len(g_ondespawn) {
@ -49,30 +127,608 @@ fn emit_despawn_hooks() -> void {
falloc = buf_new()
let saved = code
code = fbody
if (hk.ty != null) { # bind the reason: r name to %reason
let rslot = emit_alloca("i32")
emit(" store i32 %reason, ptr "); emit(rslot); emit("\n")
loc_push(hk.ty, rslot, "int")
}
emit_bind_props(model, "%e")
emit_block(hk.a)
if not g_term { emit(" br label %ret\n") }
emit("ret:\n ret void\n")
code = saved
emit("define void @on_despawn_"); emit(hk.s); emit("(i32 %e) {\nentry:\n")
emit("define void @on_despawn_"); emit(hk.s); emit("(i32 %e, i32 %reason) {\nentry:\n")
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
i = i + 1
}
emit_despawn_all_fn()
}
fn emit_game_main() -> void {
# every system becomes a function first
# LC1 "no silent deaths": at program shutdown every still-live entity's despawn
# hook fires with reason Quit, so teardown that must run on exit is not skipped.
# @L_despawn_all(reason) walks the live set and dispatches each entity by kind —
# the same per-model dispatch as `despawn`, but without freeing (the process is
# ending). Emitted only when the program has @OnDespawn hooks, so despawn-free
# programs are byte-for-byte unchanged.
fn emit_despawn_all_fn() -> void {
if len(g_ondespawn) == 0 { return }
let me = itoa(MAX_ENT)
emit("define void @L_despawn_all(i32 %reason) {\nentry:\n br label %loop\n")
emit("loop:\n %i = phi i32 [ 0, %entry ], [ %i1, %cont ]\n")
emit(" %n = load i32, ptr @L_entc\n %go = icmp slt i32 %i, %n\n")
emit(" br i1 %go, label %body, label %fin\n")
emit("body:\n %ap = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_alive, i32 0, i32 %i\n")
emit(" %al = load i32, ptr %ap\n %isa = icmp ne i32 %al, 0\n")
emit(" br i1 %isa, label %do, label %cont\n")
emit("do:\n %kp = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_kind, i32 0, i32 %i\n")
emit(" %k = load i32, ptr %kp\n")
var i = 0
while i < len(g_ondespawn) {
let mname = g_ondespawn[i].s
let si = itoa(i)
emit(" %c"); emit(si); emit(" = icmp eq i32 %k, "); emit(itoa(find_arch_id(mname))); emit("\n")
emit(" br i1 %c"); emit(si); emit(", label %hit"); emit(si); emit(", label %next"); emit(si); emit("\n")
emit("hit"); emit(si); emit(":\n call void @on_despawn_"); emit(mname); emit("(i32 %i, i32 %reason)\n")
let dev = `model_{mname}_despawn` # EV1: @Public despawn event at shutdown
if (find_event(dev) != null) { emit(" call void @ev_"); emit(dev); emit("(i32 %i, i32 %reason)\n") }
emit(" br label %next"); emit(si); emit("\n")
emit("next"); emit(si); emit(":\n")
i = i + 1
}
emit(" br label %cont\n")
emit("cont:\n %i1 = add i32 %i, 1\n br label %loop\n")
emit("fin:\n ret void\n}\n\n")
}
# EV0: each declared `event E` compiles to a `@ev_<E>(payload…)` function whose
# body is (1) its `@On(E)` listeners concatenated in declaration order — the
# closed, compile-time half — then (2) a loop over a runtime listener array, the
# open half a mod in another language joins through the C ABI. The payload fields
# arrive as params (%p0, %p1, …), bound by name so a listener body reads them bare
# (like a query/hook binding). Emitted only when g_events is non-empty, so an
# event-free program is byte-for-byte unchanged.
#
# The runtime half is the deliberate opt-in exception to "no dispatch tables":
# %Ev_<E> — the POD payload struct passed by pointer to foreign listeners
# @evL_<E> — a fixed-capacity [16 x ptr] array of foreign callbacks
# @evN_<E> — how many are registered (registration order = dispatch order)
# @ludic_on_<E>(ptr cb) -> i32 — the C ABI: a mod appends its callback
# A native Ludic listener costs a direct call; a foreign one costs one indirect
# call. With no foreign listeners registered the loop runs zero times (one branch).
const EV_CAP: int = 16
# EV6 — re-entrant emit is bounded: a listener may `emit` another event, but the
# nesting is capped so an event cycle traps as an early return instead of hanging
# the frame. @ev_depth counts the live dispatch nesting; past the cap a dispatch
# returns immediately (a cancellable event returns "not cancelled").
const EV_DEPTH_CAP: int = 32
fn emit_event_fns() -> void {
emith("@ev_depth = global i32 0\n") # EV6 re-entrancy counter
var e = 0
while e < len(g_events) {
let ev = g_events[e]
let en = ev.s
let cap = itoa(EV_CAP)
# --- module-level: payload struct + the foreign listener registry (into head)
# A cancellable event's payload carries a trailing i32 `cancelled` flag that a
# listener (native or foreign) can set; the caller reads it back.
emith("%Ev_"); emith(en); emith(" = type { ")
var t = 0
while t < len(ev.kids) {
if t > 0 { emith(", ") }
emith(llty(ev.kids[t].ty))
t = t + 1
}
if ev.ival == 1 { if len(ev.kids) > 0 { emith(", ") }; emith("i32") }
emith(" }\n")
emith("@evL_"); emith(en); emith(" = global ["); emith(cap); emith(" x ptr] zeroinitializer\n")
emith("@evN_"); emith(en); emith(" = global i32 0\n")
# EV5: a parallel owner array — -1 = program-scoped (never swept), >=0 = the
# entity that owns the listener (swept when that entity despawns).
emith("@evO_"); emith(en); emith(" = global ["); emith(cap); emith(" x i32] zeroinitializer\n")
# --- @ludic_on_<E>(cb): append a program-scoped callback, return a token
emit("define i32 @ludic_on_"); emit(en); emit("(ptr %cb) {\nentry:\n")
emit(" %n = load i32, ptr @evN_"); emit(en); emit("\n")
emit(" %full = icmp slt i32 %n, "); emit(cap); emit("\n")
emit(" br i1 %full, label %add, label %drop\n")
emit("add:\n %slot = getelementptr inbounds ["); emit(cap); emit(" x ptr], ptr @evL_"); emit(en); emit(", i32 0, i32 %n\n")
emit(" store ptr %cb, ptr %slot\n")
emit(" %oslot = getelementptr inbounds ["); emit(cap); emit(" x i32], ptr @evO_"); emit(en); emit(", i32 0, i32 %n\n")
emit(" store i32 -1, ptr %oslot\n")
emit(" %n1 = add i32 %n, 1\n store i32 %n1, ptr @evN_"); emit(en); emit("\n ret i32 %n\n")
emit("drop:\n ret i32 -1\n}\n\n") # registry full: reject (token -1)
# --- @ludic_on_entity_<E>(owner, cb): append an entity-scoped callback
emit("define i32 @ludic_on_entity_"); emit(en); emit("(i32 %owner, ptr %cb) {\nentry:\n")
emit(" %n = load i32, ptr @evN_"); emit(en); emit("\n")
emit(" %full = icmp slt i32 %n, "); emit(cap); emit("\n")
emit(" br i1 %full, label %add, label %drop\n")
emit("add:\n %slot = getelementptr inbounds ["); emit(cap); emit(" x ptr], ptr @evL_"); emit(en); emit(", i32 0, i32 %n\n")
emit(" store ptr %cb, ptr %slot\n")
emit(" %oslot = getelementptr inbounds ["); emit(cap); emit(" x i32], ptr @evO_"); emit(en); emit(", i32 0, i32 %n\n")
emit(" store i32 %owner, ptr %oslot\n")
emit(" %n1 = add i32 %n, 1\n store i32 %n1, ptr @evN_"); emit(en); emit("\n ret i32 %n\n")
emit("drop:\n ret i32 -1\n}\n\n")
# --- @ludic_off_<E>(token): remove a listener (tombstone the slot to null)
emit("define void @ludic_off_"); emit(en); emit("(i32 %tok) {\nentry:\n")
emit(" %n = load i32, ptr @evN_"); emit(en); emit("\n")
emit(" %lo = icmp sge i32 %tok, 0\n %hi = icmp slt i32 %tok, %n\n %ok = and i1 %lo, %hi\n")
emit(" br i1 %ok, label %do, label %skip\n")
emit("do:\n %slot = getelementptr inbounds ["); emit(cap); emit(" x ptr], ptr @evL_"); emit(en); emit(", i32 0, i32 %tok\n")
emit(" store ptr null, ptr %slot\n br label %skip\n")
emit("skip:\n ret void\n}\n\n")
# --- @ev_<E>(payload): fire compile-time listeners, then foreign ones
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0; nself = 0
ret_ty = "void"
let fbody = buf_new()
falloc = buf_new()
let saved = code
code = fbody
# EV6: bound re-entrant emit — past EV_DEPTH_CAP, return without dispatching
emit(" %evd = load i32, ptr @ev_depth\n")
emit(" %evover = icmp sge i32 %evd, "); emit(itoa(EV_DEPTH_CAP)); emit("\n")
emit(" br i1 %evover, label %evcap, label %evgo\n")
emit("evcap:\n")
if ev.ival == 1 { emit(" ret i32 0\n") } else { emit(" ret void\n") }
emit("evgo:\n")
emit(" %evd1 = add i32 %evd, 1\n store i32 %evd1, ptr @ev_depth\n")
# a stack copy of the payload, passed by pointer to every foreign listener
let pl = emit_alloca(`%Ev_{en}`)
# bind each field: store the param into the payload struct AND a name slot the
# compile-time listener bodies read bare.
var f = 0
while f < len(ev.kids) {
let fd = ev.kids[f]
let lt = llty(fd.ty)
let pa = nreg(); emit(" "); emit(pa); emit(" = getelementptr inbounds %Ev_"); emit(en); emit(", ptr "); emit(pl); emit(", i32 0, i32 "); emit(itoa(f)); emit("\n")
emit(" store "); emit(lt); emit(" %p"); emit(itoa(f)); emit(", ptr "); emit(pa); emit("\n")
let slot = emit_alloca(lt)
emit(" store "); emit(lt); emit(" %p"); emit(itoa(f)); emit(", ptr "); emit(slot); emit("\n")
loc_push(fd.s, slot, fd.ty)
f = f + 1
}
# cancellable: zero the flag and expose its address to `cancel` in the listeners
var caddr = null
if ev.ival == 1 {
caddr = nreg(); emit(" "); emit(caddr); emit(" = getelementptr inbounds %Ev_"); emit(en); emit(", ptr "); emit(pl); emit(", i32 0, i32 "); emit(itoa(len(ev.kids))); emit("\n")
emit(" store i32 0, ptr "); emit(caddr); emit("\n")
g_cancel_addr = caddr
}
let base = nloc # listeners share the params but not each other's locals
var i = 0
while i < len(g_onlisten) {
if (g_onlisten[i].s == en) { nloc = base; g_term = false; emit_block(g_onlisten[i].a) }
i = i + 1
}
# the open half: walk the foreign callback array in registration order
if not g_term {
let ci = emit_alloca("i32"); emit(" store i32 0, ptr "); emit(ci); emit("\n")
let L = lbl("evl"); let B = lbl("evb"); let D = lbl("evd")
emit(" br label %"); emit(L); emit("\n")
emit(L); emit(":\n")
let iv = emit_bind(`load i32, ptr {ci}`)
let nn = emit_bind(`load i32, ptr @evN_{en}`)
let go = emit_bind(`icmp slt i32 {iv}, {nn}`)
emit(" br i1 "); emit(go); emit(", label %"); emit(B); emit(", label %"); emit(D); emit("\n")
emit(B); emit(":\n")
let sp = nreg(); emit(" "); emit(sp); emit(" = getelementptr inbounds ["); emit(cap); emit(" x ptr], ptr @evL_"); emit(en); emit(", i32 0, i32 "); emit(iv); emit("\n")
let cb = emit_bind(`load ptr, ptr {sp}`)
let cbn = emit_bind(`icmp eq ptr {cb}, null`) # EV5: a removed (off) listener is null — skip it
let doc = lbl("evdo"); let skp = lbl("evsk")
emit(" br i1 "); emit(cbn); emit(", label %"); emit(skp); emit(", label %"); emit(doc); emit("\n")
emit(doc); emit(":\n")
emit(" call void "); emit(cb); emit("(ptr "); emit(pl); emit(")\n")
emit(" br label %"); emit(skp); emit("\n")
emit(skp); emit(":\n")
let i2 = emit_bind(`add i32 {iv}, 1`)
emit(" store i32 "); emit(i2); emit(", ptr "); emit(ci); emit("\n")
emit(" br label %"); emit(L); emit("\n")
emit(D); emit(":\n")
emit(" br label %ret\n")
}
emit("ret:\n")
emit(" %evdd = load i32, ptr @ev_depth\n %evdd1 = sub i32 %evdd, 1\n store i32 %evdd1, ptr @ev_depth\n") # EV6: leave one nesting level
if ev.ival == 1 { # return the (possibly set) cancelled flag
let cv = emit_bind(`load i32, ptr {caddr}`)
emit(" ret i32 "); emit(cv); emit("\n")
} else { emit(" ret void\n") }
g_cancel_addr = null # leaves listener scope
code = saved
var rt = "void"; if ev.ival == 1 { rt = "i32" }
emit("define "); emit(rt); emit(" @ev_"); emit(en); emit("(")
var g = 0
while g < len(ev.kids) {
if g > 0 { emit(", ") }
emit(llty(ev.kids[g].ty)); emit(" %p"); emit(itoa(g))
g = g + 1
}
emit(") {\nentry:\n")
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
e = e + 1
}
# EV5: @ludic_sweep_entity(owner) — remove every entity-scoped listener owned by
# a despawning entity, across all events. Called from `despawn`, so a listener
# bound to an entity cannot outlive it (the Node listener-leak footgun, gone).
let capS = itoa(EV_CAP)
emit("define void @ludic_sweep_entity(i32 %owner) {\nentry:\n %ci = alloca i32\n store i32 0, ptr %ci\n br label %sw0\n")
var e2 = 0
while e2 < len(g_events) {
let en2 = g_events[e2].s; let sk = itoa(e2)
emit("sw"); emit(sk); emit(":\n")
emit(" %swi"); emit(sk); emit(" = load i32, ptr %ci\n")
emit(" %swn"); emit(sk); emit(" = load i32, ptr @evN_"); emit(en2); emit("\n")
emit(" %swg"); emit(sk); emit(" = icmp slt i32 %swi"); emit(sk); emit(", %swn"); emit(sk); emit("\n")
emit(" br i1 %swg"); emit(sk); emit(", label %swb"); emit(sk); emit(", label %swd"); emit(sk); emit("\n")
emit("swb"); emit(sk); emit(":\n")
emit(" %swop"); emit(sk); emit(" = getelementptr inbounds ["); emit(capS); emit(" x i32], ptr @evO_"); emit(en2); emit(", i32 0, i32 %swi"); emit(sk); emit("\n")
emit(" %swov"); emit(sk); emit(" = load i32, ptr %swop"); emit(sk); emit("\n")
emit(" %swm"); emit(sk); emit(" = icmp eq i32 %swov"); emit(sk); emit(", %owner\n")
emit(" br i1 %swm"); emit(sk); emit(", label %swh"); emit(sk); emit(", label %swx"); emit(sk); emit("\n")
emit("swh"); emit(sk); emit(":\n")
emit(" %swlp"); emit(sk); emit(" = getelementptr inbounds ["); emit(capS); emit(" x ptr], ptr @evL_"); emit(en2); emit(", i32 0, i32 %swi"); emit(sk); emit("\n")
emit(" store ptr null, ptr %swlp"); emit(sk); emit("\n br label %swx"); emit(sk); emit("\n")
emit("swx"); emit(sk); emit(":\n")
emit(" %swi1"); emit(sk); emit(" = add i32 %swi"); emit(sk); emit(", 1\n store i32 %swi1"); emit(sk); emit(", ptr %ci\n br label %sw"); emit(sk); emit("\n")
emit("swd"); emit(sk); emit(":\n store i32 0, ptr %ci\n")
if (e2 + 1) < len(g_events) { emit(" br label %sw"); emit(itoa(e2 + 1)); emit("\n") }
else { emit(" ret void\n") }
e2 = e2 + 1
}
emit("}\n\n")
}
# EV2 — the world table: a generated reflection ABI so a mod reads and writes
# entity state *by name*, without having compiled against the game. This is the
# "game table" that lets a modding layer be ported in. Generated from the
# compile-time schema, so it never drifts. Emitted only for an ECS program that
# also declares events (a modding program), so event-free games stay byte-exact.
#
# i32 ludic_prop_id(name) property name -> stable id (-1 = none)
# i32 ludic_field_id(prop, name) field name within a property -> index
# i64 ludic_get(entity, prop, field) read a field (sign-extended to i64)
# void ludic_set(entity, prop, field, i64) write a field (truncated to i32)
# i32 ludic_has(entity, prop) does the entity have the property?
#
# First cut: integer component fields (the common case — hp, x, amount). Property
# ids are assignment order in the source; field ids are declaration order.
fn emit_world_table() -> void {
let me = itoa(MAX_ENT)
emith("declare i32 @strcmp(ptr, ptr)\n")
# EV7 — schema opening: a mod can register a brand-new component at runtime.
# Compile-time components take prop ids 0..NC-1; mod-defined ones take NC.. and
# live in these parallel registries (fixed capacity 32). Storage is a flat
# malloc'd [MAX_ENT x nfields x i32] with a parallel has-flag array. get/set/has
# and prop_id fall through to this table for a prop id >= NC.
var ncomp = 0
var ci0 = 0
while ci0 < len(prog) { if prog[ci0].kind == N_COMP { ncomp = ncomp + 1 }; ci0 = ci0 + 1 }
let NC = itoa(ncomp)
emith("@dyn_count = global i32 0\n")
emith("@dynS = global [32 x ptr] zeroinitializer\n") # storage base per dyn component
emith("@dynH = global [32 x ptr] zeroinitializer\n") # has-flag array per dyn component
emith("@dynF = global [32 x i32] zeroinitializer\n") # field count per dyn component
emith("@dynName = global [32 x ptr] zeroinitializer\n") # name per dyn component
# ludic_prop_id(name): strcmp against each property's name constant
emit("define i32 @ludic_prop_id(ptr %name) {\nentry:\n")
var k = 0
var i = 0
while i < len(prog) {
if prog[i].kind == N_COMP {
let sc = emit_str_const(prog[i].s); let sk = itoa(k)
emit(" %c"); emit(sk); emit(" = call i32 @strcmp(ptr %name, ptr "); emit(sc); emit(")\n")
emit(" %e"); emit(sk); emit(" = icmp eq i32 %c"); emit(sk); emit(", 0\n")
emit(" br i1 %e"); emit(sk); emit(", label %h"); emit(sk); emit(", label %n"); emit(sk); emit("\n")
emit("h"); emit(sk); emit(":\n ret i32 "); emit(sk); emit("\n")
emit("n"); emit(sk); emit(":\n")
k = k + 1
}
i = i + 1
}
# EV7: not a compile-time component — search the dynamic (mod-registered) names
emit(" %dpi = alloca i32\n store i32 0, ptr %dpi\n br label %dpl\n")
emit("dpl:\n %di = load i32, ptr %dpi\n %dn = load i32, ptr @dyn_count\n %dg = icmp slt i32 %di, %dn\n br i1 %dg, label %dpb, label %dpnone\n")
emit("dpb:\n %dnp = getelementptr inbounds [32 x ptr], ptr @dynName, i32 0, i32 %di\n %dname = load ptr, ptr %dnp\n %dcmp = call i32 @strcmp(ptr %name, ptr %dname)\n %deq = icmp eq i32 %dcmp, 0\n br i1 %deq, label %dphit, label %dpnext\n")
emit("dphit:\n %drid = add i32 %di, "); emit(NC); emit("\n ret i32 %drid\n")
emit("dpnext:\n %di1 = add i32 %di, 1\n store i32 %di1, ptr %dpi\n br label %dpl\n")
emit("dpnone:\n ret i32 -1\n}\n\n")
# ludic_field_id(prop, name): within the matched property, strcmp each field name
emit("define i32 @ludic_field_id(i32 %p, ptr %name) {\nentry:\n")
k = 0; i = 0
while i < len(prog) {
if prog[i].kind == N_COMP {
let c = prog[i]; let sk = itoa(k)
emit(" %pm"); emit(sk); emit(" = icmp eq i32 %p, "); emit(sk); emit("\n")
emit(" br i1 %pm"); emit(sk); emit(", label %pk"); emit(sk); emit(", label %pn"); emit(sk); emit("\n")
emit("pk"); emit(sk); emit(":\n")
var f = 0
while f < len(c.kids) {
let fc = emit_str_const(c.kids[f].s); let fk = `{sk}_{itoa(f)}`
emit(" %fc"); emit(fk); emit(" = call i32 @strcmp(ptr %name, ptr "); emit(fc); emit(")\n")
emit(" %fe"); emit(fk); emit(" = icmp eq i32 %fc"); emit(fk); emit(", 0\n")
emit(" br i1 %fe"); emit(fk); emit(", label %fh"); emit(fk); emit(", label %fn"); emit(fk); emit("\n")
emit("fh"); emit(fk); emit(":\n ret i32 "); emit(itoa(f)); emit("\n")
emit("fn"); emit(fk); emit(":\n")
f = f + 1
}
emit(" ret i32 -1\n")
emit("pn"); emit(sk); emit(":\n")
k = k + 1
}
i = i + 1
}
emit(" ret i32 -1\n}\n\n")
# ludic_get / ludic_set / ludic_has dispatch prop -> @S_/@H_ storage; the field
# address is slot + field*4 (integer fields).
# ludic_get/ludic_set dispatch prop -> component storage, then the field id to a
# constant struct GEP (so mixed layouts and ptr/byte fields are addressed
# correctly, not assumed 4-byte). Values cross the ABI as i64: int/bool/fixed
# sign-extend, byte zero-extends, ptr round-trips through ptrtoint/inttoptr.
emit("define i64 @ludic_get(i32 %e, i32 %p, i32 %f) {\nentry:\n")
k = 0; i = 0
while i < len(prog) {
if prog[i].kind == N_COMP {
let c = prog[i]; let cn = c.s; let sk = itoa(k)
emit(" %m"); emit(sk); emit(" = icmp eq i32 %p, "); emit(sk); emit("\n")
emit(" br i1 %m"); emit(sk); emit(", label %g"); emit(sk); emit(", label %gn"); emit(sk); emit("\n")
emit("g"); emit(sk); emit(":\n")
emit(" %s"); emit(sk); emit(" = getelementptr inbounds ["); emit(me); emit(" x %Cmp_"); emit(cn); emit("], ptr @S_"); emit(cn); emit(", i32 0, i32 %e\n")
var fj = 0
while fj < len(c.kids) {
let ft = llty(c.kids[fj].ty); let fk = `{sk}_{itoa(fj)}`
emit(" %gm"); emit(fk); emit(" = icmp eq i32 %f, "); emit(itoa(fj)); emit("\n")
emit(" br i1 %gm"); emit(fk); emit(", label %gf"); emit(fk); emit(", label %gk"); emit(fk); emit("\n")
emit("gf"); emit(fk); emit(":\n")
emit(" %ga"); emit(fk); emit(" = getelementptr inbounds %Cmp_"); emit(cn); emit(", ptr %s"); emit(sk); emit(", i32 0, i32 "); emit(itoa(fj)); emit("\n")
if (ft == "ptr") {
emit(" %gl"); emit(fk); emit(" = load ptr, ptr %ga"); emit(fk); emit("\n")
emit(" %gr"); emit(fk); emit(" = ptrtoint ptr %gl"); emit(fk); emit(" to i64\n ret i64 %gr"); emit(fk); emit("\n")
} else { if (ft == "i8") {
emit(" %gl"); emit(fk); emit(" = load i8, ptr %ga"); emit(fk); emit("\n")
emit(" %gr"); emit(fk); emit(" = zext i8 %gl"); emit(fk); emit(" to i64\n ret i64 %gr"); emit(fk); emit("\n")
} else {
emit(" %gl"); emit(fk); emit(" = load i32, ptr %ga"); emit(fk); emit("\n")
emit(" %gr"); emit(fk); emit(" = sext i32 %gl"); emit(fk); emit(" to i64\n ret i64 %gr"); emit(fk); emit("\n")
} }
emit("gk"); emit(fk); emit(":\n")
fj = fj + 1
}
emit(" ret i64 0\n")
emit("gn"); emit(sk); emit(":\n")
k = k + 1
}
i = i + 1
}
# EV7: prop id >= NC -> a mod-registered component; index its flat storage
emit(" %gdyn = sub i32 %p, "); emit(NC); emit("\n")
emit(" %gdlo = icmp sge i32 %gdyn, 0\n %gdc = load i32, ptr @dyn_count\n %gdhi = icmp slt i32 %gdyn, %gdc\n %gdok = and i1 %gdlo, %gdhi\n br i1 %gdok, label %gdyng, label %gdnone\n")
emit("gdyng:\n %gsp = getelementptr inbounds [32 x ptr], ptr @dynS, i32 0, i32 %gdyn\n %gbase = load ptr, ptr %gsp\n")
emit(" %gfp = getelementptr inbounds [32 x i32], ptr @dynF, i32 0, i32 %gdyn\n %gnf = load i32, ptr %gfp\n %grow = mul i32 %e, %gnf\n %gidx = add i32 %grow, %f\n")
emit(" %gaddr = getelementptr inbounds i32, ptr %gbase, i32 %gidx\n %gv = load i32, ptr %gaddr\n %gr = sext i32 %gv to i64\n ret i64 %gr\n")
emit("gdnone:\n ret i64 0\n}\n\n")
emit("define void @ludic_set(i32 %e, i32 %p, i32 %f, i64 %val) {\nentry:\n")
k = 0; i = 0
while i < len(prog) {
if prog[i].kind == N_COMP {
let c = prog[i]; let cn = c.s; let sk = itoa(k)
emit(" %m"); emit(sk); emit(" = icmp eq i32 %p, "); emit(sk); emit("\n")
emit(" br i1 %m"); emit(sk); emit(", label %g"); emit(sk); emit(", label %gn"); emit(sk); emit("\n")
emit("g"); emit(sk); emit(":\n")
emit(" %s"); emit(sk); emit(" = getelementptr inbounds ["); emit(me); emit(" x %Cmp_"); emit(cn); emit("], ptr @S_"); emit(cn); emit(", i32 0, i32 %e\n")
var fj = 0
while fj < len(c.kids) {
let ft = llty(c.kids[fj].ty); let fk = `{sk}_{itoa(fj)}`
emit(" %sm"); emit(fk); emit(" = icmp eq i32 %f, "); emit(itoa(fj)); emit("\n")
emit(" br i1 %sm"); emit(fk); emit(", label %sf"); emit(fk); emit(", label %sk"); emit(fk); emit("\n")
emit("sf"); emit(fk); emit(":\n")
emit(" %sa"); emit(fk); emit(" = getelementptr inbounds %Cmp_"); emit(cn); emit(", ptr %s"); emit(sk); emit(", i32 0, i32 "); emit(itoa(fj)); emit("\n")
if (ft == "ptr") {
emit(" %sp"); emit(fk); emit(" = inttoptr i64 %val to ptr\n")
emit(" store ptr %sp"); emit(fk); emit(", ptr %sa"); emit(fk); emit("\n ret void\n")
} else { if (ft == "i8") {
emit(" %sb"); emit(fk); emit(" = trunc i64 %val to i8\n")
emit(" store i8 %sb"); emit(fk); emit(", ptr %sa"); emit(fk); emit("\n ret void\n")
} else {
emit(" %sw"); emit(fk); emit(" = trunc i64 %val to i32\n")
emit(" store i32 %sw"); emit(fk); emit(", ptr %sa"); emit(fk); emit("\n ret void\n")
} }
emit("sk"); emit(fk); emit(":\n")
fj = fj + 1
}
emit(" ret void\n")
emit("gn"); emit(sk); emit(":\n")
k = k + 1
}
i = i + 1
}
# EV7: prop id >= NC -> a mod-registered component
emit(" %sdyn = sub i32 %p, "); emit(NC); emit("\n")
emit(" %sdlo = icmp sge i32 %sdyn, 0\n %sdc = load i32, ptr @dyn_count\n %sdhi = icmp slt i32 %sdyn, %sdc\n %sdok = and i1 %sdlo, %sdhi\n br i1 %sdok, label %sdyng, label %sdnone\n")
emit("sdyng:\n %ssp = getelementptr inbounds [32 x ptr], ptr @dynS, i32 0, i32 %sdyn\n %sbase = load ptr, ptr %ssp\n")
emit(" %sfp = getelementptr inbounds [32 x i32], ptr @dynF, i32 0, i32 %sdyn\n %snf = load i32, ptr %sfp\n %srow = mul i32 %e, %snf\n %sidx = add i32 %srow, %f\n")
emit(" %saddr = getelementptr inbounds i32, ptr %sbase, i32 %sidx\n %sv = trunc i64 %val to i32\n store i32 %sv, ptr %saddr\n ret void\n")
emit("sdnone:\n ret void\n}\n\n")
emit("define i32 @ludic_has(i32 %e, i32 %p) {\nentry:\n")
k = 0; i = 0
while i < len(prog) {
if prog[i].kind == N_COMP {
let cn = prog[i].s; let sk = itoa(k)
emit(" %m"); emit(sk); emit(" = icmp eq i32 %p, "); emit(sk); emit("\n")
emit(" br i1 %m"); emit(sk); emit(", label %g"); emit(sk); emit(", label %gn"); emit(sk); emit("\n")
emit("g"); emit(sk); emit(":\n")
emit(" %hp"); emit(sk); emit(" = getelementptr inbounds ["); emit(me); emit(" x i8], ptr @H_"); emit(cn); emit(", i32 0, i32 %e\n")
emit(" %hv"); emit(sk); emit(" = load i8, ptr %hp"); emit(sk); emit("\n")
emit(" %hr"); emit(sk); emit(" = zext i8 %hv"); emit(sk); emit(" to i32\n ret i32 %hr"); emit(sk); emit("\n")
emit("gn"); emit(sk); emit(":\n")
k = k + 1
}
i = i + 1
}
# EV7: prop id >= NC -> a mod-registered component's has-flag array
emit(" %hdyn = sub i32 %p, "); emit(NC); emit("\n")
emit(" %hdlo = icmp sge i32 %hdyn, 0\n %hdc = load i32, ptr @dyn_count\n %hdhi = icmp slt i32 %hdyn, %hdc\n %hdok = and i1 %hdlo, %hdhi\n br i1 %hdok, label %hdyng, label %hdnone\n")
emit("hdyng:\n %hhp = getelementptr inbounds [32 x ptr], ptr @dynH, i32 0, i32 %hdyn\n %hh = load ptr, ptr %hhp\n %hslot = getelementptr inbounds i8, ptr %hh, i32 %e\n %hval = load i8, ptr %hslot\n %hres = zext i8 %hval to i32\n ret i32 %hres\n")
emit("hdnone:\n ret i32 0\n}\n\n")
# ludic_register_prop(name, nfields) -> prop id — a mod declares a NEW component.
# Allocates flat [MAX_ENT x nfields x i32] storage + a MAX_ENT has-flag array,
# zeroed. The returned id works with get/set/has/attach exactly like a built-in.
emit("define i32 @ludic_register_prop(ptr %name, i32 %nfields) {\nentry:\n")
emit(" %dc = load i32, ptr @dyn_count\n %full = icmp slt i32 %dc, 32\n br i1 %full, label %do, label %rej\n")
emit("do:\n %nf4 = mul i32 %nfields, 4\n %sz = mul i32 %nf4, "); emit(me); emit("\n %szl = sext i32 %sz to i64\n")
emit(" %buf = call ptr @malloc(i64 %szl)\n call ptr @memset(ptr %buf, i32 0, i64 %szl)\n")
emit(" %sp = getelementptr inbounds [32 x ptr], ptr @dynS, i32 0, i32 %dc\n store ptr %buf, ptr %sp\n")
emit(" %hbuf = call ptr @malloc(i64 "); emit(me); emit(")\n call ptr @memset(ptr %hbuf, i32 0, i64 "); emit(me); emit(")\n")
emit(" %hp = getelementptr inbounds [32 x ptr], ptr @dynH, i32 0, i32 %dc\n store ptr %hbuf, ptr %hp\n")
emit(" %fp = getelementptr inbounds [32 x i32], ptr @dynF, i32 0, i32 %dc\n store i32 %nfields, ptr %fp\n")
emit(" %np = getelementptr inbounds [32 x ptr], ptr @dynName, i32 0, i32 %dc\n store ptr %name, ptr %np\n")
emit(" %id = add i32 %dc, "); emit(NC); emit("\n %dc1 = add i32 %dc, 1\n store i32 %dc1, ptr @dyn_count\n ret i32 %id\n")
emit("rej:\n ret i32 -1\n}\n\n")
# ludic_attach_dyn / ludic_detach_dyn(entity, prop) — set/clear a mod-registered
# component's has-flag on an entity (the dynamic analogue of attach/detach).
emit("define void @ludic_attach_dyn(i32 %e, i32 %p) {\nentry:\n")
emit(" %dyn = sub i32 %p, "); emit(NC); emit("\n %hp = getelementptr inbounds [32 x ptr], ptr @dynH, i32 0, i32 %dyn\n %h = load ptr, ptr %hp\n %slot = getelementptr inbounds i8, ptr %h, i32 %e\n store i8 1, ptr %slot\n ret void\n}\n\n")
emit("define void @ludic_detach_dyn(i32 %e, i32 %p) {\nentry:\n")
emit(" %dyn = sub i32 %p, "); emit(NC); emit("\n %hp = getelementptr inbounds [32 x ptr], ptr @dynH, i32 0, i32 %dyn\n %h = load ptr, ptr %hp\n %slot = getelementptr inbounds i8, ptr %h, i32 %e\n store i8 0, ptr %slot\n ret void\n}\n\n")
# ludic_entity_count / ludic_kind / ludic_model_id — a mod scans the world and
# identifies each entity's model, then reads/writes it with get/set/has above.
emit("define i32 @ludic_entity_count() {\nentry:\n %n = load i32, ptr @L_entc\n ret i32 %n\n}\n\n")
emit("define i32 @ludic_kind(i32 %e) {\nentry:\n")
emit(" %kp = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_kind, i32 0, i32 %e\n")
emit(" %k = load i32, ptr %kp\n ret i32 %k\n}\n\n")
emit("define i32 @ludic_model_id(ptr %name) {\nentry:\n")
k = 0; i = 0
while i < len(prog) {
if prog[i].kind == N_ARCH {
let sc = emit_str_const(prog[i].s); let sk = itoa(k)
emit(" %mdc"); emit(sk); emit(" = call i32 @strcmp(ptr %name, ptr "); emit(sc); emit(")\n")
emit(" %mde"); emit(sk); emit(" = icmp eq i32 %mdc"); emit(sk); emit(", 0\n")
emit(" br i1 %mde"); emit(sk); emit(", label %mdh"); emit(sk); emit(", label %mdn"); emit(sk); emit("\n")
emit("mdh"); emit(sk); emit(":\n ret i32 "); emit(itoa(find_arch_id(prog[i].s))); emit("\n")
emit("mdn"); emit(sk); emit(":\n")
k = k + 1
}
i = i + 1
}
emit(" ret i32 -1\n}\n\n")
# ludic_spawn(model_id) -> entity — a mod creates a new entity. Each model gets a
# @ludic_spawn_<M> that reuses the compiler's own spawn lowering (alloc, kind,
# component defaults, @OnSpawn, and the model_<M>_spawn event), so a mod-spawned
# entity is indistinguishable from one born in source. A dispatcher routes the id.
i = 0
while i < len(prog) {
if prog[i].kind == N_ARCH {
let m = prog[i].s
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0; nself = 0
ret_ty = "int"
let fbody = buf_new()
falloc = buf_new()
let saved = code
code = fbody
let syn = node(S_SPAWN); syn.s = m # a defaults-only spawn of model m
let se = emit_spawn(syn)
emit(" ret i32 "); emit(se); emit("\n")
code = saved
emit("define i32 @ludic_spawn_"); emit(m); emit("() {\nentry:\n")
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
}
i = i + 1
}
emit("define i32 @ludic_spawn(i32 %m) {\nentry:\n")
k = 0; i = 0
while i < len(prog) {
if prog[i].kind == N_ARCH {
let m = prog[i].s; let sk = itoa(k)
emit(" %sm"); emit(sk); emit(" = icmp eq i32 %m, "); emit(itoa(find_arch_id(m))); emit("\n")
emit(" br i1 %sm"); emit(sk); emit(", label %sh"); emit(sk); emit(", label %sn"); emit(sk); emit("\n")
emit("sh"); emit(sk); emit(":\n %sr"); emit(sk); emit(" = call i32 @ludic_spawn_"); emit(m); emit("()\n ret i32 %sr"); emit(sk); emit("\n")
emit("sn"); emit(sk); emit(":\n")
k = k + 1
}
i = i + 1
}
emit(" ret i32 -1\n}\n\n")
# ludic_query_next(prop_id, from) -> the next live entity (>= from) that has the
# property, or -1. A mod iterates: for (e = query_next(p, 0); e >= 0; e =
# query_next(p, e+1)). Reuses ludic_has for the membership test.
emit("define i32 @ludic_query_next(i32 %p, i32 %from) {\nentry:\n")
emit(" %n = load i32, ptr @L_entc\n br label %loop\n")
emit("loop:\n %e = phi i32 [ %from, %entry ], [ %e1, %cont ]\n")
emit(" %go = icmp slt i32 %e, %n\n br i1 %go, label %body, label %none\n")
emit("body:\n %ap = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_alive, i32 0, i32 %e\n")
emit(" %al = load i32, ptr %ap\n %isa = icmp ne i32 %al, 0\n br i1 %isa, label %chk, label %cont\n")
emit("chk:\n %h = call i32 @ludic_has(i32 %e, i32 %p)\n %hit = icmp ne i32 %h, 0\n br i1 %hit, label %hitb, label %cont\n")
emit("hitb:\n ret i32 %e\n")
emit("cont:\n %e1 = add i32 %e, 1\n br label %loop\n")
emit("none:\n ret i32 -1\n}\n\n")
}
# N5 — the drivable sim (NETWORKING-DESIGN §5). The per-frame phases the auto-loop
# runs are also exposed as callables, so a game that owns its `entry` loop can
# drive the simulation itself (for prediction/rollback, replay, headless tests, or
# AI). tick_fixed() runs the sim phases; tick_render() runs Render.
fn emit_tick_helpers() -> void {
emit("define void @L_tick_fixed() {\nentry:\n")
ll_t = 0; ll_lbl = 0
emit_calls_for_phase("Input")
emit_calls_for_phase("FixedUpdate")
emit_calls_for_phase("Update")
emit_calls_for_phase("LateUpdate")
emit(" ret void\n}\n\n")
emit("define void @L_tick_render() {\nentry:\n")
ll_t = 0; ll_lbl = 0
emit_calls_for_phase("Render")
emit(" ret void\n}\n\n")
}
# system functions + lifecycle hooks + the drivable tick helpers — shared by the
# auto-loop game (emit_game_main) and an entry-driven game that owns its own loop.
fn emit_game_defs() -> void {
var i = 0
while i < len(prog) { if prog[i].kind == N_SYS { emit_system_fn(prog[i]) }; i = i + 1 }
emit_despawn_hooks()
emit_scene_hooks()
emit_tick_helpers()
}
fn emit_game_main() -> void {
emit_game_defs()
emit("define i32 @main(i32 %argc, ptr %argv) {\nentry:\n")
emit(" store i32 %argc, ptr @L_argc\n")
emit(" store ptr %argv, ptr @L_argv\n")
if (find_fn("rt_init") != null) { emit(" call void @fn_rt_init()\n") }
emit_calls_for_phase("Start")
# enter the start scene once, after boot: store its id and run its on-enter.
if len(g_scenes) > 0 {
emit(" store i32 "); emit(itoa(g_start_scene)); emit(", ptr @L_scene\n")
var si = 0
while si < len(g_scenes) {
if (g_scenes[si].ival == g_start_scene) { emit(" call void @scene_enter_"); emit(g_scenes[si].s); emit("()\n") }
si = si + 1
}
}
if (find_event("program_start") != null) { emit(" call void @ev_program_start()\n") } # EV1: @Public @OnStart
emit(" br label %loop\n")
emit("loop:\n")
let r = emit_bind("load i32, ptr @L_running")
@ -97,7 +753,9 @@ fn emit_game_main() -> void {
emit_calls_for_phase("Render")
emit(" br label %loop\n")
emit("done:\n")
if len(g_ondespawn) > 0 { emit(" call void @L_despawn_all(i32 2)\n") } # LC1: every survivor's @OnDespawn fires with reason Quit
emit_calls_for_phase("OnQuit") # @OnQuit shutdown hooks run once, before teardown
if (find_event("program_quit") != null) { emit(" call void @ev_program_quit()\n") } # EV1: @Public @OnQuit
if (find_fn("rt_shutdown") != null) { emit(" call void @fn_rt_shutdown()\n") }
emit(" ret i32 0\n}\n")
}

View file

@ -102,6 +102,27 @@ fn emit_header() -> void {
}
}
# One `declare <ret> @<sym>(<argtys>)` per `extern fn`, so the linker resolves the
# call to the bound symbol. Emitted after the header; a program with no `extern fn`
# emits nothing here, so un-networked builds stay byte-identical.
fn emit_extern_decls() -> void {
var i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_EXTERN {
emith("declare "); emith(llty(d.ty)); emith(" @"); emith(d.a.s); emith("(")
var f = 0
while f < len(d.kids) {
if f > 0 { emith(", ") }
emith(llty(d.kids[f].ty))
f = f + 1
}
emith(")\n")
}
i = i + 1
}
}
# The string runtime, emitted (once) into any program that uses `+`/`==`/`!=`
# on strings. Hand-written IR over NUL-terminated byte buffers: str_eq walks both
# until a mismatch or a shared terminator; str_concat measures both, mallocs

View file

@ -26,14 +26,26 @@ fn emit_machine(st: Node) -> void {
nmach = nmach - 1
}
# `become Name` — a machine state transition when Name is a state of an
# enclosing `machine`, otherwise a scene transition. A scene transition runs the
# source scene's on-exit, stores the target scene id into @L_scene, and runs the
# target's on-enter (two direct calls and a store — no dispatch table).
fn emit_become(st: Node) -> void {
if nmach == 0 { perr("'become' outside a machine") }
if nmach > 0 { # inside a machine: try a state first
let m = mach_stk[nmach - 1]
var target: Node = null
var i = 0
while i < len(m.kids) { if (m.kids[i].s == st.s) { target = m.kids[i] }; i = i + 1 }
if (target == null) { perr(`become: no state {st.s}`) }
if (target != null) {
let regv = emit_expr(m.a)
let sv = emit_expr(target.b)
emit(" call void @fn_rt_set_reg(i32 "); emit(regv.code); emit(", i32 "); emit(sv.code); emit(")\n")
return
}
}
let sc = find_scene(st.s) # else a scene transition
if (sc == null) { perr(`become: no state or scene {st.s}`) }
if (g_cur_scene != null) { emit(" call void @scene_exit_"); emit(g_cur_scene.s); emit("()\n") }
emit(" store i32 "); emit(itoa(sc.ival)); emit(", ptr @L_scene\n")
emit(" call void @scene_enter_"); emit(sc.s); emit("()\n")
}

373
selfhost/emit_net.ludic Normal file
View file

@ -0,0 +1,373 @@
# emit_net.ludic — NETWORKING N2–N6 codegen (NETWORKING-DESIGN.md).
#
# A program that calls net_send/net_poll with no `extern fn` override triggers the
# built-in loopback transport; this flag defers its emission to end-of-module.
var g_uses_loopback: bool = false
#
# N2 (@Sync): per-model serialize/apply over the replicable-and-participating
# fields, by-kind dispatchers a replication runtime calls, and the POD-scalar
# compile error + empty-participation warning.
# N3 (@Owned): the @L_owner array + owner()/set_owner()/is_owner().
# N5 (roles): the @L_role / @L_localid registers + is_server()/local_id().
#
# Everything here is gated (net_has_sync / net_has_owned / len(g_events) etc.),
# so a program that uses none of it is byte-identical to single-player (§8).
# ---- N2: what replicates -----------------------------------------------------
# A field of an entity replicates iff the field is @Sync (field.ival==1) AND its
# component participates in the entity's model (the model member is @Sync,
# member.ival==1). Participation is decided per model use-site.
fn net_field_ibytes(ty: ptr) -> int { if (llty(ty) == "i8") { return 1 }; return 4 }
fn net_field_bytes(ty: ptr) -> ptr { if (llty(ty) == "i8") { return "1" }; return "4" }
# total replicated bytes for model m (compile-time constant)
fn net_model_bytes(m: Node) -> int {
var total = 0
var ci = 0
while ci < len(m.kids) {
if m.kids[ci].ival == 1 {
let c = find_comp(m.kids[ci].s)
if (c != null) {
var fj = 0
while fj < len(c.kids) { if c.kids[fj].ival == 1 { total = total + net_field_ibytes(c.kids[fj].ty) }; fj = fj + 1 }
}
}
ci = ci + 1
}
return total
}
fn net_model_syncs(m: Node) -> bool { return net_model_bytes(m) > 0 }
fn net_has_sync() -> bool {
var i = 0
while i < len(prog) { if prog[i].kind == N_ARCH { if net_model_syncs(prog[i]) { return true } }; i = i + 1 }
return false
}
# ---- N3: ownership -----------------------------------------------------------
fn net_has_owned() -> bool {
var i = 0
while i < len(prog) { if prog[i].kind == N_ARCH and (prog[i].ival == 1) { return true }; i = i + 1 }
return false
}
# ---- N5: role-tagged handlers ------------------------------------------------
# A handler tagged @Server (ival==1) or @Predicted (ival==2) has a network role.
fn net_has_role() -> bool {
var i = 0
while i < len(prog) { if prog[i].kind == N_SYS and (prog[i].ival != 0) { return true }; i = i + 1 }
return false
}
# Any networking feature in use → emit the shared role registers (@L_role /
# @L_localid). A runtime sets them; offline they keep their single-player default.
fn net_any() -> bool { return net_has_sync() or net_has_owned() or net_has_role() }
# ---- diagnostics -------------------------------------------------------------
fn net_warn(msg: ptr) -> void {
let e = file_stderr()
file_write(e, "ludicc(self): warning: ", 23)
file_write(e, msg, len(msg))
file_write(e, "\n", 1)
}
# Validate @Sync usage: a participating member whose component replicates nothing
# is a warning (participation that replicates nothing); a @Sync ptr field is a
# hard error (footgun 3 — networked fields must be POD scalars).
fn net_check() -> void {
var i = 0
while i < len(prog) {
if prog[i].kind == N_ARCH {
let m = prog[i]
var ci = 0
while ci < len(m.kids) {
if m.kids[ci].ival == 1 {
let cn = m.kids[ci].s
let c = find_comp(cn)
if (c != null) {
var any = false
var fj = 0
while fj < len(c.kids) {
if c.kids[fj].ival == 1 {
any = true
if (llty(c.kids[fj].ty) == "ptr") { perr(`@Sync field {cn}.{c.kids[fj].s} is not a POD scalar (networked fields must be int/bool/fixed/byte)`) }
}
fj = fj + 1
}
if not any { net_warn(`model {m.s} @Syncs {cn} but it has no @Sync fields — nothing replicates`) }
}
}
ci = ci + 1
}
}
i = i + 1
}
}
# ---- N2: per-model serializer / applier --------------------------------------
# serialize_<M>(e, buf) -> bytes written. Copies each replicated field, tightly
# packed in member-then-field order, so apply reads the identical layout.
fn emit_net_serialize(m: Node) -> void {
ll_t = 0
let me = itoa(MAX_ENT)
emit("define i32 @L_serialize_"); emit(m.s); emit("(i32 %e, ptr %buf) {\nentry:\n")
var off = 0
var ci = 0
while ci < len(m.kids) {
if m.kids[ci].ival == 1 {
let cn = m.kids[ci].s
let c = find_comp(cn)
if (c != null) {
let s = nreg(); emit(" "); emit(s); emit(" = getelementptr inbounds ["); emit(me); emit(" x %Cmp_"); emit(cn); emit("], ptr @S_"); emit(cn); emit(", i32 0, i32 %e\n")
var fj = 0
while fj < len(c.kids) {
if c.kids[fj].ival == 1 {
let bytes = net_field_bytes(c.kids[fj].ty)
let fa = nreg(); emit(" "); emit(fa); emit(" = getelementptr inbounds %Cmp_"); emit(cn); emit(", ptr "); emit(s); emit(", i32 0, i32 "); emit(itoa(fj)); emit("\n")
let dst = nreg(); emit(" "); emit(dst); emit(" = getelementptr inbounds i8, ptr %buf, i32 "); emit(itoa(off)); emit("\n")
emit(" call ptr @memcpy(ptr "); emit(dst); emit(", ptr "); emit(fa); emit(", i64 "); emit(bytes); emit(")\n")
off = off + net_field_ibytes(c.kids[fj].ty)
}
fj = fj + 1
}
}
}
ci = ci + 1
}
emit(" ret i32 "); emit(itoa(off)); emit("\n}\n\n")
}
# apply_<M>(e, buf, len): the inverse — copy each replicated field back from the
# buffer into component storage. `len` is accepted for symmetry (the runtime's
# framing) but the layout is fixed, so it is not consulted.
fn emit_net_apply(m: Node) -> void {
ll_t = 0
let me = itoa(MAX_ENT)
emit("define void @L_apply_"); emit(m.s); emit("(i32 %e, ptr %buf, i32 %len) {\nentry:\n")
var off = 0
var ci = 0
while ci < len(m.kids) {
if m.kids[ci].ival == 1 {
let cn = m.kids[ci].s
let c = find_comp(cn)
if (c != null) {
let s = nreg(); emit(" "); emit(s); emit(" = getelementptr inbounds ["); emit(me); emit(" x %Cmp_"); emit(cn); emit("], ptr @S_"); emit(cn); emit(", i32 0, i32 %e\n")
var fj = 0
while fj < len(c.kids) {
if c.kids[fj].ival == 1 {
let bytes = net_field_bytes(c.kids[fj].ty)
let fa = nreg(); emit(" "); emit(fa); emit(" = getelementptr inbounds %Cmp_"); emit(cn); emit(", ptr "); emit(s); emit(", i32 0, i32 "); emit(itoa(fj)); emit("\n")
let src = nreg(); emit(" "); emit(src); emit(" = getelementptr inbounds i8, ptr %buf, i32 "); emit(itoa(off)); emit("\n")
emit(" call ptr @memcpy(ptr "); emit(fa); emit(", ptr "); emit(src); emit(", i64 "); emit(bytes); emit(")\n")
off = off + net_field_ibytes(c.kids[fj].ty)
}
fj = fj + 1
}
}
}
ci = ci + 1
}
emit(" ret void\n}\n\n")
}
# ---- N2: by-kind dispatchers (the runtime ABI) -------------------------------
# ludic_serialize(e, buf) -> bytes / ludic_apply(e, buf, len) / ludic_sync_size(e)
# route on the entity's model kind to the per-model function above, so a
# replication runtime replicates any entity without knowing its type.
fn emit_net_dispatch() -> void {
let me = itoa(MAX_ENT)
emit("define i32 @ludic_serialize(i32 %e, ptr %buf) {\nentry:\n")
emit(" %kp = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_kind, i32 0, i32 %e\n %k = load i32, ptr %kp\n")
var k = 0
var i = 0
while i < len(prog) {
if prog[i].kind == N_ARCH and net_model_syncs(prog[i]) {
let mn = prog[i].s; let sk = itoa(k)
emit(" %c"); emit(sk); emit(" = icmp eq i32 %k, "); emit(itoa(find_arch_id(mn))); emit("\n")
emit(" br i1 %c"); emit(sk); emit(", label %h"); emit(sk); emit(", label %n"); emit(sk); emit("\n")
emit("h"); emit(sk); emit(":\n %r"); emit(sk); emit(" = call i32 @L_serialize_"); emit(mn); emit("(i32 %e, ptr %buf)\n ret i32 %r"); emit(sk); emit("\n")
emit("n"); emit(sk); emit(":\n")
k = k + 1
}
i = i + 1
}
emit(" ret i32 0\n}\n\n")
emit("define void @ludic_apply(i32 %e, ptr %buf, i32 %len) {\nentry:\n")
emit(" %kp = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_kind, i32 0, i32 %e\n %k = load i32, ptr %kp\n")
k = 0; i = 0
while i < len(prog) {
if prog[i].kind == N_ARCH and net_model_syncs(prog[i]) {
let mn = prog[i].s; let sk = itoa(k)
emit(" %c"); emit(sk); emit(" = icmp eq i32 %k, "); emit(itoa(find_arch_id(mn))); emit("\n")
emit(" br i1 %c"); emit(sk); emit(", label %h"); emit(sk); emit(", label %n"); emit(sk); emit("\n")
emit("h"); emit(sk); emit(":\n call void @L_apply_"); emit(mn); emit("(i32 %e, ptr %buf, i32 %len)\n ret void\n")
emit("n"); emit(sk); emit(":\n")
k = k + 1
}
i = i + 1
}
emit(" ret void\n}\n\n")
# ludic_sync_size(e): the replicated byte count for the entity's model — a
# constant per kind, so a runtime can size a buffer before serialize.
emit("define i32 @ludic_sync_size(i32 %e) {\nentry:\n")
emit(" %kp = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_kind, i32 0, i32 %e\n %k = load i32, ptr %kp\n")
k = 0; i = 0
while i < len(prog) {
if prog[i].kind == N_ARCH and net_model_syncs(prog[i]) {
let mn = prog[i].s; let sk = itoa(k)
emit(" %c"); emit(sk); emit(" = icmp eq i32 %k, "); emit(itoa(find_arch_id(mn))); emit("\n")
emit(" br i1 %c"); emit(sk); emit(", label %h"); emit(sk); emit(", label %n"); emit(sk); emit("\n")
emit("h"); emit(sk); emit(":\n ret i32 "); emit(itoa(net_model_bytes(prog[i]))); emit("\n")
emit("n"); emit(sk); emit(":\n")
k = k + 1
}
i = i + 1
}
emit(" ret i32 0\n}\n\n")
}
# ---- N3: ownership storage + accessors ---------------------------------------
# @L_owner: one i32 owner id per entity, -1 = unowned. Only emitted when a model
# is @Owned, and L_reset clears it to -1 on alloc/free (see emit_ecs). owner()/
# set_owner()/is_owner() read and write it; the authority assigns.
fn emit_net_owner() -> void {
let me = itoa(MAX_ENT)
emit("define i32 @L_owner(i32 %e) {\nentry:\n")
emit(" %p = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_owner_arr, i32 0, i32 %e\n %v = load i32, ptr %p\n ret i32 %v\n}\n\n")
emit("define void @L_set_owner(i32 %e, i32 %id) {\nentry:\n")
emit(" %p = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_owner_arr, i32 0, i32 %e\n store i32 %id, ptr %p\n ret void\n}\n\n")
# is_owner(e): does the local peer own e? owner(e) == local_id().
emit("define i32 @L_is_owner(i32 %e) {\nentry:\n")
emit(" %p = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_owner_arr, i32 0, i32 %e\n %o = load i32, ptr %p\n")
emit(" %lid = load i32, ptr @L_localid\n %eq = icmp eq i32 %o, %lid\n %r = zext i1 %eq to i32\n ret i32 %r\n}\n\n")
}
# ---- built-in loopback transport (NETWORKING-DESIGN §5 N0) -------------------
# The transport seam is net_send/net_poll. A production build binds them to a real
# socket via `extern fn` (UDP native, WebRTC/WebSocket wasm). Absent that, the
# compiler emits this in-process loopback — a single FIFO of datagrams, send
# enqueues a copy and poll dequeues the oldest — so a game is networked end to end
# with NO foreign host at all (the Ludic-native default). Datagram-preserving:
# one message per poll, matching how replication/RPC frame. Emitted only when a
# program actually calls net_send/net_poll without an extern override.
fn emit_loopback() -> void {
emith("@L_netq = internal global [64 x [2048 x i8]] zeroinitializer\n")
emith("@L_netlen = internal global [64 x i32] zeroinitializer\n")
emith("@L_nethead = internal global i32 0\n")
emith("@L_nettail = internal global i32 0\n")
emit("define void @L_net_send(i32 %peer, ptr %buf, i32 %len) {\nentry:\n")
emit(" %l0 = icmp slt i32 %len, 0\n %len1 = select i1 %l0, i32 0, i32 %len\n")
emit(" %l1 = icmp sgt i32 %len1, 2048\n %n = select i1 %l1, i32 2048, i32 %len1\n")
emit(" %t = load i32, ptr @L_nettail\n %h = load i32, ptr @L_nethead\n")
emit(" %t1 = add i32 %t, 1\n %tn = srem i32 %t1, 64\n %full = icmp eq i32 %tn, %h\n")
emit(" br i1 %full, label %drop, label %go\n")
emit("go:\n")
emit(" %row = getelementptr inbounds [64 x [2048 x i8]], ptr @L_netq, i32 0, i32 %t\n")
emit(" %nz = zext i32 %n to i64\n call ptr @memcpy(ptr %row, ptr %buf, i64 %nz)\n")
emit(" %lp = getelementptr inbounds [64 x i32], ptr @L_netlen, i32 0, i32 %t\n store i32 %n, ptr %lp\n")
emit(" store i32 %tn, ptr @L_nettail\n br label %drop\n")
emit("drop:\n ret void\n}\n\n")
emit("define i32 @L_net_poll(ptr %buf, i32 %cap) {\nentry:\n")
emit(" %h = load i32, ptr @L_nethead\n %t = load i32, ptr @L_nettail\n %empty = icmp eq i32 %h, %t\n")
emit(" br i1 %empty, label %none, label %go\n")
emit("go:\n")
emit(" %lp = getelementptr inbounds [64 x i32], ptr @L_netlen, i32 0, i32 %h\n %ln = load i32, ptr %lp\n")
emit(" %big = icmp sgt i32 %ln, %cap\n %n = select i1 %big, i32 %cap, i32 %ln\n")
emit(" %row = getelementptr inbounds [64 x [2048 x i8]], ptr @L_netq, i32 0, i32 %h\n")
emit(" %nz = zext i32 %n to i64\n call ptr @memcpy(ptr %buf, ptr %row, i64 %nz)\n")
emit(" %h1 = add i32 %h, 1\n %hn = srem i32 %h1, 64\n store i32 %hn, ptr @L_nethead\n ret i32 %n\n")
emit("none:\n ret i32 0\n}\n\n")
}
# ---- N4: remote events (RPCs) ------------------------------------------------
# An `event` marked @ToServer / @ToClients (ev.ty set) crosses the wire. At an
# `emit` site the POD payload is serialized as [i32 event_id][packed fields] and
# net_send in the declared direction; net_pump() drains inbound frames and
# re-emits each into the ordinary @ev_<E> dispatch on the far side. Reuses the
# EV0 payload (already flat) and the transport seam — no new concept.
fn net_has_remote() -> bool {
var i = 0
while i < len(g_events) { if (g_events[i].ty != null) { return true }; i = i + 1 }
return false
}
# stable wire id for an event = its index in g_events (same program both peers)
fn net_event_id(name: ptr) -> int {
var i = 0
while i < len(g_events) { if (g_events[i].s == name) { return i }; i = i + 1 }
return 0 - 1
}
# the transport symbols: an `extern fn` override, else the built-in loopback.
fn net_send_sym() -> ptr { let x = find_extern("net_send"); if (x != null) { return x.a.s }; return "L_net_send" }
fn net_poll_sym() -> ptr { let x = find_extern("net_poll"); if (x != null) { return x.a.s }; return "L_net_poll" }
# net_pump(): poll every pending frame and re-emit it locally. The receive path
# of a remote event — the runtime/game calls this each tick.
fn emit_net_pump() -> void {
emith("@L_recvbuf = internal global [2048 x i8] zeroinitializer\n")
if (find_extern("net_poll") == null) { g_uses_loopback = true }
let psym = net_poll_sym()
emit("define void @L_net_pump() {\nentry:\n br label %loop\n")
emit("loop:\n %n = call i32 @"); emit(psym); emit("(ptr @L_recvbuf, i32 2048)\n")
emit(" %done = icmp eq i32 %n, 0\n br i1 %done, label %fin, label %body\n")
emit("body:\n %eid = load i32, ptr @L_recvbuf\n")
var e = 0
while e < len(g_events) {
let ev = g_events[e]
if (ev.ty != null) {
let sk = itoa(e)
emit(" %c"); emit(sk); emit(" = icmp eq i32 %eid, "); emit(itoa(net_event_id(ev.s))); emit("\n")
emit(" br i1 %c"); emit(sk); emit(", label %h"); emit(sk); emit(", label %x"); emit(sk); emit("\n")
emit("h"); emit(sk); emit(":\n")
# decode each field from the frame (offset starts after the i32 event id)
var off = 4
var f = 0
let acc = buf_new()
while f < len(ev.kids) {
let ft = llty(ev.kids[f].ty)
let fk = `{sk}_{itoa(f)}`
emit(" %fa"); emit(fk); emit(" = getelementptr inbounds i8, ptr @L_recvbuf, i32 "); emit(itoa(off)); emit("\n")
emit(" %fv"); emit(fk); emit(" = load "); emit(ft); emit(", ptr %fa"); emit(fk); emit("\n")
if f > 0 { buf_puts(acc, ", ") }
buf_puts(acc, ft); buf_puts(acc, " %fv"); buf_puts(acc, fk)
off = off + net_field_ibytes(ev.kids[f].ty)
f = f + 1
}
emit(" call void @ev_"); emit(ev.s); emit("("); emit(buf_str(acc)); emit(")\n")
emit(" br label %loop\n")
emit("x"); emit(sk); emit(":\n")
}
e = e + 1
}
emit(" br label %loop\n") # unknown id: skip, keep draining
emit("fin:\n ret void\n}\n\n")
}
# ---- driver ------------------------------------------------------------------
fn emit_net() -> void {
if net_has_sync() {
net_check()
var i = 0
while i < len(prog) {
if prog[i].kind == N_ARCH and net_model_syncs(prog[i]) {
emit_net_serialize(prog[i])
emit_net_apply(prog[i])
}
i = i + 1
}
emit_net_dispatch()
}
if net_has_owned() { emit_net_owner() }
if net_has_remote() {
emith("@L_sendbuf = internal global [2048 x i8] zeroinitializer\n") # RPC send scratch
emit_net_pump()
}
}

View file

@ -5,13 +5,47 @@
var g_iok: int = 0
# The snapshot is a fixed sequence of (region, byte-length) blocks; the same list
# feeds two targets — a file (save/load via fwrite/fread) and a memory buffer
# (world_save/world_load via memcpy, NETWORKING-DESIGN §5 N1). g_snap_mode picks
# which; buffer modes thread a running i64 offset (@g_off) so world_save returns
# the total byte count and world_load reads the identical layout back.
# NOTE: a string initializer on a module `ptr` var lowers to null (global_init),
# so these are seeded at runtime in emit_snapshot before first use — never read
# them uninitialized (a null string `==` would deref and crash the compiler).
var g_snap_mode: ptr = null # "file" | "save" | "load" | "size"
var g_off: ptr = null # current byte-offset register, buffer modes
fn emit_io(fn2: ptr, p: ptr, bytes: ptr) -> void {
if (g_snap_mode == "file") {
let r = `%io{itoa(g_iok)}`; g_iok = g_iok + 1
emit(" "); emit(r); emit(" = call i64 @"); emit(fn2); emit("(ptr "); emit(p); emit(", i64 1, i64 "); emit(bytes); emit(", ptr %f)\n")
return
}
if (g_snap_mode == "size") { # accumulate the offset only, no copy
let noff = `%ioff{itoa(g_iok)}`
emit(" "); emit(noff); emit(" = add i64 "); emit(g_off); emit(", "); emit(bytes); emit("\n")
g_off = noff
g_iok = g_iok + 1
return
}
# buffer mode: dst/src is %buf + g_off, copy `bytes`, then advance the cursor
let addr = `%ioa{itoa(g_iok)}`
emit(" "); emit(addr); emit(" = getelementptr inbounds i8, ptr %buf, i64 "); emit(g_off); emit("\n")
if (g_snap_mode == "save") {
emit(" call ptr @memcpy(ptr "); emit(addr); emit(", ptr "); emit(p); emit(", i64 "); emit(bytes); emit(")\n")
} else {
emit(" call ptr @memcpy(ptr "); emit(p); emit(", ptr "); emit(addr); emit(", i64 "); emit(bytes); emit(")\n")
}
let noff = `%ioff{itoa(g_iok)}`
emit(" "); emit(noff); emit(" = add i64 "); emit(g_off); emit(", "); emit(bytes); emit("\n")
g_off = noff
g_iok = g_iok + 1
}
fn emit_snapshot_blocks(fn2: ptr) -> void {
g_iok = 0
g_off = "0"
let me = itoa(MAX_ENT)
emit_io(fn2, "@L_entc", "4")
emit_io(fn2, "@L_freen", "4")
@ -19,6 +53,9 @@ fn emit_snapshot_blocks(fn2: ptr) -> void {
emit_io(fn2, "@L_alive", "%nalive")
emit_io(fn2, "@L_freelist", "%nalive")
emit_io(fn2, "@L_kind", "%nalive")
# N3: an @Owned world snapshots its per-entity owners too, so rollback/replication
# round-trips ownership (like @L_kind). Gated, so non-@Owned snapshots are unchanged.
if net_has_owned() { emit_io(fn2, "@L_owner_arr", "%nalive") }
var i = 0
while i < len(prog) { if prog[i].kind == N_VAR { emit_io(fn2, `@g_{prog[i].s}`, "4") }; i = i + 1 }
var ci = 0
@ -37,6 +74,7 @@ fn emit_snapshot_blocks(fn2: ptr) -> void {
}
fn emit_snapshot() -> void {
g_snap_mode = "file" # seed (module ptr inits are null)
emith("@.sav_path = private unnamed_addr constant [10 x i8] c\"ludic.sav\\00\"\n")
emith("@.sav_wb = private unnamed_addr constant [3 x i8] c\"wb\\00\"\n")
emith("@.sav_rb = private unnamed_addr constant [3 x i8] c\"rb\\00\"\n")
@ -58,4 +96,32 @@ fn emit_snapshot() -> void {
emit_snapshot_blocks("fread")
if has_load { emit(" call void @fn_rt_load_state(ptr %f)\n") }
emit(" %c = call i32 @fclose(ptr %f)\n ret i32 1\n}\n\n")
# world_save(buf) -> int / world_load(buf, len): the same whole-world snapshot,
# to a caller-owned memory buffer instead of a file (NETWORKING-DESIGN §5 N1) —
# the rollback/replication substrate. No rt_ hook: this is the ECS world only
# (entities, components, vars), which is what a peer replicates or a rollback
# restores; the runtime's windowing state stays local. world_save returns the
# byte count written; the caller sizes the buffer with world_size().
emit("define i32 @L_world_save(ptr %buf) {\nentry:\n")
g_snap_mode = "save"
emit_snapshot_blocks("")
let sret = `%wsn{itoa(g_iok)}`
emit(" "); emit(sret); emit(" = trunc i64 "); emit(g_off); emit(" to i32\n")
emit(" ret i32 "); emit(sret); emit("\n}\n\n")
emit("define void @L_world_load(ptr %buf, i32 %len) {\nentry:\n")
g_snap_mode = "load"
emit_snapshot_blocks("")
emit(" ret void\n}\n\n")
# world_size() -> int: the exact byte count a full snapshot needs, so a caller
# can size the buffer before world_save. Same block walk, offset-only.
emit("define i32 @L_world_size() {\nentry:\n")
g_snap_mode = "size"
emit_snapshot_blocks("")
let zret = `%wzn{itoa(g_iok)}`
emit(" "); emit(zret); emit(" = trunc i64 "); emit(g_off); emit(" to i32\n")
emit(" ret i32 "); emit(zret); emit("\n}\n\n")
g_snap_mode = "file"
}

View file

@ -47,6 +47,9 @@ fn emit_init_component(e: ptr, comp: ptr, rec: Node) -> void {
emit_block(ab)
nloc = save
}
# EV1: a @Public attach hook fires prop_<P>_attach with the entity
let aev = `prop_{comp}_attach`
if (find_event(aev) != null) { emit(" call void @ev_"); emit(aev); emit("(i32 "); emit(e); emit(")\n") }
}
# bind each of a model's properties to entity `e`'s component storage, so an
@ -65,7 +68,7 @@ fn emit_bind_props(model: Node, e: ptr) -> void {
}
}
fn emit_spawn(st: Node) -> void {
fn emit_spawn(st: Node) -> ptr {
let e = emit_bind("call i32 @L_alloc()")
let ak = find_arch_id(st.s)
if ak > 0 {
@ -89,10 +92,15 @@ fn emit_spawn(st: Node) -> void {
emit_block(ob)
nloc = save
}
# EV1: a @Public spawn hook also fires the public event model_<M>_spawn, so
# mods (native or foreign, over the ABI) see the entity born.
let sev = `model_{st.s}_spawn`
if (find_event(sev) != null) { emit(" call void @ev_"); emit(sev); emit("(i32 "); emit(e); emit(")\n") }
} else {
var i = 0
while i < len(st.kids) { emit_init_component(e, st.kids[i].s, st.kids[i].a); i = i + 1 }
}
return e # the new entity id (for ludic_spawn_<M>)
}
fn emit_despawn(st: Node) -> void {
@ -108,19 +116,76 @@ fn emit_despawn(st: Node) -> void {
let c = emit_bind(`icmp eq i32 {kind}, {itoa(find_arch_id(mname))}`)
let yes = lbl("dh"); let no = lbl("dhn")
emit(" br i1 "); emit(c); emit(", label %"); emit(yes); emit(", label %"); emit(no); emit("\n")
emit(yes); emit(":\n call void @on_despawn_"); emit(mname); emit("(i32 "); emit(v.code); emit(")\n")
emit(yes); emit(":\n call void @on_despawn_"); emit(mname); emit("(i32 "); emit(v.code); emit(", i32 0)\n") # reason = EndReason.Despawned
let dev = `model_{mname}_despawn` # EV1: @Public despawn event
if (find_event(dev) != null) { emit(" call void @ev_"); emit(dev); emit("(i32 "); emit(v.code); emit(", i32 0)\n") }
emit(" br label %"); emit(no); emit("\n"); emit(no); emit(":\n")
i = i + 1
}
}
if len(g_events) > 0 { emit(" call void @ludic_sweep_entity(i32 "); emit(v.code); emit(")\n") } # EV5: drop entity-scoped listeners
emit(" call void @L_free_entity(i32 "); emit(v.code); emit(")\n")
}
# attach P on e [{ overrides }] — add a property to a live entity. Structural
# (not a toggle): seeds the property's fields and fires @OnAttach, but only on a
# real transition — if the entity already has the property it is a no-op, so the
# hook fires once per genuine attach (flecs/Bevy "real add" semantics).
fn emit_attach(st: Node) -> void {
let ev = emit_expr(st.a)
let me = itoa(MAX_ENT)
let hp = nreg(); emit(" "); emit(hp); emit(" = getelementptr inbounds ["); emit(me); emit(" x i8], ptr @H_"); emit(st.s); emit(", i32 0, i32 "); emit(ev.code); emit("\n")
let cur = emit_bind(`load i8, ptr {hp}`)
let isnew = emit_bind(`icmp eq i8 {cur}, 0`)
let doit = lbl("attach"); let done = lbl("attdone")
emit(" br i1 "); emit(isnew); emit(", label %"); emit(doit); emit(", label %"); emit(done); emit("\n")
emit(doit); emit(":\n"); g_term = false
emit_init_component(ev.code, st.s, st.b) # sets has=1, seeds defaults+overrides, fires @OnAttach
if not g_term { emit(" br label %"); emit(done); emit("\n") }
emit(done); emit(":\n"); g_term = false
}
# detach P on e — remove a property from a live entity. Fires @OnDetach with the
# property bound by name (its data still lives in @S_ storage, so the teardown
# body reads the outgoing value), then clears the has-flag so queries skip it.
# Only fires on a real transition; detaching an absent property is a no-op.
fn emit_detach(st: Node) -> void {
let ev = emit_expr(st.a)
let me = itoa(MAX_ENT)
let hp = nreg(); emit(" "); emit(hp); emit(" = getelementptr inbounds ["); emit(me); emit(" x i8], ptr @H_"); emit(st.s); emit(", i32 0, i32 "); emit(ev.code); emit("\n")
let cur = emit_bind(`load i8, ptr {hp}`)
let here = emit_bind(`icmp ne i8 {cur}, 0`)
let doit = lbl("detach"); let done = lbl("detdone")
emit(" br i1 "); emit(here); emit(", label %"); emit(doit); emit(", label %"); emit(done); emit("\n")
emit(doit); emit(":\n"); g_term = false
emit(" store i8 0, ptr "); emit(hp); emit("\n") # clear has-flag (data persists in @S_)
let hb = ondetach_body(st.s) # @OnDetach reads the outgoing value
if (hb != null) {
let save = nloc
let slot = nreg(); emit(" "); emit(slot); emit(" = getelementptr inbounds ["); emit(me); emit(" x %Cmp_"); emit(st.s); emit("], ptr @S_"); emit(st.s); emit(", i32 0, i32 "); emit(ev.code); emit("\n")
let vslot = emit_alloca("ptr")
emit(" store ptr "); emit(slot); emit(", ptr "); emit(vslot); emit("\n")
loc_push(st.s, vslot, st.s)
emit_block(hb)
nloc = save
}
let dev = `prop_{st.s}_detach` # EV1: @Public detach event
if (find_event(dev) != null) { emit(" call void @ev_"); emit(dev); emit("(i32 "); emit(ev.code); emit(")\n") }
if not g_term { emit(" br label %"); emit(done); emit("\n") }
emit(done); emit(":\n"); g_term = false
}
# enable/disable. `<P> on <e>` toggles a property's has-flag on an entity (its
# data persists, so re-enabling restores it, and queries already skip a cleared
# flag). A bare `<Model>` / `<Handler>` flips a global enabled flag.
fn emit_toggle(st: Node) -> void {
var val = "0"; if st.ival == 1 { val = "1" }
if (st.ty != null) and (st.ty == "layer") { # enable/disable layer L
emit(" store i32 "); emit(val); emit(", ptr @LE_"); emit(st.s); emit("\n")
var lev = `layer_{st.s}_hide`; if st.ival == 1 { lev = `layer_{st.s}_show` } # public layer -> event
if (find_event(lev) != null) { emit(" call void @ev_"); emit(lev); emit("()\n") }
return
}
if (st.a != null) {
let ev = emit_expr(st.a)
let me = itoa(MAX_ENT)
@ -137,6 +202,9 @@ fn emit_toggle(st: Node) -> void {
emit_block(hb)
nloc = save
}
# EV1: a @Public enable/disable hook fires prop_<P>_enable / prop_<P>_disable
var tev = `prop_{st.s}_disable`; if st.ival == 1 { tev = `prop_{st.s}_enable` }
if (find_event(tev) != null) { emit(" call void @ev_"); emit(tev); emit("(i32 "); emit(ev.code); emit(")\n") }
} else {
var g = "@HE_"; if is_model(st.s) { g = "@ME_" } # model vs handler
emit(" store i32 "); emit(val); emit(", ptr "); emit(g); emit(st.s); emit("\n")

View file

@ -160,6 +160,67 @@ fn emit_match(st: Node) -> void {
emit(endl); emit(":\n"); g_term = false
}
# emit E(field: v, ...) — evaluate the payload args in the event's declared field
# order (so call args line up with the `@ev_<E>` signature), then a direct call.
# A missing arg falls back to the field's default; an unset scalar/ptr to 0/null.
fn emit_emit(st: Node) -> Val {
let ev = find_event(st.s)
if (ev == null) { perr(`emit: unknown event {st.s}`) }
# evaluate each payload field in declared order (default for a missing arg)
let fcodes = new []ptr
let ftys = new []ptr
var f = 0
while f < len(ev.kids) {
let fd = ev.kids[f]
var av: Node = null # the caller's value for this field, if given
var j = 0
while j < len(st.a.kids) { if (st.a.kids[j].s == fd.s) { av = st.a.kids[j].a }; j = j + 1 }
let lt = llty(fd.ty)
var code = "0"
if (lt == "ptr") { code = "null" }
if (av != null) { let v = emit_expr(av); code = v.code }
else { if (fd.a != null) { let dv = emit_expr(fd.a); code = dv.code } } # declared default
push(fcodes, code); push(ftys, lt)
f = f + 1
}
# N4: a remote event (@ToServer/@ToClients) serializes its payload as
# [i32 event_id][packed fields] and net_send in its direction — the far side's
# net_pump() re-emits it. It is a remote call, so there is no local dispatch.
if (ev.ty != null) {
let idp = nreg(); emit(" "); emit(idp); emit(" = getelementptr inbounds i8, ptr @L_sendbuf, i32 0\n")
emit(" store i32 "); emit(itoa(net_event_id(st.s))); emit(", ptr "); emit(idp); emit("\n")
var off = 4
var k = 0
while k < len(ev.kids) {
let dp = nreg(); emit(" "); emit(dp); emit(" = getelementptr inbounds i8, ptr @L_sendbuf, i32 "); emit(itoa(off)); emit("\n")
emit(" store "); emit(ftys[k]); emit(" "); emit(fcodes[k]); emit(", ptr "); emit(dp); emit("\n")
off = off + net_field_ibytes(ev.kids[k].ty)
k = k + 1
}
var peer = "0"
if (ev.ty == "toclients") { peer = "-1" } # broadcast (loopback ignores the peer id)
if (find_extern("net_send") == null) { g_uses_loopback = true }
emit(" call void @"); emit(net_send_sym()); emit("(i32 "); emit(peer); emit(", ptr @L_sendbuf, i32 "); emit(itoa(off)); emit(")\n")
return val("0", "int")
}
# a local event: build the call args and dispatch. A cancellable event returns
# its cancelled flag (i32); a plain event is void.
let args = buf_new()
var g = 0
while g < len(fcodes) {
if g > 0 { buf_puts(args, ", ") }
buf_puts(args, ftys[g]); buf_puts(args, " "); buf_puts(args, fcodes[g])
g = g + 1
}
if ev.ival == 1 {
let r = nreg()
emit(" "); emit(r); emit(" = call i32 @ev_"); emit(st.s); emit("("); emit(buf_str(args)); emit(")\n")
return val(r, "int")
}
emit(" call void @ev_"); emit(st.s); emit("("); emit(buf_str(args)); emit(")\n")
return val("0", "int")
}
fn emit_stmt(st: Node) -> void {
if st.kind == S_LET {
var ty = st.ty
@ -184,9 +245,16 @@ fn emit_stmt(st: Node) -> void {
if st.kind == S_CONTINUE { emit(" br label %"); emit(cnt_lbl[nloop - 1]); emit("\n"); g_term = true; return }
if st.kind == S_MATCH { emit_match(st); return }
if st.kind == S_QUERY { emit_query(st); return }
if st.kind == S_SPAWN { emit_spawn(st); return }
if st.kind == S_SPAWN { let se = emit_spawn(st); return }
if st.kind == S_DESPAWN { emit_despawn(st); return }
if st.kind == S_TOGGLE { emit_toggle(st); return }
if st.kind == S_ATTACH { emit_attach(st); return }
if st.kind == S_DETACH { emit_detach(st); return }
if st.kind == S_EMIT { let v = emit_emit(st); return } # statement form: discard the flag
if st.kind == S_CANCEL { # veto the enclosing cancellable event
if (g_cancel_addr == null) { perr("cancel outside a cancellable event listener") }
emit(" store i32 1, ptr "); emit(g_cancel_addr); emit("\n"); return
}
if st.kind == S_MACHINE { emit_machine(st); return }
if st.kind == S_BECOME { emit_become(st); return }
if st.kind == S_EXPR { let v = emit_expr(st.a); return }

File diff suppressed because it is too large Load diff

View file

@ -109,9 +109,26 @@ fn parse_interp(raw: ptr) -> Node {
return acc
}
# emit E(field: v, ...) — shared by the statement form and the expression form.
# As an expression it yields a cancellable event's cancelled flag (0/1); a
# non-cancellable event yields 0.
fn parse_emit() -> Node {
pi = pi + 1; let n = node(S_EMIT); n.s = eat_id()
let r = node(E_REC)
eat_op("("); skipnl()
while not is_op(")") {
let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); fi.a = expr(); push(r.kids, fi)
skipnl(); if is_op(",") { pi = pi + 1; skipnl() }
}
eat_op(")")
n.a = r
return n
}
fn p_primary() -> Node {
let t = toks[pi]
if t.kind == TK_INTERP { pi = pi + 1; return parse_interp(t.text) }
if t.kind == TK_ID and (t.text == "emit") and (toks[pi + 1].kind == TK_ID) { return parse_emit() }
if t.kind == TK_INT { let n = node(E_INT); n.ival = t.ival; pi = pi + 1; return n }
if t.kind == TK_FLOAT { let n = node(E_FLOAT); n.ival = t.ival; pi = pi + 1; return n }
if t.kind == TK_STR { let n = node(E_STR); n.s = t.text; pi = pi + 1; return n }
@ -253,16 +270,35 @@ fn stmt() -> Node {
push(n.kids, s); sidx = sidx + 1 }
eat_op("}"); return n
}
# `emit E(...)` fires an event, but a bare `emit(...)` is an ordinary call
# (the compiler dogfoods a function named `emit`), so require an event name.
if (t.text == "emit") and (toks[pi + 1].kind == TK_ID) { return parse_emit() }
if (t.text == "become") { pi = pi + 1; let n = node(S_BECOME); n.s = eat_id(); return n }
if (t.text == "despawn") { pi = pi + 1; let n = node(S_DESPAWN); n.a = expr(); return n }
if (t.text == "enable") or (t.text == "disable") {
var en = 0; if (t.text == "enable") { en = 1 }
pi = pi + 1; let n = node(S_TOGGLE); n.ival = en; n.s = eat_id() # `enable P on e` / `disable Model` / `disable Handler`
pi = pi + 1; let n = node(S_TOGGLE); n.ival = en
if is_id("layer") { pi = pi + 1; n.ty = "layer"; n.s = eat_id(); note_toggled_layer(n.s); return n } # enable/disable layer L
n.s = eat_id() # `enable P on e` / `disable Model` / `disable Handler`
if is_id("on") { pi = pi + 1; n.a = expr() } # property on an entity
return n
}
if (t.text == "attach") { # attach P on e [{ field: val, ... }]
pi = pi + 1; let n = node(S_ATTACH); n.s = eat_id()
if not is_id("on") { perr("attach needs 'on <entity>'") }
pi = pi + 1; n.a = expr()
if is_op("{") { n.b = record() } # optional field overrides (same-line)
return n
}
if (t.text == "detach") { # detach P on e
pi = pi + 1; let n = node(S_DETACH); n.s = eat_id()
if not is_id("on") { perr("detach needs 'on <entity>'") }
pi = pi + 1; n.a = expr()
return n
}
if (t.text == "break") { pi = pi + 1; return node(S_BREAK) }
if (t.text == "continue") { pi = pi + 1; return node(S_CONTINUE) }
if (t.text == "cancel") { pi = pi + 1; return node(S_CANCEL) } # veto a cancellable event
if (t.text == "match") {
pi = pi + 1; let n = node(S_MATCH); n.a = expr(); skipnl(); eat_op("{")
while true {
@ -337,23 +373,42 @@ fn parse_one_decl() -> void {
var qspec: Node = null
var onspawn_model: ptr = null
var ondespawn_model: ptr = null
var ondespawn_reason: ptr = null # @OnDespawn(M, reason: r) — LC1 teardown reason binding
var onattach_prop: ptr = null
var ondetach_prop: ptr = null
var onenable_prop: ptr = null
var ondisable_prop: ptr = null
var on_event: ptr = null # @On(Event) — a compile-time event listener
var is_public = false # @Public — promote a lifecycle hook to an event
var hook_phase: ptr = null # @OnStart / @OnQuit override the phase
var is_sync_prop = false # @Sync property P — every field replicates (NETWORKING N2)
var is_owned = false # @Owned model M — entities carry a network owner (N3)
var role: ptr = null # @Server / @Predicted — a handler's network role (N5)
var remote_dir: ptr = null # @ToServer / @ToClients — a remote event's direction (N4)
while is_op("@") {
pi = pi + 1; let a = eat_id() # collect a leading @annotation
if (a == "export") { is_export = true }
else { if (a == "Public") { is_public = true } # @Public hook promotion
else { if (a == "On") { eat_op("("); on_event = eat_id(); eat_op(")") } # @On(Event) listener
else { if (a == "Queries") { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...)
else { if (a == "OnSpawn") { eat_op("("); onspawn_model = eat_id(); eat_op(")") }
else { if (a == "OnDespawn") { eat_op("("); ondespawn_model = eat_id(); eat_op(")") }
else { if (a == "OnDespawn") { eat_op("("); ondespawn_model = eat_id()
if is_op(",") { pi = pi + 1; eat_id(); eat_op(":"); ondespawn_reason = eat_id() } # , reason: r
eat_op(")") }
else { if (a == "OnAttach") { eat_op("("); onattach_prop = eat_id(); eat_op(")") }
else { if (a == "OnDetach") { eat_op("("); ondetach_prop = eat_id(); eat_op(")") }
else { if (a == "OnEnable") { eat_op("("); onenable_prop = eat_id(); eat_op(")") }
else { if (a == "OnDisable") { eat_op("("); ondisable_prop = eat_id(); eat_op(")") }
else { if (a == "OnStart") { hook_phase = "Start" } # boot
else { if (a == "OnQuit") { hook_phase = "OnQuit" } # shutdown
else { if (a == "Sync") { is_sync_prop = true } # @Sync property (N2)
else { if (a == "Owned") { is_owned = true } # @Owned model (N3)
else { if (a == "Server") { role = "server" } # @Server handler (N5)
else { if (a == "Predicted") { role = "predicted" } # @Predicted handler (N5)
else { if (a == "ToServer") { remote_dir = "toserver" } # @ToServer event (N4)
else { if (a == "ToClients") { remote_dir = "toclients" } # @ToClients event (N4)
else { if is_op("(") { var d = 0 # any other @anno(args) — parsed and skipped
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } } } } } } } } } }
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } } } } } } } } } } } } } } } } } } }
skipnl()
}
if is_id("import") { pi = pi + 1
@ -364,16 +419,47 @@ fn parse_one_decl() -> void {
return
}
if is_id("enum") { push(prog, parse_enum()); return }
if is_id("property") { push(prog, parse_component()); return }
if is_id("model") { push(prog, parse_archetype()); return }
if is_id("event") {
let ev = parse_event()
if (remote_dir != null) { ev.ty = remote_dir } # N4: a directional remote event (RPC)
register_event(ev); return
}
if is_id("property") {
let c = parse_component()
if is_sync_prop { var fi = 0; while fi < len(c.kids) { c.kids[fi].ival = 1; fi = fi + 1 } } # N2: mark every field replicable
push(prog, c); return
}
if is_id("model") {
let m = parse_archetype()
if is_owned { m.ival = 1 } # N3: this model's entities carry a network owner
push(prog, m); return
}
if is_id("scene") { parse_scene(); return } # layers push handlers into prog; scene -> g_scenes
if is_id("handler") {
let h = parse_system()
if (onspawn_model != null) { register_onspawn(onspawn_model, h.a); return } # spawn hook
if (ondespawn_model != null) { register_ondespawn(ondespawn_model, h.a); return } # despawn hook
if (onattach_prop != null) { register_onattach(onattach_prop, h.a); return } # attach hook
if (onenable_prop != null) { register_onenable(onenable_prop, h.a); return } # enable hook
if (ondisable_prop != null) { register_ondisable(ondisable_prop, h.a); return } # disable hook
if (hook_phase != null) { h.ty = hook_phase } # @OnStart/@OnQuit
if (role != null) { if (role == "server") { h.ival = 1 } else { h.ival = 2 } } # N5: @Server=1 / @Predicted=2
if (on_event != null) { register_onlisten(on_event, h.a); return } # @On(Event) listener
if (onspawn_model != null) {
register_onspawn(onspawn_model, h.a) # spawn hook
if is_public { ensure_event(`model_{onspawn_model}_spawn`, false) } # @Public -> model_<M>_spawn
return
}
if (ondespawn_model != null) {
register_ondespawn(ondespawn_model, h.a, ondespawn_reason) # despawn hook
if is_public { ensure_event(`model_{ondespawn_model}_despawn`, true) } # @Public -> model_<M>_despawn (with reason)
return
}
if (onattach_prop != null) { register_onattach(onattach_prop, h.a); if is_public { ensure_event(`prop_{onattach_prop}_attach`, false) }; return } # -> prop_<P>_attach
if (ondetach_prop != null) { register_ondetach(ondetach_prop, h.a); if is_public { ensure_event(`prop_{ondetach_prop}_detach`, false) }; return } # -> prop_<P>_detach
if (onenable_prop != null) { register_onenable(onenable_prop, h.a); if is_public { ensure_event(`prop_{onenable_prop}_enable`, false) }; return } # -> prop_<P>_enable
if (ondisable_prop != null) { register_ondisable(ondisable_prop, h.a); if is_public { ensure_event(`prop_{ondisable_prop}_disable`, false) }; return } # -> prop_<P>_disable
if (hook_phase != null) { # @OnStart/@OnQuit
h.ty = hook_phase
if is_public { # -> program_start / program_quit
if (hook_phase == "Start") { ensure_event_empty("program_start") }
else { ensure_event_empty("program_quit") }
}
}
if (qspec != null) { # @Queries wraps the body in its S_QUERY
qspec.a = h.a
let wrap = node(N_BLOCK); push(wrap.kids, qspec); h.a = wrap
@ -421,8 +507,15 @@ fn parse_program() -> void {
g_onspawn = new []Node
g_ondespawn = new []Node
g_onattach = new []Node
g_ondetach = new []Node
g_onenable = new []Node
g_ondisable = new []Node
g_scenes = new []Node
g_scene_count = 0
g_start_scene = 0
g_events = new []Node
g_onlisten = new []Node
g_toggled_layers = new []ptr
loaded_paths = new []ptr
skipnl()
g_game_name = "Ludic"

View file

@ -6,15 +6,32 @@ fn parse_component() -> Node {
pi = pi + 1; let n = node(N_COMP); n.s = eat_id(); skipnl(); eat_op("{")
while true { skipnl(); if is_op("}") { break }
var is_computed = false
if is_op("@") { pi = pi + 1; let ann = eat_id(); if (ann == "Computed") { is_computed = true }; skipnl() }
var is_sync = false # @Sync — this field replicates (NETWORKING N2)
if is_op("@") { pi = pi + 1; let ann = eat_id(); if (ann == "Computed") { is_computed = true } else { if (ann == "Sync") { is_sync = true } }; skipnl() }
let f = node(N_FIELD); f.s = eat_id(); eat_op(":"); f.ty = ptype()
if is_op("=") { pi = pi + 1; f.a = expr() }
if is_sync { f.ival = 1 } # mark the field replicable (read by emit_net)
if is_computed { register_computed(n.s, f.s, f.ty, f.a) } # derived: no storage
else { push(n.kids, f) }
if is_op(",") { pi = pi + 1 } }
eat_op("}"); return n
}
# event Name { field: T = default, ... } — a public event's POD payload. Same
# field grammar as a `property`, but stored in g_events, not prog: an event is a
# signal shape, not per-entity storage. Zero fields is allowed (`event Ping {}`).
fn parse_event() -> Node {
pi = pi + 1; let n = node(N_EVENT)
if is_id("cancellable") { pi = pi + 1; n.ival = 1 } # a decision event: listeners may `cancel` it
n.s = eat_id(); skipnl(); eat_op("{")
while true { skipnl(); if is_op("}") { break }
let f = node(N_FIELD); f.s = eat_id(); eat_op(":"); f.ty = ptype()
if is_op("=") { pi = pi + 1; f.a = expr() }
push(n.kids, f)
if is_op(",") { pi = pi + 1 } }
eat_op("}"); return n
}
fn parse_system() -> Node {
pi = pi + 1; let n = node(N_SYS); n.s = eat_id(); n.ty = "Update"
# postfix clauses on `handler Name …`: @anno(...) (parsed and reserved, e.g.
@ -139,6 +156,57 @@ fn parse_spawn() -> Node {
eat_op("}"); return n
}
# scene Name [start] { on enter {..} on exit {..} layer L { handler .. } .. }
# A scene groups handlers behind an implicit active-scene register; only the
# active scene's handlers run each phase. `on enter`/`on exit` are lifecycle
# blocks (scene .a/.b); each layer's handlers are pushed straight into `prog` as
# ordinary N_SYS nodes, tagged with the owning scene in `.c`, so the whole
# system backend (functions, phases, enable/disable) is reused unchanged.
fn parse_scene() -> void {
pi = pi + 1 # 'scene'
let n = node(N_SCENE); n.s = eat_id()
n.ival = g_scene_count
# optional modifiers after the name, any order: `start` (the boot scene) and
# `public` (promote its on-enter/on-exit to scene_<S>_enter / scene_<S>_exit).
var is_pub = false
while is_id("start") or is_id("public") {
if is_id("start") { pi = pi + 1; g_start_scene = g_scene_count }
else { pi = pi + 1; is_pub = true }
}
g_scene_count = g_scene_count + 1
if is_pub { ensure_event_empty(`scene_{n.s}_enter`); ensure_event_empty(`scene_{n.s}_exit`) }
skipnl(); eat_op("{")
while true {
skipnl(); if is_op("}") { break }
if is_id("on") { # on enter { .. } / on exit { .. }
pi = pi + 1; let which = eat_id(); skipnl()
if (which == "enter") { n.a = block() }
else { if (which == "exit") { n.b = block() } else { perr("expected 'enter' or 'exit' after 'on'") } }
continue
}
if is_id("layer") { # layer Name [public] { handler .. }
pi = pi + 1; let lname = eat_id()
if is_id("public") { pi = pi + 1; ensure_event_empty(`layer_{lname}_show`); ensure_event_empty(`layer_{lname}_hide`) }
skipnl(); eat_op("{")
let ltag = node(E_ID); ltag.s = lname # the layer name, tagged onto each handler
while true {
skipnl(); if is_op("}") { break }
if not is_id("handler") { perr("expected 'handler' in layer") }
let h = parse_system() # N_SYS: .s name, .ty phase, .a body
h.c = n # tag the owning scene (null = global)
h.b = ltag # tag the owning layer (for enable/disable layer)
push(prog, h)
skipnl()
}
eat_op("}")
continue
}
perr("expected 'on', 'layer' or '}' in scene")
}
eat_op("}")
push(g_scenes, n)
}
# enum Name { A, B, C } — named int constants; a variant's value is its index.
# Accessed as `Name.A` (a compile-time int), so it names magic-int value spaces
# (state ids, menu selections, mode registers) without a runtime cost.
@ -150,11 +218,17 @@ fn parse_enum() -> Node {
eat_op("}"); return n
}
# archetype Name { CompA, CompB } — a named entity kind (bundle of components)
# archetype Name { CompA, @Sync CompB } — a named entity kind (bundle of
# components). A member marked `@Sync` *participates* in replication (NETWORKING
# N2): its @Sync-marked fields cross the wire for this model. Participation is
# per model use-site — the same property syncs in one model, not another. The
# per-member @Sync sets the member E_ID's ival=1 (read by emit_net).
fn parse_archetype() -> Node {
pi = pi + 1; let n = node(N_ARCH); n.s = eat_id(); skipnl(); eat_op("{")
while true { skipnl(); if is_op("}") { break }
let c = node(E_ID); c.s = eat_id(); push(n.kids, c)
let c = node(E_ID)
if is_op("@") { pi = pi + 1; let a = eat_id(); if (a == "Sync") { c.ival = 1 }; skipnl() }
c.s = eat_id(); push(n.kids, c)
if is_op(",") { pi = pi + 1 }; skipnl() }
eat_op("}"); return n
}

View file

@ -10,7 +10,8 @@ B=build/cfree; mkdir -p "$B"
FRAGS="selfhost/str.ludic selfhost/buf.ludic selfhost/io.ludic selfhost/ast.ludic
selfhost/lex.ludic selfhost/parse.ludic selfhost/parse_game.ludic selfhost/emit_core.ludic selfhost/emit_head.ludic
selfhost/emit_addr.ludic selfhost/emit_intrin.ludic selfhost/emit_intrin2.ludic selfhost/emit_math.ludic selfhost/emit_new.ludic
selfhost/emit_expr.ludic selfhost/emit_stmt.ludic selfhost/emit_ecs.ludic selfhost/emit_query.ludic selfhost/emit_spawn.ludic selfhost/emit_game.ludic selfhost/emit_machine.ludic selfhost/emit_save.ludic selfhost/emit_ui.ludic selfhost/emit_decl.ludic selfhost/main.ludic"
selfhost/emit_expr.ludic selfhost/emit_stmt.ludic selfhost/emit_ecs.ludic selfhost/emit_query.ludic selfhost/emit_spawn.ludic selfhost/emit_game.ludic selfhost/emit_machine.ludic selfhost/emit_save.ludic
selfhost/emit_net.ludic selfhost/emit_ui.ludic selfhost/emit_decl.ludic selfhost/main.ludic"
{ echo "program SelfHost {"; for f in $FRAGS; do cat "$f"; echo; done; echo "}"; } > "$B/selfhost.ludic"
if $CC selfhost/ludicc.seed.ll -o "$B/sh_old" 2>/dev/null; then
# compile once with the old seed, then AGAIN with the freshly built one so the

135
test.sh
View file

@ -71,6 +71,141 @@ if ./selfhost/game-build.sh build/ludicc examples/strings.ludic "/tmp/ludic_str"
&& [ "$(/tmp/ludic_str </dev/null | tr '\n' ' ')" = "1 2 3 4 5 6 7 8 9 " ]; then
ok "strings.ludic (str ops, interpolation, slicing)"
else bad "strings: $(tail -1 /tmp/str.out)"; fi
# Scenes: one scene active at a time; only its layers' handlers run each phase
# (global handlers first, then the active scene's layers in declaration order).
# `become` runs the source scene's on-exit, switches, runs the target's on-enter.
# The active scene is snapshotted per phase, so the switch shows next phase (Play
# renders the frame it is entered). Trace: boot 1000, enter Title 1, Tick 101,
# Tick 102 -> become (exit 2, enter 3), Draw 900, Step 201, Draw 900, Step 202
# -> quit, Draw 900.
if ./selfhost/game-build.sh build/ludicc examples/scenes.ludic "/tmp/ludic_scn" >/tmp/scn.out 2>&1 \
&& [ "$(printf 'aaaaaaaaaa' | /tmp/ludic_scn | tr '\n' ' ')" = "1000 1 101 102 2 3 900 201 900 202 900 " ]; then
ok "scenes.ludic (scene/layer/on enter/on exit/become)"
else bad "scenes: $(tail -1 /tmp/scn.out)"; fi
# Structural attach/detach + @OnAttach/@OnDetach: attach seeds a property on a
# live entity and fires @OnAttach (5+10=15), one Shield matches (1), detach fires
# @OnDetach reading the outgoing value (5+20=25) then clears it (0).
if ./selfhost/game-build.sh build/ludicc examples/detach.ludic "/tmp/ludic_det" >/tmp/det.out 2>&1 \
&& [ "$(/tmp/ludic_det </dev/null | tr '\n' ' ')" = "15 1 25 0 " ]; then
ok "detach.ludic (attach/detach + @OnAttach/@OnDetach)"
else bad "detach: $(tail -1 /tmp/det.out)"; fi
# LC1 reason-carrying teardown: one @OnDespawn(M, reason: r) hook branches on the
# EndReason each site passes. An in-world despawn -> Despawned (drop loot 3+500),
# a survivor at quit -> Quit (skip loot 9+1000; no silent deaths).
if ./selfhost/game-build.sh build/ludicc examples/reason.ludic "/tmp/ludic_rsn" >/tmp/rsn.out 2>&1 \
&& [ "$(/tmp/ludic_rsn </dev/null | tr '\n' ' ')" = "503 1009 " ]; then
ok "reason.ludic (@OnDespawn reason: Despawned vs Quit)"
else bad "reason: $(tail -1 /tmp/rsn.out)"; fi
# EV0 the event bus: `event E { payload }` + `@On(E)` listeners fired by `emit`.
# emit Hurt(3,5) -> Flash 5, Guard 8; Hurt(10,20) -> 20, 30; Cleared -> 999;
# Hurt(amount:42) with entity defaulting to 0 -> 42, 42. Listeners run in
# declaration order; the whole thing desugars to a direct @ev_<E> call.
if ./selfhost/game-build.sh build/ludicc examples/events.ludic "/tmp/ludic_evt" >/tmp/evt.out 2>&1 \
&& [ "$(/tmp/ludic_evt </dev/null | tr '\n' ' ')" = "5 8 20 30 999 42 42 " ]; then
ok "events.ludic (event/emit/@On — the event bus core)"
else bad "events: $(tail -1 /tmp/evt.out)"; fi
# A pure-Ludic example that drives and asserts itself from its own `entry`: compile
# headless to IR, let clang assemble it (no C compiled — clang is only the IR
# assembler, the floor Rust/Swift stand on), run, and compare stdout.
netcase() { # name expected-output(space-joined)
local n="$1" exp="$2"
build/ludicc --headless "examples/$n.ludic" --emit-llvm -o "/tmp/$n.ll" >"/tmp/$n.out" 2>&1 \
&& ${LUDIC_CC:-clang} -O2 "/tmp/$n.ll" -o "/tmp/ludic_$n" >>"/tmp/$n.out" 2>&1 || { bad "$n: build ($(tail -1 /tmp/$n.out))"; return; }
local got; got=$("/tmp/ludic_$n" </dev/null | tr '\n' ' ' | sed 's/ *$//')
[ "$got" = "$exp" ] && ok "$n ($got)" || bad "$n: got [$got] want [$exp]"
}
# EV0b the event bus with two listeners on one event: Native prints each hit,
# Accum sums them. Fire 10 then 32 -> 10, 32, 42. (Pure Ludic; was a C mod.)
netcase mod_events "10 32 42"
# NETWORKING N0–N6 (NETWORKING-DESIGN.md) — every phase is a self-contained Ludic
# program, driven and asserted from its own `entry`. No C host and no C compiled.
# The transport is the compiler's built-in loopback (an `extern fn` overrides it
# with a real socket in production), so a networked game runs with zero foreign code.
# N0 transport seam: net_send/net_poll — the built-in loopback (no extern host)
netcase net_echo "4 10 20 30 42"
# N1 world snapshot to a buffer: world_size/world_save/world_load (rollback substrate)
netcase net_snapshot "50 7 50"
# N2 @Sync: per-model serialize/apply, selective — Player syncs 12 bytes, max kept mutated
netcase net_sync "12 3 4 50 999"
# N3 @Owned: owner/set_owner/is_owner — fresh entity unowned, then assigned/tested
netcase net_owner "-1 7 0 1"
# N4 remote events (RPCs): @ToServer emit → wire → net_pump re-emits (5+3=8)
netcase net_rpc "0 8"
# N5 roles + drivable sim: @Server gated by set_role; tick_fixed() drives the phases
netcase net_roles "1 102"
# N6 blessed runtime end to end: RPC input → authority applies → replicate → reconcile
netcase net_demo "5 999 5"
# EV1 @Public hook promotion: a @Public @OnSpawn/@OnDespawn also emits the public
# event model_<M>_spawn / model_<M>_despawn, which native @On listeners (and
# foreign mods over the ABI) subscribe to. Two spawns -> Spawned 100,101; two
# despawns -> Died 200,201 (entity + reason(0) + 200).
if ./selfhost/game-build.sh build/ludicc examples/promote.ludic "/tmp/ludic_prm" >/tmp/prm.out 2>&1 \
&& [ "$(/tmp/ludic_prm </dev/null | tr '\n' ' ')" = "100 101 200 201 " ]; then
ok "promote.ludic (@Public -> model_<M>_spawn/despawn events)"
else bad "promote: $(tail -1 /tmp/prm.out)"; fi
# EV3 cancellable events: a `cancellable` event fires before an action; a listener
# `cancel`s it and the caller reads the verdict from `emit E(...)` as an
# expression. Armor vetoes hits over 10: emit(5)->0, emit(15)->1; applying only
# un-vetoed damage leaves hp at 92.
if ./selfhost/game-build.sh build/ludicc examples/cancel.ludic "/tmp/ludic_cnl" >/tmp/cnl.out 2>&1 \
&& [ "$(/tmp/ludic_cnl </dev/null | tr '\n' ' ')" = "0 1 92 " ]; then
ok "cancel.ludic (cancellable event + cancel + emit-as-expression)"
else bad "cancel: $(tail -1 /tmp/cnl.out)"; fi
# EV2 the world table: an ECS program that declares events also generates the mod
# reflection ABI, now callable from Ludic via the world_* builtins (world_prop_id/
# field_id/get/set/has/count/kind/model_id/query_next/spawn/register_prop/attach_dyn)
# — the same functions a foreign mod binds by name. Each driver below reads and
# writes the world by NAME, never having compiled against a fixed layout.
netcase world_get "50 1 7" # EV2 read/write a field by name (has 1, hp 50->7)
netcase world_scan "2 110" # EV2b count/kind/model_id — scan + identify by model
netcase world_spawn "1 42 1" # EV2b world_spawn — create an entity by model id
netcase world_mixed "99" # EV2b real struct offsets (qty after a ptr, byte 8)
netcase world_query "2 110" # EV2b world_query_next — iterate bearers of a property
# EV6 re-entrancy bound: Ping's listener emits Pong and Pong's emits Ping — an
# event cycle. The @ev_depth cap makes the nesting trap as an early return, so the
# program terminates deterministically (n=16 at the cap) instead of hanging.
if ./selfhost/game-build.sh build/ludicc examples/recurse.ludic "/tmp/ludic_rec" >/tmp/rec.out 2>&1 \
&& [ "$(/tmp/ludic_rec </dev/null | tr '\n' ' ')" = "16 " ]; then
ok "recurse.ludic (EV6: re-entrant emit is depth-bounded, no runaway cycle)"
else bad "recurse: $(tail -1 /tmp/rec.out)"; fi
# EV5 despawn drops an entity from later event-driven work: an @On(Tick) listener
# counts live Units; ticking twice gives 2, then the Unit is despawned and a third
# tick finds none, so the count stays 2.
netcase scoped "2"
# EV7 schema opening: register a NEW component (Mana) the game never declared, get a
# prop id that works with the same get/set/has ABI, attach it, and keep per-entity
# storage isolated. has 0->1, fields 30/100, name resolves (1), other entity's write
# doesn't corrupt the first (still 30).
netcase world_dyn "0 1 30 100 1 30"
# EV1 for properties: @Public on @OnAttach/@OnDetach promotes to prop_<P>_attach /
# prop_<P>_detach, extending public events past models to properties. Attaching
# then detaching Shield on entity 0 -> Gained 300, Lost 400.
if ./selfhost/game-build.sh build/ludicc examples/prop_events.ludic "/tmp/ludic_pe" >/tmp/pe.out 2>&1 \
&& [ "$(/tmp/ludic_pe </dev/null | tr '\n' ' ')" = "300 400 " ]; then
ok "prop_events.ludic (@Public -> prop_<P>_attach/detach events)"
else bad "prop events: $(tail -1 /tmp/pe.out)"; fi
# EV1 for scenes: a `public` scene promotes on-enter/on-exit to scene_<S>_enter /
# scene_<S>_exit. Boot enters Menu (10, listener 1); `become Game` leaves Menu (20,
# listener 2) and enters Game (30, listener 3).
if ./selfhost/game-build.sh build/ludicc examples/scene_events.ludic "/tmp/ludic_se" >/tmp/se.out 2>&1 \
&& [ "$(/tmp/ludic_se </dev/null | tr '\n' ' ')" = "10 1 20 2 30 3 " ]; then
ok "scene_events.ludic (public scene -> scene_<S>_enter/exit events)"
else bad "scene events: $(tail -1 /tmp/se.out)"; fi
# EV1 for the program scope: @Public @OnStart/@OnQuit -> program_start/program_quit.
# Boot fires @OnStart(1) then program_start(100); shutdown fires @OnQuit(2) then
# program_quit(200) -> the top-level mod entry/exit points.
if ./selfhost/game-build.sh build/ludicc examples/program_events.ludic "/tmp/ludic_pge" >/tmp/pge.out 2>&1 \
&& [ "$(/tmp/ludic_pge </dev/null | tr '\n' ' ')" = "1 100 2 200 " ]; then
ok "program_events.ludic (@Public @OnStart/@OnQuit -> program_start/quit)"
else bad "program events: $(tail -1 /tmp/pge.out)"; fi
# EV1 for layers (+ SCENES E2 layer toggle): a `public` layer + `enable/disable
# layer L` flips the layer (its handlers stop) and fires layer_<L>_show/hide.
# Frame 1 Draw(50) then disable->Hidden(2); frame 2 Draw gated off, enable->Shown(1);
# frame 3 Draw(50) then quit. Needs input frames, so feed keys.
if ./selfhost/game-build.sh build/ludicc examples/layer_events.ludic "/tmp/ludic_le" >/tmp/le.out 2>&1 \
&& [ "$(printf 'aaaa' | /tmp/ludic_le | tr '\n' ' ')" = "50 2 1 50 " ]; then
ok "layer_events.ludic (layer toggle + public layer -> layer_<L>_show/hide)"
else bad "layer events: $(tail -1 /tmp/le.out)"; fi
# --- Toolchain-agent CLI smoke tests append below this line ---
echo "== self-hosted front-end binaries (ludicc / ludic) =="
# The two commands are one multi-call native binary built from the seed with

View file

@ -5,41 +5,39 @@
# ./tools/build-tools.sh --test build, then run tools/test-tools.sh
# ./tools/build-tools.sh --install also symlink both into ~/.local/bin
#
# Both binaries are plain C with no dependencies, same as the compiler. They
# share the lexer and vocabulary in tools/ludic-tools/ludic_syntax.h, so a
# keyword added there reaches every editor at once.
# Both binaries are written in Ludic (tools/ludic-tools/fmt.ludic, lsp.ludic) and
# compiled by the Ludic compiler itself — no C is compiled. clang only assembles
# the emitted LLVM IR, the same floor the compiler stands on. The vocabulary the
# grammar/lexer share still lives in tools/ludic-tools/ludic_syntax.h (data, read
# by check-vocabulary.py); the tools carry their own copy of it in Ludic.
set -e
cd "$(dirname "$0")/.."
CC="${LUDIC_CC:-clang}"
CFLAGS="-O2 -Wall -Wno-unused-function"
SRC=tools/ludic-tools
OUT=build
mkdir -p "$OUT"
build_one() {
local name="$1" main="$2"
local newest
# rebuild when any header or the entry point is newer than the binary
if [ -x "$OUT/$name" ]; then
newest=$(find "$SRC" -name '*.h' -o -name "$(basename "$main")" | while read -r f; do
[ "$f" -nt "$OUT/$name" ] && echo new
done) || true
if [ -z "$newest" ]; then echo "$name: up to date"; return; fi
# the Ludic compiler, assembled from the checked-in IR seed (C-free)
if [ ! -x "$OUT/ludicc" ] || [ selfhost/ludicc.seed.ll -nt "$OUT/ludicc" ]; then
echo "cc: selfhost/ludicc.seed.ll -> $OUT/ludicc (from the IR seed, no C compiler)"
$CC selfhost/ludicc.seed.ll -o "$OUT/ludicc"
fi
echo "cc: $main -> $OUT/$name"
$CC $CFLAGS "$main" -o "$OUT/$name"
build_ludic() {
local name="$1" src="$2"
if [ -x "$OUT/$name" ] && [ ! "$src" -nt "$OUT/$name" ] && [ ! "$OUT/ludicc" -nt "$OUT/$name" ]; then
echo "$name: up to date"; return
fi
echo "ludicc: $src -> $OUT/$name (Ludic -> LLVM IR -> binary, no C)"
"$OUT/ludicc" "$src" --emit-llvm -o "$OUT/$name.ll" >/dev/null 2>&1 \
&& $CC -O2 "$OUT/$name.ll" -o "$OUT/$name" \
&& rm -f "$OUT/$name.ll" || { echo "build failed: $name"; exit 1; }
}
build_one ludic-fmt "$SRC/ludic_fmt_main.c"
build_one ludic-lsp "$SRC/ludic_lsp.c"
# The language server shells out to the compiler for real diagnostics, so having
# it around is most of the value.
if [ ! -x "$OUT/ludicc" ]; then
echo "note: $OUT/ludicc not built yet — run ./build.sh for compiler diagnostics"
fi
build_ludic ludic-fmt "$SRC/fmt.ludic"
build_ludic ludic-lsp "$SRC/lsp.ludic"
# VS Code needs the grammar inside its own extension directory, so it gets a
# copy. A copy that can drift is the whole failure mode this toolchain is built

View file

@ -42,15 +42,15 @@ object LudicTokens {
object LudicVocabulary {
val DECL = setOf(
"program", "import", "property", "model", "enum", "ui",
"const", "var", "fn", "extern", "handler", "entry"
"const", "var", "fn", "extern", "handler", "entry", "event", "scene"
)
val CLAUSE = setOf(
"phase", "query", "on"
"phase", "query", "on", "cancellable", "public", "layer", "start"
)
val STMT = setOf(
"let", "return", "if", "else", "while", "for", "in", "spawn", "despawn",
"enable", "disable", "match", "machine", "state", "become", "where",
"and", "or", "not", "break", "continue", "new"
"and", "or", "not", "break", "continue", "new", "emit", "cancel"
)
val PRIMITIVES = setOf("int", "fixed", "bool", "entity", "str", "ptr", "byte", "words", "fixeds", "ptrs", "void")
val PHASES = setOf("Start", "Input", "FixedUpdate", "Update", "LateUpdate", "Render")

563
tools/ludic-tools/fmt.ludic Normal file
View file

@ -0,0 +1,563 @@
# fmt.ludic — the canonical Ludic formatter, written in Ludic (replaces the C
# ludic_fmt_main.c + ludic_fmt.h). Works on the token stream, so comments and
# blank lines survive and nothing is ever dropped or reordered — only the
# whitespace between tokens is normalized. Lines are re-indented and respaced but
# never joined or split. Mirrors tools/ludic-tools/ludic_fmt.h exactly.
#
# ludic-fmt a.ludic print the formatted text
# ludic-fmt -w a.ludic rewrite in place
# ludic-fmt --check a.ludic exit 1 if unformatted
# ludic-fmt a.md format the ```ludic fences in a document
# cat a.ludic | ludic-fmt - filter mode (stdin -> stdout)
program LudicFmt {
# ---- token kinds (mirror ludic_syntax.h) ----
const LT_EOF: int = 0
const LT_NL: int = 1
const LT_COMMENT: int = 2
const LT_ID: int = 3
const LT_KW: int = 4
const LT_TYPE: int = 5
const LT_PHASE: int = 6
const LT_BOOL: int = 7
const LT_INT: int = 8
const LT_FLOAT: int = 9
const LT_STR: int = 10
const LT_CHAR: int = 11
const LT_ANNO: int = 12
const LT_OP: int = 13
const LT_ERR: int = 14
# ---- tiny stdio + string helpers (self-contained) ----
fn read_file(path: str) -> ptr {
let f = file_open(path, "rb")
if (f == null) { return null }
file_seek(f, 0, 2)
let n = file_tell(f)
file_seek(f, 0, 0)
let buf = bytes(n + 1)
file_read(f, buf, n)
buf[n] = 0
file_close(f)
return buf
}
fn cstr_len(s: ptr) -> int { var n = 0; while s[n] != 0 { n = n + 1 }; return n }
fn char_is_digit(c: int) -> bool { return c >= 48 and c <= 57 }
fn char_is_alpha(c: int) -> bool {
if c >= 65 and c <= 90 { return true }
if c >= 97 and c <= 122 { return true }
return c == 95
}
fn char_is_alnum(c: int) -> bool { return char_is_alpha(c) or char_is_digit(c) }
fn char_is_hex(c: int) -> bool { return char_is_digit(c) or (c >= 97 and c <= 102) or (c >= 65 and c <= 70) }
fn itoa(v: int) -> ptr {
if v == 0 { let z = bytes(2); z[0] = 48; z[1] = 0; return z }
var neg = false; var x = v
if x < 0 { neg = true; x = 0 - x }
let tmp = bytes(16); var n = 0
while x > 0 { tmp[n] = 48 + x % 10; x = x / 10; n = n + 1 }
var total = n
if neg { total = total + 1 }
let out = bytes(total + 1); var k = 0
if neg { out[0] = 45; k = 1 }
var i = 0
while i < n { out[k + i] = tmp[n - 1 - i]; i = i + 1 }
out[total] = 0
return out
}
# ---- a growable byte buffer ----
property Buf { data: ptr = null, len: int = 0, cap: int = 0 }
fn buf_new() -> Buf { let b = new Buf; b.cap = 256; b.data = bytes(b.cap); b.len = 0; return b }
fn buf_ensure(b: Buf, extra: int) -> void {
if b.len + extra + 1 <= b.cap { return }
while b.len + extra + 1 > b.cap { b.cap = b.cap * 2 }
b.data = resize(b.data, b.cap)
}
fn buf_putc(b: Buf, c: int) -> void { buf_ensure(b, 1); b.data[b.len] = c; b.len = b.len + 1 }
fn buf_puts(b: Buf, s: ptr) -> void { var i = 0; while s[i] != 0 { buf_putc(b, s[i]); i = i + 1 } }
fn buf_indent(b: Buf, n: int) -> void { var i = 0; while i < n { buf_putc(b, 32); i = i + 1 } }
fn buf_str(b: Buf) -> ptr { b.data[b.len] = 0; return b.data }
# append src[a..b) raw
fn buf_addrange(b: Buf, s: ptr, a: int, e: int) -> void { var k = a; while k < e { buf_putc(b, s[k]); k = k + 1 } }
# ---- the token stream (parallel slices) ----
var src: ptr = null
var tk_kind: []int
var tk_start: []int
var tk_end: []int
var tk_line: []int
var linestart: []int
fn ntok() -> int { return len(tk_kind) }
fn tok_len(i: int) -> int { return tk_end[i] - tk_start[i] }
fn tok_text(i: int) -> ptr { return src[tk_start[i]..tk_end[i]] }
fn push_tok(k: int, st: int, en: int, ln: int) -> void {
push(tk_kind, k); push(tk_start, st); push(tk_end, en); push(tk_line, ln)
}
# ---- vocabulary classifiers ----
fn is_type_word(w: ptr) -> bool {
return (w == "int") or (w == "fixed") or (w == "bool") or (w == "entity") or (w == "str") or (w == "ptr") or (w == "byte") or (w == "words") or (w == "fixeds") or (w == "ptrs") or (w == "void")
}
fn is_phase_word(w: ptr) -> bool {
return (w == "Start") or (w == "Input") or (w == "FixedUpdate") or (w == "Update") or (w == "LateUpdate") or (w == "Render")
}
fn is_keyword_word(w: ptr) -> bool {
if (w == "program") or (w == "import") or (w == "property") or (w == "model") or (w == "enum") or (w == "ui") { return true }
if (w == "const") or (w == "var") or (w == "fn") or (w == "extern") or (w == "handler") or (w == "entry") or (w == "event") or (w == "scene") { return true }
if (w == "phase") or (w == "query") or (w == "on") or (w == "cancellable") or (w == "public") or (w == "layer") or (w == "start") { return true }
if (w == "let") or (w == "return") or (w == "if") or (w == "else") or (w == "while") or (w == "for") or (w == "in") or (w == "spawn") or (w == "despawn") { return true }
if (w == "enable") or (w == "disable") or (w == "match") or (w == "machine") or (w == "state") or (w == "become") or (w == "where") { return true }
if (w == "and") or (w == "or") or (w == "not") or (w == "break") or (w == "continue") or (w == "new") or (w == "emit") or (w == "cancel") { return true }
return false
}
fn is_clause_word(w: ptr) -> bool {
return (w == "phase") or (w == "query") or (w == "reads") or (w == "writes") or (w == "needs") or (w == "uses") or (w == "requires") or (w == "ensures") or (w == "invariant") or (w == "effects")
}
# ---- the lexer: keeps comments, newlines, byte spans; never exits on bad input ----
fn is_op2(c0: int, c1: int) -> bool {
if c0 == 45 and c1 == 62 { return true } # ->
if c1 == 61 and (c0 == 43 or c0 == 45 or c0 == 42 or c0 == 47 or c0 == 61 or c0 == 33 or c0 == 60 or c0 == 62) { return true } # += -= *= /= == != <= >=
if c0 == 38 and c1 == 38 { return true } # &&
if c0 == 124 and c1 == 124 { return true } # ||
if c0 == 46 and c1 == 46 { return true } # ..
if c0 == 61 and c1 == 62 { return true } # =>
return false
}
fn is_op1(c: int) -> bool {
return c == 43 or c == 45 or c == 42 or c == 47 or c == 37 or c == 60 or c == 62 or c == 61 or c == 40 or c == 41 or c == 123 or c == 125 or c == 91 or c == 93 or c == 44 or c == 58 or c == 46 or c == 33 or c == 64 or c == 59
}
fn lex(s: ptr) -> void {
src = s
tk_kind = new []int; tk_start = new []int; tk_end = new []int; tk_line = new []int
linestart = new []int
push(linestart, 0)
var i = 0; var line = 0
while s[i] != 0 {
let c = s[i]
if c == 10 { push_tok(LT_NL, i, i + 1, line); i = i + 1; line = line + 1; push(linestart, i); continue }
if c == 32 or c == 9 or c == 13 { i = i + 1; continue }
if c == 35 { # '#' comment to end of line
let st = i; while s[i] != 0 and s[i] != 10 { i = i + 1 }; push_tok(LT_COMMENT, st, i, line); continue
}
if c == 34 { # "string"
let st = i; i = i + 1
while s[i] != 0 and s[i] != 34 and s[i] != 10 { if s[i] == 92 and s[i + 1] != 0 { i = i + 2 } else { i = i + 1 } }
if s[i] == 34 { i = i + 1 }
push_tok(LT_STR, st, i, line); continue
}
if c == 96 { # `interpolated`
let st = i; i = i + 1
while s[i] != 0 and s[i] != 96 { if s[i] == 92 and s[i + 1] != 0 { i = i + 2 } else { i = i + 1 } }
if s[i] == 96 { i = i + 1 }
push_tok(LT_STR, st, i, line); continue
}
if c == 39 { # 'c'
let st = i; i = i + 1
if s[i] == 92 and s[i + 1] != 0 { i = i + 2 } else { if s[i] != 0 and s[i] != 10 { i = i + 1 } }
if s[i] == 39 { i = i + 1 }
push_tok(LT_CHAR, st, i, line); continue
}
if char_is_digit(c) {
let st = i
if c == 48 and (s[i + 1] == 120 or s[i + 1] == 88) { # 0x hex
i = i + 2; while char_is_hex(s[i]) { i = i + 1 }; push_tok(LT_INT, st, i, line); continue
}
while char_is_digit(s[i]) { i = i + 1 }
if s[i] == 46 and char_is_digit(s[i + 1]) {
i = i + 1; while char_is_digit(s[i]) { i = i + 1 }; push_tok(LT_FLOAT, st, i, line); continue
}
push_tok(LT_INT, st, i, line); continue
}
if c == 64 and (char_is_alpha(s[i + 1]) or s[i + 1] == 95) { # @name annotation
let st = i; i = i + 1; while char_is_alnum(s[i]) { i = i + 1 }; push_tok(LT_ANNO, st, i, line); continue
}
if char_is_alpha(c) {
let st = i; while char_is_alnum(s[i]) { i = i + 1 }
let w = s[st..i]
var k = LT_ID
if (w == "true") or (w == "false") or (w == "null") { k = LT_BOOL }
else { if is_type_word(w) { k = LT_TYPE }
else { if is_phase_word(w) { k = LT_PHASE }
else { if is_keyword_word(w) { k = LT_KW } } } }
push_tok(k, st, i, line); continue
}
if is_op2(c, s[i + 1]) { push_tok(LT_OP, i, i + 2, line); i = i + 2; continue }
if is_op1(c) { push_tok(LT_OP, i, i + 1, line); i = i + 1; continue }
# anything else: one UTF-8 character's worth as an LT_ERR token
var ln = 1
if c >= 240 { ln = 4 } else { if c >= 224 { ln = 3 } else { if c >= 128 { ln = 2 } } }
var kk = 1
while kk < ln { if s[i + kk] == 0 or (s[i + kk] & 192) != 128 { ln = kk }; kk = kk + 1 }
push_tok(LT_ERR, i, i + ln, line); i = i + ln
}
push_tok(LT_EOF, i, i, line)
}
# ---- token-stream helpers ----
fn next_sig(i: int) -> int {
var j = i + 1
while j < ntok() { let k = tk_kind[j]; if k != LT_NL and k != LT_COMMENT { return j }; j = j + 1 }
return 0 - 1
}
fn prev_sig(i: int) -> int {
var j = i - 1
while j >= 0 { let k = tk_kind[j]; if k != LT_NL and k != LT_COMMENT { return j }; j = j - 1 }
return 0 - 1
}
fn name_like(k: int) -> bool { return k == LT_ID or k == LT_KW or k == LT_TYPE or k == LT_PHASE or k == LT_BOOL }
# a '.' hugs an operand, a closing bracket, and another dot
fn dot_tight(t: int) -> bool {
if name_like(tk_kind[t]) { return true }
if tk_kind[t] != LT_OP { return false }
let n = tok_len(t); let c0 = src[tk_start[t]]
if n == 1 and (c0 == 41 or c0 == 93 or c0 == 46) { return true }
if n == 2 and c0 == 46 and src[tk_start[t] + 1] == 46 { return true }
return false
}
# is the token at index i a unary '-'/'!' rather than a binary operator?
fn is_unary(i: int) -> bool {
if tk_kind[i] != LT_OP { return false }
let n = tok_len(i)
if not (n == 1 and (src[tk_start[i]] == 45 or src[tk_start[i]] == 33)) { return false }
let p = prev_sig(i)
if p < 0 { return true }
let pk = tk_kind[p]
if pk == LT_ID or pk == LT_INT or pk == LT_FLOAT or pk == LT_STR or pk == LT_CHAR or pk == LT_BOOL or pk == LT_TYPE or pk == LT_PHASE { return false }
if pk == LT_OP {
let c = src[tk_start[p]]
return not (tok_len(p) == 1 and (c == 41 or c == 93 or c == 125)) # a closing bracket ends an operand
}
return true # keyword/annotation/comment: operand starts here
}
# whitespace between the previous emitted token (prev) and the current one (cur)
fn space_before(prev: int, cur: int) -> int {
if prev < 0 { return 0 }
let pk = tk_kind[prev]; let ck = tk_kind[cur]
let plen = tok_len(prev); let clen = tok_len(cur)
let p0 = src[tk_start[prev]]; let c0 = src[tk_start[cur]]
let p1 = (plen == 1); let c1 = (clen == 1)
if pk == LT_ERR or ck == LT_ERR { return tk_start[cur] - tk_end[prev] } # preserve an error token's spacing
if c1 and (c0 == 41 or c0 == 93 or c0 == 44 or c0 == 58 or c0 == 59) { return 0 } # ) ] , : ;
if c1 and c0 == 46 and ck == LT_OP and dot_tight(prev) { return 0 }
if p1 and p0 == 46 and pk == LT_OP and dot_tight(cur) { return 0 }
if p1 and (p0 == 40 or p0 == 91) and pk == LT_OP { return 0 } # nothing hugs an opener from the right
if pk == LT_OP and is_unary(prev) { return 0 }
if pk == LT_ANNO and c1 and c0 == 40 { return 0 } # @anno(
if c1 and c0 == 40 and ck == LT_OP {
if pk == LT_ID or pk == LT_TYPE or pk == LT_PHASE { return 0 } # fn move( / clear(
if pk == LT_OP { if p1 and (p0 == 41 or p0 == 93) { return 1 }; return 0 }
return 1
}
return 1
}
# ---- the formatting pass ----
fn format(indent_width: int) -> Buf {
let o = buf_new()
let stack = words(600)
let hang = words(600)
var sp = 0
var cur = 0
var open = 0
var brack = 0
var ui_depth = 0 - 1
var pending_blank = 0
var wrote_any = 0
var prev_line_had_comment = 0
let N = ntok()
var i = 0
while i < N and tk_kind[i] != LT_EOF {
let a = i
while i < N and tk_kind[i] != LT_NL and tk_kind[i] != LT_EOF { i = i + 1 }
let b = i
if i < N and tk_kind[i] == LT_NL { i = i + 1 }
if a == b { # a blank line
if wrote_any == 1 { pending_blank = 1 }
continue
}
# where does this line start?
var line_level = cur
var tsp = sp
var t = a
while t < b and tk_kind[t] == LT_OP and tok_len(t) == 1 and src[tk_start[t]] == 125 { # leading '}'
if tsp > 0 { tsp = tsp - 1; line_level = stack[tsp] }
t = t + 1
}
# original column of this line
var orig_ind = 0
var kk = linestart[tk_line[a]]
while kk < tk_start[a] { if src[kk] == 9 { orig_ind = orig_ind + 4 } else { orig_ind = orig_ind + 1 }; kk = kk + 1 }
# a comment continuing the previous comment line keeps the author's column
var comment_run = 0
if tk_kind[a] == LT_COMMENT and b == a + 1 and prev_line_had_comment == 1 { comment_run = 1 }
var ind = 0
var hangprev = 0
if sp > 0 { hangprev = hang[sp - 1] }
if open > 0 or (sp > 0 and hangprev == 1) { # author owns alignment inside open call/brace
let ls = linestart[tk_line[a]]
ind = 0
var k2 = ls
while k2 < tk_start[a] { if src[k2] == 9 { ind = ind + 4 } else { ind = ind + 1 }; k2 = k2 + 1 }
} else {
var extra = 0
if is_clause_word(tok_text(a)) and tk_kind[a] == LT_KW { extra = indent_width }
ind = line_level * indent_width + extra
if comment_run == 1 and orig_ind > ind { ind = orig_ind }
}
if pending_blank == 1 and wrote_any == 1 { buf_putc(o, 10) }
pending_blank = 0
buf_indent(o, ind)
# emit the tokens
var prev = 0 - 1
var line_brack = brack
var line_ui_open = 0
if ui_depth >= 0 and sp > ui_depth { line_ui_open = 1 }
t = a
while t < b {
var want = 0
if tk_kind[t] == LT_COMMENT { if prev >= 0 { want = 2 } else { want = 0 } }
else {
want = space_before(prev, t)
if line_brack > 0 { # query {Tag} filter is one word
if tk_kind[t] == LT_OP and tok_len(t) == 1 and src[tk_start[t]] == 125 { want = 0 }
if prev >= 0 and tk_kind[prev] == LT_OP and tok_len(prev) == 1 and src[tk_start[prev]] == 123 { want = 0 }
}
if line_ui_open == 1 { # widget props are k=v
var eq_here = 0
if tk_kind[t] == LT_OP and tok_len(t) == 1 and src[tk_start[t]] == 61 { eq_here = 1 }
var eq_prev = 0
if prev >= 0 and tk_kind[prev] == LT_OP and tok_len(prev) == 1 and src[tk_start[prev]] == 61 { eq_prev = 1 }
if eq_here == 1 or eq_prev == 1 { want = 0 }
}
}
var gap = 0
if prev >= 0 { gap = tk_start[t] - tk_end[prev] }
if gap >= 2 and want >= 1 { if gap > 60 { gap = 60 }; want = gap } # hand alignment wins
if want < 0 { want = 0 }
var w2 = 0
while w2 < want { buf_putc(o, 32); w2 = w2 + 1 }
buf_addrange(o, src, tk_start[t], tk_end[t])
prev = t
if tk_kind[t] == LT_OP and tok_len(t) == 1 {
let c = src[tk_start[t]]
if c == 91 { line_brack = line_brack + 1 }
else { if c == 93 { line_brack = line_brack - 1; if line_brack < 0 { line_brack = 0 } } }
}
t = t + 1
}
buf_putc(o, 10)
wrote_any = 1
prev_line_had_comment = 0
if prev >= 0 and tk_kind[prev] == LT_COMMENT { prev_line_had_comment = 1 }
# carry the nesting into the next line
if tk_kind[a] == LT_KW and (tok_text(a) == "ui") and ui_depth < 0 { ui_depth = sp }
var level = cur
t = a
while t < b {
if tk_kind[t] == LT_OP and tok_len(t) == 1 {
let c = src[tk_start[t]]
if c == 123 { # '{'
if sp < 512 {
let nxt = next_sig(t)
stack[sp] = line_level
var h = 0
if nxt >= 0 and nxt < b { h = 1 }
hang[sp] = h
sp = sp + 1
}
level = line_level + 1
}
else { if c == 125 { if sp > 0 { sp = sp - 1; level = stack[sp] } }
else { if c == 40 or c == 91 { open = open + 1; if c == 91 { brack = brack + 1 } }
else { if c == 41 or c == 93 { open = open - 1; if open < 0 { open = 0 }; if c == 93 { brack = brack - 1; if brack < 0 { brack = 0 } } } } } }
}
t = t + 1
}
cur = level
if ui_depth >= 0 and sp <= ui_depth { ui_depth = 0 - 1 }
}
return o
}
# ---- markdown: format the body of every ```ludic fence, leave prose alone ----
var g_flen: int = 0
var g_info: int = 0
var g_marker: int = 0
# detect a fence at line offset i; sets g_flen/g_info/g_marker; returns bool
fn md_fence_at(s: ptr, i: int) -> bool {
var j = i; while s[j] == 32 { j = j + 1 }
let m = s[j]
if m != 96 and m != 126 { return false } # ` or ~
var n = 0; while s[j] == m { j = j + 1; n = n + 1 }
if n < 3 { return false }
g_flen = n; g_info = j; g_marker = m
return true
}
fn md_info_is_ludic(s: ptr, at: int) -> bool {
var a = at; while s[a] == 32 or s[a] == 9 { a = a + 1 }
# case-insensitive "ludic"
if not (((s[a] == 108 or s[a] == 76)) and ((s[a + 1] == 117 or s[a + 1] == 85)) and ((s[a + 2] == 100 or s[a + 2] == 68)) and ((s[a + 3] == 105 or s[a + 3] == 73)) and ((s[a + 4] == 99 or s[a + 4] == 67))) { return false }
let af = s[a + 5]
return af == 0 or af == 10 or af == 32 or af == 9 or af == 13
}
fn line_end(s: ptr, i: int) -> int { var e = i; while s[e] != 0 and s[e] != 10 { e = e + 1 }; return e }
fn format_markdown(s: ptr, indent_width: int) -> Buf {
let o = buf_new()
var i = 0
while s[i] != 0 {
let ls = i
let le = line_end(s, ls)
var indent = 0; while s[ls + indent] == 32 { indent = indent + 1 }
if md_fence_at(s, ls) and md_info_is_ludic(s, g_info) {
# copy the opening fence line verbatim (with its newline)
var e0 = le; if s[le] != 0 { e0 = le + 1 }
buf_addrange(o, s, ls, e0)
i = e0
# gather the body up to the closing fence
let bs = i
var be = bs
while true {
if s[be] == 0 { break }
let ps = be; let pe = line_end(s, ps)
if md_fence_at(s, ps) and g_marker == g_marker and g_flen >= g_flen {
# re-run detection for THIS line (md_fence_at set globals for ps)
let cmark = g_marker; let clen = g_flen; let cinfo = g_info
if md_fence_at(s, ps) {
if g_marker == cmark and g_flen >= clen {
var only = 1
var k = g_info
while k < pe { if s[k] != 32 and s[k] != 13 { only = 0; break }; k = k + 1 }
if only == 1 { be = ps; break }
}
}
}
if s[pe] != 0 { be = pe + 1 } else { be = pe }
}
# de-indent the body, format it, re-indent it
let body = buf_new()
var p = bs
while p < be {
var q = line_end(s, p)
var skip = 0
while skip < indent and (p + skip) < q and s[p + skip] == 32 { skip = skip + 1 }
buf_addrange(body, s, p + skip, q)
buf_putc(body, 10)
if s[q] != 0 { p = q + 1 } else { p = q }
}
# feed the body through the ludic formatter
lex(buf_str(body))
let f = format(indent_width)
let ftext = buf_str(f)
var fp = 0
while ftext[fp] != 0 {
var fq = fp; while ftext[fq] != 0 and ftext[fq] != 10 { fq = fq + 1 }
if fq > fp { buf_indent(o, indent) }
buf_addrange(o, ftext, fp, fq)
buf_putc(o, 10)
if ftext[fq] != 0 { fp = fq + 1 } else { fp = fq }
}
i = be
continue
}
var e1 = le; if s[le] != 0 { e1 = le + 1 }
buf_addrange(o, s, ls, e1)
i = e1
}
return o
}
# ---- ends-with helpers for extension detection ----
fn ends_with(s: ptr, suf: ptr) -> bool {
let n = cstr_len(s); let m = cstr_len(suf)
if m > n { return false }
return (s[n - m..n] == suf)
}
# ---- stdin slurp (for `-`) ----
fn slurp_stdin() -> ptr {
let b = buf_new()
var c = read_char()
while c >= 0 { buf_putc(b, c); c = read_char() }
return buf_str(b)
}
# format one source string according to its kind (markdown vs ludic)
fn format_source(text: ptr, is_md: bool, indent_width: int) -> ptr {
if is_md { let m = format_markdown(text, indent_width); return buf_str(m) }
lex(text)
let f = format(indent_width)
return buf_str(f)
}
fn streq(a: ptr, b: ptr) -> bool { return (a == b) }
entry {
var write = false
var check = false
var indent = 2
var quiet = false
var changed = false
var failed = false
let files = new []ptr
var ai = 1
while ai < arg_count() {
let a = arg(ai)
if (a == "-w") or (a == "--write") { write = true }
else { if (a == "--check") or (a == "-l") { check = true }
else { if (a == "-q") or (a == "--quiet") { quiet = true }
else { if (a == "--indent") { ai = ai + 1; if ai < arg_count() { indent = 0; let d = arg(ai); var di = 0; while d[di] != 0 { indent = indent * 10 + (d[di] - 48); di = di + 1 } } }
else { if (a == "-h") or (a == "--help") { print("ludic-fmt — format Ludic source"); return }
else { push(files, a) } } } } }
ai = ai + 1
}
if indent < 1 or indent > 8 { indent = 2 }
# stdin filter mode
if len(files) == 0 or (len(files) == 1 and (files[0] == "-")) {
let text = slurp_stdin()
let out = format_source(text, false, indent)
file_write(file_stdout(), out, cstr_len(out))
return
}
var fi = 0
while fi < len(files) {
let path = files[fi]
let text = read_file(path)
if (text == null) {
let m = `ludic-fmt: cannot open {path}\n`
file_write(file_stderr(), m, cstr_len(m)); failed = true
} else {
let is_md = ends_with(path, ".md") or ends_with(path, ".markdown")
let out = format_source(text, is_md, indent)
let same = (out == text)
if check {
if not same { changed = true; if not quiet { print(path) } }
} else { if write {
if not same {
let f = file_open(path, "wb")
if (f == null) { let m = `ludic-fmt: cannot write {path}\n`; file_write(file_stderr(), m, cstr_len(m)); failed = true }
else { file_write(f, out, cstr_len(out)); file_close(f); if not quiet { let m = `formatted {path}\n`; file_write(file_stderr(), m, cstr_len(m)) } }
changed = true
}
} else {
file_write(file_stdout(), out, cstr_len(out))
} }
}
fi = fi + 1
}
if failed { exit(2) }
if check and changed { exit(1) }
}
}

2409
tools/ludic-tools/lsp.ludic Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,339 +0,0 @@
/* ============================================================================
* ludic_fmt.h — the canonical Ludic formatter.
*
* This is deliberately NOT `ludicc --fmt`. The compiler's printer walks the AST
* after import splicing, so it drops every comment and inlines every imported
* file into whichever file you pointed it at — fine for inspecting what the
* compiler saw, catastrophic as an editor's "format on save".
*
* This formatter works on the token stream instead:
* - comments and blank lines survive, because they are tokens;
* - nothing is ever dropped or reordered, because every token is re-emitted
* in order — the only freedom taken is the whitespace between them;
* - lines are re-indented and respaced but never joined or split, so the
* author keeps control of line structure and a format-on-save never
* rewrites a file out from under someone mid-edit.
* ==========================================================================*/
#ifndef LUDIC_FMT_H
#define LUDIC_FMT_H
#include "ludic_syntax.h"
typedef struct { char* b; size_t n, cap; } FSB;
static void fsb_ensure(FSB* s, size_t add){
if (s->n + add + 1 > s->cap){ s->cap = (s->n + add + 1) * 2; s->b = realloc(s->b, s->cap); }
}
static void fsb_add(FSB* s, const char* z, size_t l){ fsb_ensure(s, l); memcpy(s->b + s->n, z, l); s->n += l; s->b[s->n] = 0; }
static void fsb_puts(FSB* s, const char* z){ fsb_add(s, z, strlen(z)); }
static void fsb_putc(FSB* s, char c){ fsb_add(s, &c, 1); }
static void fsb_indent(FSB* s, int n){ for (int i = 0; i < n; i++) fsb_putc(s, ' '); }
/* Can this token be one side of a member access? */
static int lud_name_like(int kind){
return kind == LT_ID || kind == LT_KW || kind == LT_TYPE || kind == LT_PHASE || kind == LT_BOOL;
}
/* A '.' hugs an operand, a closing bracket, and another dot — the last so that
* a run of dots stays a run of dots instead of being spaced into pieces. */
static int lud_dot_tight(const LLex* L, const LTok* t){
if (lud_name_like(t->kind)) return 1;
if (t->kind != LT_OP) return 0;
int n = ltok_len(t);
char c0 = L->src[t->start];
if (n == 1 && (c0 == ')' || c0 == ']' || c0 == '.')) return 1;
if (n == 2 && c0 == '.' && L->src[t->start + 1] == '.') return 1;
return 0;
}
/* Is the token at index i a unary '-' / '!' rather than a binary operator?
* Unary iff nothing that can end an operand precedes it. */
static int fmt_is_unary(const LLex* L, int i){
const LTok* t = &L->v[i];
if (t->kind != LT_OP) return 0;
int n = ltok_len(t);
if (!(n == 1 && (L->src[t->start] == '-' || L->src[t->start] == '!'))) return 0;
int p = ltok_prev_sig(L, i);
if (p < 0) return 1;
const LTok* pt = &L->v[p];
switch (pt->kind){
case LT_ID: case LT_INT: case LT_FLOAT: case LT_STR: case LT_CHAR:
case LT_BOOL: case LT_TYPE: case LT_PHASE:
return 0;
case LT_OP: {
char c = L->src[pt->start];
/* a closing bracket ends an operand; every other operator does not */
return !(ltok_len(pt) == 1 && (c == ')' || c == ']' || c == '}'));
}
default: return 1; /* keyword, annotation, comment: operand starts here */
}
}
/* Whitespace between the previous emitted token (prev) and the current one. */
static int fmt_space_before(const LLex* L, int prev, int cur){
if (prev < 0) return 0;
const LTok* p = &L->v[prev];
const LTok* c = &L->v[cur];
const char* src = L->src;
int plen = ltok_len(p), clen = ltok_len(c);
char p0 = src[p->start], c0 = src[c->start];
int p1 = (plen == 1), c1 = (clen == 1);
/* An error token is bytes we did not understand. Whatever spacing it had
* is the only spacing we can justify, so it is preserved verbatim. */
if (p->kind == LT_ERR || c->kind == LT_ERR) return c->start - p->end;
/* nothing hugs a closer, a separator or a member dot from the left */
if (c1 && (c0 == ')' || c0 == ']' || c0 == ',' || c0 == ':' || c0 == ';')) return 0;
/* `.` binds tight only when it really is member access — `a.b`. A lone dot
* next to a brace is something else (or a typo) and gets normal spacing. */
if (c1 && c0 == '.' && c->kind == LT_OP && lud_dot_tight(L, p)) return 0;
if (p1 && p0 == '.' && p->kind == LT_OP && lud_dot_tight(L, c)) return 0;
/* nothing hugs an opener from the right */
if (p1 && (p0 == '(' || p0 == '[') && p->kind == LT_OP) return 0;
/* a unary sign binds to its operand */
if (p->kind == LT_OP && fmt_is_unary(L, prev)) return 0;
/* @anno(args) */
if (p->kind == LT_ANNO && c1 && c0 == '(') return 0;
if (c1 && c0 == '(' && c->kind == LT_OP){
/* `fn move(` and `clear(` hug; `if (`, `return (`, `x * (` do not */
switch (p->kind){
case LT_ID: case LT_TYPE: case LT_PHASE: return 0;
case LT_OP: return !(p1 && (p0 == ')' || p0 == ']'));
default: return 1;
}
}
return 1;
}
/* Clause keywords hang under the declaration they qualify: a system's
* `phase`/`query` lines and a function's contract lines are indented one level
* past the `system`/`fn` they belong to, with the body brace back at the
* declaration's own level. Every example in the tree is written that way. */
static int fmt_is_clause_word(const LLex* L, int i){
static const char* CLAUSES[] = { "phase","query","reads","writes","needs","uses",
"requires","ensures","invariant","effects", 0 };
if (i < 0 || i >= L->n || L->v[i].kind != LT_KW) return 0;
char b[32]; ltok_text(L, i, b, sizeof(b));
return lud_in(CLAUSES, b);
}
/* One pass over the token stream, re-emitting it with canonical whitespace.
*
* Two carve-outs keep the result idiomatic rather than merely uniform:
* - a run of two or more spaces is preserved verbatim, so hand-aligned
* columns (`const R_DIR: int = 0`) and deliberately set-off trailing
* comments survive a format-on-save;
* - `id=Root` inside a `ui` block and `{Enemy}` inside a query stay tight,
* because those are the spellings the language documents and uses.
*/
static char* ludic_format(const char* src, int indent_width){
LLex L; lud_lex(&L, src);
FSB o = {0};
/* One entry per open brace, holding the indent level to return to. Every
* brace opened on the same line shares that line's level, so a line like
* `if a { if b { if c {` steps in by ONE level, not three — and the line
* that closes them all lands back where it started. */
int stack[512]; int hang[512]; int sp = 0;
int cur = 0;
int open = 0; /* unclosed ( or [ : the author owns the alignment */
int brack = 0; /* unclosed [ only: query-term context */
int ui_depth = -1; /* brace depth just outside the innermost `ui` block */
int pending_blank = 0;
int wrote_any = 0;
int prev_line_had_comment = 0;
int i = 0;
while (i < L.n && L.v[i].kind != LT_EOF){
int a = i;
while (i < L.n && L.v[i].kind != LT_NL && L.v[i].kind != LT_EOF) i++;
int b = i; /* [a,b) are this line's tokens */
if (i < L.n && L.v[i].kind == LT_NL) i++;
if (a == b){ /* a blank line */
if (wrote_any) pending_blank = 1;
continue;
}
/* ---- where does this line start? --------------------------------- */
int line_level = cur;
{ /* leading closers belong to the level of the line that opened them */
int tsp = sp, t = a;
while (t < b && L.v[t].kind == LT_OP && ltok_len(&L.v[t]) == 1 && src[L.v[t].start] == '}'){
if (tsp > 0) line_level = stack[--tsp];
t++;
}
}
/* Original column of this line, for the cases where the author's
* alignment is the only sensible answer. */
int orig_ind = 0;
for (int k = L.linestart[L.v[a].line]; k < L.v[a].start; k++) orig_ind += (src[k] == '\t') ? 4 : 1;
/* A comment on its own line, indented past its block and following a
* line that itself ended in a comment, is the continuation of that
* comment — a column the author chose, not stray indentation. */
int comment_run = (L.v[a].kind == LT_COMMENT && b == a + 1 && prev_line_had_comment);
int ind;
if (open > 0 || (sp > 0 && hang[sp - 1])){
/* Inside an unclosed call, query, or a brace that was opened with
* content trailing it, the author is aligning to a column the
* formatter cannot see. Leave those lines exactly as written. */
int ls = L.linestart[L.v[a].line];
ind = 0;
for (int k = ls; k < L.v[a].start; k++) ind += (src[k] == '\t') ? 4 : 1;
} else {
ind = line_level * indent_width + (fmt_is_clause_word(&L, a) ? indent_width : 0);
if (comment_run && orig_ind > ind) ind = orig_ind;
}
if (pending_blank && wrote_any) fsb_putc(&o, '\n');
pending_blank = 0;
fsb_indent(&o, ind);
/* ---- the tokens --------------------------------------------------- */
int prev = -1;
int line_brack = brack, line_ui_open = (ui_depth >= 0 && sp > ui_depth);
for (int t = a; t < b; t++){
const LTok* tk = &L.v[t];
int gap = (prev >= 0) ? tk->start - L.v[prev].end : 0;
int want;
if (tk->kind == LT_COMMENT){
want = (prev >= 0) ? 2 : 0; /* set a trailing comment off */
} else {
want = fmt_space_before(&L, prev, t);
/* a query's {Tag} filter is one word, not a record literal */
if (line_brack > 0){
if (tk->kind == LT_OP && ltok_len(tk) == 1 && src[tk->start] == '}') want = 0;
if (prev >= 0 && L.v[prev].kind == LT_OP && ltok_len(&L.v[prev]) == 1 && src[L.v[prev].start] == '{') want = 0;
}
/* widget props are written k=v */
if (line_ui_open){
int eq_here = tk->kind == LT_OP && ltok_len(tk) == 1 && src[tk->start] == '=';
int eq_prev = prev >= 0 && L.v[prev].kind == LT_OP &&
ltok_len(&L.v[prev]) == 1 && src[L.v[prev].start] == '=';
if (eq_here || eq_prev) want = 0;
}
}
/* hand alignment wins over the canonical single space */
if (gap >= 2 && want >= 1){ if (gap > 60) gap = 60; want = gap; }
if (want < 0) want = 0;
for (int k = 0; k < want; k++) fsb_putc(&o, ' ');
fsb_add(&o, src + tk->start, ltok_len(tk));
prev = t;
if (tk->kind == LT_OP && ltok_len(tk) == 1){
char c = src[tk->start];
if (c == '[') line_brack++;
else if (c == ']'){ line_brack--; if (line_brack < 0) line_brack = 0; }
}
}
fsb_putc(&o, '\n');
wrote_any = 1;
prev_line_had_comment = (prev >= 0 && L.v[prev].kind == LT_COMMENT);
/* ---- carry the nesting into the next line ------------------------- */
if (ltok_is(&L, a, "ui") && L.v[a].kind == LT_KW && ui_depth < 0) ui_depth = sp;
{
int level = cur;
for (int t = a; t < b; t++){
const LTok* tk = &L.v[t];
if (tk->kind != LT_OP || ltok_len(tk) != 1) continue;
char c = src[tk->start];
if (c == '{'){
if (sp < 512){
int nxt = ltok_next_sig(&L, t);
stack[sp] = line_level;
hang[sp] = (nxt >= 0 && nxt < b); /* content follows on this line */
sp++;
}
level = line_level + 1;
}
else if (c == '}'){ if (sp > 0) level = stack[--sp]; }
else if (c == '(' || c == '['){ open++; if (c == '[') brack++; }
else if (c == ')' || c == ']'){ open--; if (open < 0) open = 0; if (c == ']'){ brack--; if (brack < 0) brack = 0; } }
}
cur = level;
}
if (ui_depth >= 0 && sp <= ui_depth) ui_depth = -1;
}
lud_lex_free(&L);
if (!o.b) { o.b = malloc(1); o.b[0] = 0; }
return o.b;
}
/* ---------- markdown ------------------------------------------------------
* Fenced Ludic in prose is still Ludic. This rewrites the body of every
* ```ludic fence in a Markdown document and leaves the prose untouched, so
* LANGUAGE.md and README.md can be kept honest by the same formatter as the
* source tree. Fence indentation (a fence inside a list item) is preserved. */
static int md_fence_at(const char* s, int i, int* fence_len, int* info_at, char* marker){
int j = i, n = 0;
while (s[j] == ' ') j++; /* leading indent */
char m = s[j];
if (m != '`' && m != '~') return 0;
while (s[j] == m){ j++; n++; }
if (n < 3) return 0;
*fence_len = n; *info_at = j; *marker = m;
return 1;
}
static int md_info_is_ludic(const char* s, int at){
while (s[at] == ' ' || s[at] == '\t') at++;
if (strncasecmp(s + at, "ludic", 5)) return 0;
char after = s[at + 5];
return after == 0 || after == '\n' || after == ' ' || after == '\t' || after == '\r';
}
static char* ludic_format_markdown(const char* src, int indent_width){
FSB o = {0};
int i = 0;
while (src[i]){
int ls = i; /* line start */
int le = ls; while (src[le] && src[le] != '\n') le++;
int flen, info, indent = 0; char marker;
while (src[ls + indent] == ' ') indent++;
if (md_fence_at(src, ls, &flen, &info, &marker) && md_info_is_ludic(src, info)){
/* copy the opening fence line verbatim */
fsb_add(&o, src + ls, le - ls + (src[le] ? 1 : 0));
i = src[le] ? le + 1 : le;
/* gather the body up to the closing fence */
int bs = i;
int be = bs;
for (;;){
if (!src[be]) break;
int ps = be, pe = ps; while (src[pe] && src[pe] != '\n') pe++;
int clen, cinfo; char cmark;
if (md_fence_at(src, ps, &clen, &cinfo, &cmark) && cmark == marker && clen >= flen){
int only = 1;
for (int k = cinfo; k < pe; k++) if (src[k] != ' ' && src[k] != '\r'){ only = 0; break; }
if (only) break;
}
be = src[pe] ? pe + 1 : pe;
}
/* de-indent the body, format it, re-indent it */
FSB body = {0};
for (int p = bs; p < be; ){
int q = p; while (src[q] && src[q] != '\n') q++;
int skip = 0; while (skip < indent && p + skip < q && src[p + skip] == ' ') skip++;
fsb_add(&body, src + p + skip, q - (p + skip));
fsb_putc(&body, '\n');
p = src[q] ? q + 1 : q;
}
char* f = ludic_format(body.b ? body.b : "", indent_width);
for (char* p = f; *p; ){
char* q = strchr(p, '\n'); if (!q) q = p + strlen(p);
if (q > p) fsb_indent(&o, indent);
fsb_add(&o, p, q - p);
fsb_putc(&o, '\n');
p = *q ? q + 1 : q;
}
free(f); free(body.b);
i = be;
continue;
}
fsb_add(&o, src + ls, le - ls + (src[le] ? 1 : 0));
i = src[le] ? le + 1 : le;
}
if (!o.b){ o.b = malloc(1); o.b[0] = 0; }
return o.b;
}
#endif /* LUDIC_FMT_H */

View file

@ -1,118 +0,0 @@
/* ============================================================================
* ludic-fmt — the Ludic formatter, as a plain CLI.
*
* Editors call this through the language server, but CI and pre-commit hooks
* want a binary they can run:
*
* ludic-fmt a.ludic b.ludic print the formatted text
* ludic-fmt -w examples rewrite every .ludic under examples/
* ludic-fmt --check . exit 1 if anything is unformatted
* ludic-fmt LANGUAGE.md -w reformat the ```ludic fences in a document
* cat x.ludic | ludic-fmt - filter mode, for editors without LSP
* ==========================================================================*/
#include "ludic_fmt.h"
#include <dirent.h>
#include <sys/stat.h>
#include <limits.h>
static int g_write = 0, g_check = 0, g_indent = 2, g_quiet = 0;
static int g_changed = 0, g_failed = 0;
static char* slurp(FILE* f){
FSB s = {0};
char buf[65536]; size_t n;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) fsb_add(&s, buf, n);
if (!s.b){ s.b = malloc(1); s.b[0] = 0; }
return s.b;
}
static int is_md(const char* p){
size_t n = strlen(p);
return (n > 3 && !strcmp(p + n - 3, ".md")) || (n > 9 && !strcmp(p + n - 9, ".markdown"));
}
static int is_ludic(const char* p){
size_t n = strlen(p);
return n > 6 && !strcmp(p + n - 6, ".ludic");
}
static void do_file(const char* path){
FILE* f = fopen(path, "rb");
if (!f){ fprintf(stderr, "ludic-fmt: cannot open %s\n", path); g_failed = 1; return; }
char* src = slurp(f); fclose(f);
char* out = is_md(path) ? ludic_format_markdown(src, g_indent) : ludic_format(src, g_indent);
int same = !strcmp(src, out);
if (g_check){
if (!same){ g_changed = 1; if (!g_quiet) printf("%s\n", path); }
} else if (g_write){
if (!same){
FILE* w = fopen(path, "wb");
if (!w){ fprintf(stderr, "ludic-fmt: cannot write %s\n", path); g_failed = 1; }
else { fwrite(out, 1, strlen(out), w); fclose(w); if (!g_quiet) fprintf(stderr, "formatted %s\n", path); }
g_changed = 1;
}
} else {
fwrite(out, 1, strlen(out), stdout);
}
free(src); free(out);
}
static void do_dir(const char* dir, int depth){
if (depth > 16) return;
DIR* d = opendir(dir);
if (!d){ fprintf(stderr, "ludic-fmt: cannot open %s\n", dir); g_failed = 1; return; }
struct dirent* e;
while ((e = readdir(d))){
if (e->d_name[0] == '.') continue;
if (!strcmp(e->d_name, "build") || !strcmp(e->d_name, "node_modules")) continue;
char p[PATH_MAX]; snprintf(p, sizeof(p), "%s/%s", dir, e->d_name);
struct stat st; if (stat(p, &st)) continue;
if (S_ISDIR(st.st_mode)) do_dir(p, depth + 1);
else if (is_ludic(p)) do_file(p);
}
closedir(d);
}
static const char* USAGE =
"ludic-fmt — format Ludic source\n"
"\n"
"usage: ludic-fmt [options] [file|dir ...]\n"
" ludic-fmt - read stdin, write stdout\n"
"\n"
"options:\n"
" -w, --write rewrite files in place\n"
" --check list unformatted files; exit 1 if any (implies no output)\n"
" --indent N spaces per level (default 2)\n"
" -q, --quiet no per-file chatter\n"
" -h, --help this text\n"
"\n"
"Directories are walked for *.ludic. A .md/.markdown file has the body of every\n"
"```ludic fence formatted and the prose left alone.\n";
int main(int argc, char** argv){
const char* files[4096]; int nf = 0;
for (int i = 1; i < argc; i++){
const char* a = argv[i];
if (!strcmp(a, "-w") || !strcmp(a, "--write")) g_write = 1;
else if (!strcmp(a, "--check") || !strcmp(a, "-l")) g_check = 1;
else if (!strcmp(a, "-q") || !strcmp(a, "--quiet")) g_quiet = 1;
else if (!strcmp(a, "--indent") && i + 1 < argc) g_indent = atoi(argv[++i]);
else if (!strcmp(a, "-h") || !strcmp(a, "--help")){ fputs(USAGE, stdout); return 0; }
else if (a[0] == '-' && a[1] && strcmp(a, "-")){ fprintf(stderr, "ludic-fmt: unknown option %s\n", a); return 2; }
else if (nf < 4096) files[nf++] = a;
}
if (g_indent < 1 || g_indent > 8) g_indent = 2;
if (nf == 0 || (nf == 1 && !strcmp(files[0], "-"))){
char* src = slurp(stdin);
char* out = ludic_format(src, g_indent);
fwrite(out, 1, strlen(out), stdout);
free(src); free(out);
return 0;
}
for (int i = 0; i < nf; i++){
struct stat st;
if (stat(files[i], &st)){ fprintf(stderr, "ludic-fmt: no such file %s\n", files[i]); g_failed = 1; continue; }
if (S_ISDIR(st.st_mode)) do_dir(files[i], 0);
else do_file(files[i]);
}
if (g_failed) return 2;
return (g_check && g_changed) ? 1 : 0;
}

View file

@ -1,672 +0,0 @@
/* ============================================================================
* ludic_index.h — an error-tolerant model of a Ludic workspace.
*
* The compiler's parser is the wrong tool for an editor: it stops at the first
* error and it splices imports into one flat program. A language server needs
* the opposite — keep going after a syntax error (a file being typed into is
* broken most of the time), keep every file separate, and remember where each
* name came from so it can be jumped to, renamed and completed.
*
* So this is a second, deliberately shallow reader of the same grammar. It
* recognises declarations and bindings and records their spans; it does not
* type-check. Ground-truth errors still come from `ludicc` itself (see
* ludic_lsp.c) — this layer supplies structure, not judgement.
* ==========================================================================*/
#ifndef LUDIC_INDEX_H
#define LUDIC_INDEX_H
#include "ludic_syntax.h"
#include <stdarg.h>
/* ---------- symbol kinds --------------------------------------------------*/
enum {
LS_UNIT, LS_COMPONENT, LS_FIELD, LS_ARCHETYPE, LS_CONST, LS_VAR,
LS_FN, LS_PARAM, LS_EXTERN, LS_SYSTEM, LS_UI, LS_WIDGET, LS_SCENE,
LS_LAYER, LS_LOCAL, LS_QUERYVAR, LS_STATE, LS_IMPORT
};
/* semantic classes, one per token — the source for semantic highlighting */
enum {
SC_NONE, SC_KEYWORD, SC_TYPE, SC_COMPONENT, SC_ARCHETYPE, SC_SCENE,
SC_LAYER, SC_UI, SC_WIDGET, SC_PROP, SC_FIELD, SC_SYSTEM, SC_FUNCTION,
SC_BUILTIN, SC_PARAM, SC_VARIABLE, SC_CONST, SC_MODVAR, SC_PHASE,
SC_NUMBER, SC_STRING, SC_COMMENT, SC_OPERATOR, SC_ANNOTATION, SC_UNKNOWN
};
typedef struct {
int kind;
char name[96];
char type[96]; /* declared type; for a query var, its component */
char detail[224]; /* one-line signature, shown in hover and outlines */
char doc[640]; /* the comment block sitting directly above */
int tok; /* token index of the NAME */
int start, end; /* byte span of the name */
int body_start, body_end; /* span the symbol governs (a block, or the
* whole file for a top-level declaration) */
int parent; /* enclosing symbol index, -1 at the top level */
int exported;
} LSym;
typedef struct {
int line, col, endline, endcol, severity; /* 1 = error, 2 = warning */
char msg[512];
char path[1024]; /* empty = this document */
} LDiag;
typedef struct LDoc {
char* path; /* filesystem path */
char* uri; /* file:// URI */
char* raw; /* the buffer exactly as the editor has it */
char* text; /* what the lexer sees: `raw`, or — for Markdown — a
* copy with every byte outside a ```ludic fence blanked
* out, so offsets still line up with the real file */
int version;
int open; /* the editor holds it (so `text` beats the disk) */
int is_markdown; /* a .md file: only its ```ludic fences are Ludic */
LLex lex;
int* match; /* per token: matching brace/bracket token, else -1 */
unsigned char* cls; /* per token: semantic class */
LSym* sym; int nsym, symcap;
char** imports; int nimport;
int had_diags; /* we published a non-empty list for it last time */
int is_unit; /* declares `game` or `module` */
int is_module;
char unit[96];
} LDoc;
typedef struct {
LDoc** d; int n, cap;
char* root; /* workspace root directory */
} LIndex;
/* ---------- small helpers -------------------------------------------------*/
static void lsym_reserve(LDoc* D){
if (D->nsym >= D->symcap){ D->symcap = D->symcap ? D->symcap * 2 : 128; D->sym = realloc(D->sym, D->symcap * sizeof(LSym)); }
}
static void lcpy(char* dst, int cap, const char* s, int n){
if (n >= cap) n = cap - 1; if (n < 0) n = 0;
memcpy(dst, s, n); dst[n] = 0;
}
static void lcatf(char* dst, int cap, const char* fmt, ...){
int used = (int)strlen(dst); if (used >= cap - 1) return;
va_list ap; va_start(ap, fmt); vsnprintf(dst + used, cap - used, fmt, ap); va_end(ap);
}
/* ---------- positions -----------------------------------------------------
* LSP counts characters in UTF-16 code units by default. Ludic source is UTF-8
* and string literals really do carry non-ASCII (the language ships a Unicode
* TrueType path), so the conversion is not optional. */
static int lutf16_len(const char* s, int nbytes){
int u = 0;
for (int i = 0; i < nbytes; ){
unsigned char c = (unsigned char)s[i];
if (c < 0x80){ i += 1; u += 1; }
else if (c < 0xE0){ i += 2; u += 1; }
else if (c < 0xF0){ i += 3; u += 1; }
else { i += 4; u += 2; } /* astral planes are a surrogate pair */
}
return u;
}
static int ldoc_line_of(const LDoc* D, int off){
int lo = 0, hi = D->lex.nline - 1;
while (lo < hi){ int mid = (lo + hi + 1) / 2; if (D->lex.linestart[mid] <= off) lo = mid; else hi = mid - 1; }
return lo;
}
static int ldoc_col_of(const LDoc* D, int off){
int line = ldoc_line_of(D, off);
return lutf16_len(D->text + D->lex.linestart[line], off - D->lex.linestart[line]);
}
static int ldoc_offset_of(const LDoc* D, int line, int character){
if (line < 0) return 0;
if (line >= D->lex.nline) return (int)strlen(D->text);
int off = D->lex.linestart[line];
int end = (line + 1 < D->lex.nline) ? D->lex.linestart[line + 1] : (int)strlen(D->text);
int u = 0;
while (off < end && u < character){
unsigned char c = (unsigned char)D->text[off];
if (c < 0x80){ off += 1; u += 1; }
else if (c < 0xE0){ off += 2; u += 1; }
else if (c < 0xF0){ off += 3; u += 1; }
else { off += 4; u += 2; }
}
return off;
}
/* The token the cursor is on. A caret sitting exactly between two tokens
* belongs to the one it is *inside*; only when it is inside none of them does
* the token ending there win — otherwise `st.|guard` resolves to the dot. */
static int ldoc_tok_at(const LDoc* D, int off){
int touching = -1;
for (int i = 0; i < D->lex.n; i++){
const LTok* t = &D->lex.v[i];
if (t->kind == LT_NL || t->kind == LT_EOF) continue;
if (off >= t->start && off < t->end) return i;
if (off == t->end && touching < 0) touching = i;
if (t->start > off) break;
}
return touching;
}
/* ---------- brace matching ------------------------------------------------*/
static void ldoc_match_braces(LDoc* D){
free(D->match);
D->match = malloc(sizeof(int) * (D->lex.n + 1));
for (int i = 0; i < D->lex.n; i++) D->match[i] = -1;
int stack[512], top = 0;
for (int i = 0; i < D->lex.n; i++){
const LTok* t = &D->lex.v[i];
if (t->kind != LT_OP || ltok_len(t) != 1) continue;
char c = D->text[t->start];
if (c == '{' || c == '(' || c == '['){ if (top < 512) stack[top++] = i; }
else if (c == '}' || c == ')' || c == ']'){
if (top > 0){ int o = stack[--top]; D->match[o] = i; D->match[i] = o; }
}
}
}
/* ---------- doc comments --------------------------------------------------
* The comment block immediately above a declaration is its documentation —
* the convention the runtime and examples already follow. */
static void ldoc_collect_doc(LDoc* D, int decl_tok, char* out, int cap){
out[0] = 0;
int line = D->lex.v[decl_tok].line;
/* walk backwards over comment-only lines */
int first = -1;
for (int i = decl_tok - 1; i >= 0; i--){
const LTok* t = &D->lex.v[i];
if (t->kind == LT_NL) continue;
if (t->kind != LT_COMMENT) break;
if (t->line >= line) break; /* trailing, not leading */
/* the comment must own its line */
int p = ltok_prev_sig(&D->lex, i);
if (p >= 0 && D->lex.v[p].line == t->line) break;
if (t->line < line - 1 && first >= 0) break;/* a blank line ends it */
if (first >= 0 && D->lex.v[first].line != t->line + 1) break;
first = i; line = t->line;
}
if (first < 0) return;
for (int i = first; i < decl_tok; i++){
const LTok* t = &D->lex.v[i];
if (t->kind != LT_COMMENT) continue;
int s = t->start + 1; /* skip '#' */
while (s < t->end && (D->text[s] == ' ' || D->text[s] == '\t' || D->text[s] == '#' || D->text[s] == '*')) s++;
int used = (int)strlen(out);
int n = t->end - s;
if (used + n + 2 >= cap) break;
memcpy(out + used, D->text + s, n); out[used + n] = '\n'; out[used + n + 1] = 0;
}
}
/* ---------- the shallow parser -------------------------------------------*/
typedef struct { LDoc* D; int i; } LP;
static int lp_kind(LP* p){ return p->i < p->D->lex.n ? p->D->lex.v[p->i].kind : LT_EOF; }
static int lp_is(LP* p, const char* s){ return ltok_is(&p->D->lex, p->i, s); }
static void lp_skipnl(LP* p){ while (p->i < p->D->lex.n && (lp_kind(p) == LT_NL || lp_kind(p) == LT_COMMENT)) p->i++; }
static void lp_adv(LP* p){ if (p->i < p->D->lex.n) p->i++; lp_skipnl(p); }
static char* lp_word(LP* p, char* buf, int cap){
buf[0] = 0;
if (p->i < p->D->lex.n) lcpy(buf, cap, p->D->text + p->D->lex.v[p->i].start, ltok_len(&p->D->lex.v[p->i]));
return buf;
}
/* Consume an identifier-ish token, returning its index (or -1). */
static int lp_name(LP* p){
int k = lp_kind(p);
if (k == LT_ID || k == LT_TYPE || k == LT_PHASE || k == LT_KW || k == LT_BOOL){ int t = p->i; lp_adv(p); return t; }
return -1;
}
static int lsym_add(LDoc* D, int kind, int nametok, int parent){
lsym_reserve(D);
LSym* s = &D->sym[D->nsym];
memset(s, 0, sizeof(*s));
s->kind = kind; s->tok = nametok; s->parent = parent;
if (nametok >= 0){
const LTok* t = &D->lex.v[nametok];
s->start = t->start; s->end = t->end;
lcpy(s->name, sizeof(s->name), D->text + t->start, ltok_len(t));
}
s->body_start = 0; s->body_end = (int)strlen(D->text);
return D->nsym++;
}
/* Skip a balanced group starting at the current opener; leaves p after it. */
static void lp_skip_group(LP* p){
int m = (p->i < p->D->lex.n) ? p->D->match[p->i] : -1;
if (m < 0){ lp_adv(p); return; }
p->i = m + 1; lp_skipnl(p);
}
/* Read `name: Type` pairs inside the parentheses at p->i, recording params. */
static void lp_params(LP* p, int owner, int kind, char* sig, int sigcap){
if (!lp_is(p, "(")) return;
int close = p->D->match[p->i];
lcatf(sig, sigcap, "(");
lp_adv(p);
int first = 1;
while (p->i < p->D->lex.n && (close < 0 || p->i < close)){
if (lp_is(p, ")")) break;
if (lp_is(p, ",")){ lp_adv(p); continue; }
int nt = lp_name(p); if (nt < 0){ lp_adv(p); continue; }
char ty[96]; ty[0] = 0;
if (lp_is(p, ":")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0) lcpy(ty, sizeof(ty), p->D->text + p->D->lex.v[tt].start, ltok_len(&p->D->lex.v[tt])); }
int s = lsym_add(p->D, kind, nt, owner);
lcpy(p->D->sym[s].type, sizeof(p->D->sym[s].type), ty, (int)strlen(ty));
lcatf(sig, sigcap, "%s%s: %s", first ? "" : ", ", p->D->sym[s].name, ty[0] ? ty : "?");
first = 0;
}
if (close >= 0) p->i = close + 1;
lp_skipnl(p);
lcatf(sig, sigcap, ")");
}
/* `[Pos, Vel, {Enemy}]` — bind the listed components to the named variables in
* order, skipping {Tag} terms, which filter without binding. */
static void lp_query_terms(LP* p, int owner, int* vars, int nvars, int scope_start, int scope_end){
if (!lp_is(p, "[")) return;
int close = p->D->match[p->i];
lp_adv(p);
int bind = 0;
while (p->i < p->D->lex.n && (close < 0 || p->i < close)){
if (lp_is(p, "]")) break;
if (lp_is(p, ",")){ lp_adv(p); continue; }
if (lp_is(p, "{")){ /* a filter term */
int cb = p->D->match[p->i]; lp_adv(p);
int nt = lp_name(p); (void)nt;
if (cb >= 0) p->i = cb + 1; else lp_adv(p);
lp_skipnl(p); continue;
}
int nt = lp_name(p); if (nt < 0){ lp_adv(p); continue; }
if (bind < nvars && vars[bind] >= 0){
int s = lsym_add(p->D, LS_QUERYVAR, vars[bind], owner);
lcpy(p->D->sym[s].type, sizeof(p->D->sym[s].type), p->D->text + p->D->lex.v[nt].start, ltok_len(&p->D->lex.v[nt]));
p->D->sym[s].body_start = scope_start; p->D->sym[s].body_end = scope_end;
lcatf(p->D->sym[s].detail, sizeof(p->D->sym[s].detail), "%s: %s (query binding)", p->D->sym[s].name, p->D->sym[s].type);
}
bind++;
}
if (close >= 0) p->i = close + 1;
lp_skipnl(p);
if (lp_is(p, "where")) lp_adv(p); /* the condition is ordinary expression */
}
/* `(a, b)` variable list before `in query` / after the `query` clause. */
static int lp_varlist(LP* p, int* out, int max){
int n = 0;
if (!lp_is(p, "(")) return 0;
int close = p->D->match[p->i];
lp_adv(p);
while (p->i < p->D->lex.n && (close < 0 || p->i < close)){
if (lp_is(p, ")")) break;
if (lp_is(p, ",")){ lp_adv(p); continue; }
int nt = lp_name(p); if (nt < 0){ lp_adv(p); continue; }
if (n < max) out[n++] = nt;
}
if (close >= 0) p->i = close + 1;
lp_skipnl(p);
return n;
}
static void lp_block(LP* p, int owner, int scope_end);
/* statements — we only care about what BINDS a name or opens a scope */
static void lp_stmt(LP* p, int owner, int scope_end){
LDoc* D = p->D;
if (lp_is(p, "let")){
lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
char ty[96]; ty[0] = 0;
if (lp_is(p, ":")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0) lcpy(ty, sizeof(ty), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt])); }
int s = lsym_add(D, LS_LOCAL, nt, owner);
lcpy(D->sym[s].type, sizeof(D->sym[s].type), ty, (int)strlen(ty));
D->sym[s].body_start = D->lex.v[nt].start; D->sym[s].body_end = scope_end;
return;
}
if (lp_is(p, "for")){
lp_adv(p);
if (lp_is(p, "(")){ /* for (a, b) in query [...] */
int vars[16]; int nv = lp_varlist(p, vars, 16);
if (lp_is(p, "in")) lp_adv(p);
if (lp_is(p, "query")) lp_adv(p);
int body_end = scope_end;
/* the loop body is the next {...}; bindings live there */
lp_query_terms(p, owner, vars, nv, D->lex.v[p->i < D->lex.n ? p->i : D->lex.n - 1].start, body_end);
return;
}
int nt = lp_name(p); /* for i in a .. b */
if (nt >= 0){
int s = lsym_add(D, LS_LOCAL, nt, owner);
lcpy(D->sym[s].type, sizeof(D->sym[s].type), "int", 3);
D->sym[s].body_start = D->lex.v[nt].start; D->sym[s].body_end = scope_end;
}
return;
}
if (lp_is(p, "state")){ /* machine { state Idle = 0 {…} } */
lp_adv(p); int nt = lp_name(p);
if (nt >= 0) lsym_add(D, LS_STATE, nt, owner);
return;
}
lp_adv(p);
}
static void lp_block(LP* p, int owner, int scope_end){
if (!lp_is(p, "{")) return;
int close = p->D->match[p->i];
int end = close >= 0 ? p->D->lex.v[close].start : scope_end;
lp_adv(p);
while (p->i < p->D->lex.n && lp_kind(p) != LT_EOF){
if (close >= 0 && p->i >= close) break;
if (lp_is(p, "}")) break;
if (lp_is(p, "{")){ lp_block(p, owner, end); continue; }
int before = p->i;
lp_stmt(p, owner, end);
if (p->i == before) lp_adv(p);
}
if (close >= 0) p->i = close + 1; else lp_adv(p);
lp_skipnl(p);
}
/* `panel id=Root w=288 { … }` — each id= mints a UI_<Name> handle. */
static void lp_widget(LP* p, int owner, int uisym){
LDoc* D = p->D;
int type_tok = lp_name(p); if (type_tok < 0) return;
while (p->i < D->lex.n && lp_kind(p) == LT_ID && ltok_is(&D->lex, ltok_next_sig(&D->lex, p->i) < 0 ? p->i : ltok_next_sig(&D->lex, p->i), "=")){
int key = p->i; char kb[64]; lcpy(kb, sizeof(kb), D->text + D->lex.v[key].start, ltok_len(&D->lex.v[key]));
lp_adv(p); /* key */
if (lp_is(p, "=")) lp_adv(p); /* '=' */
if (!strcmp(kb, "id")){
int nt = lp_name(p);
if (nt >= 0){
int s = lsym_add(D, LS_WIDGET, nt, uisym);
snprintf(D->sym[s].name, sizeof(D->sym[s].name), "UI_%.*s",
ltok_len(&D->lex.v[nt]), D->text + D->lex.v[nt].start);
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s widget handle",
(const char*)(D->text + D->lex.v[type_tok].start));
lcpy(D->sym[s].type, sizeof(D->sym[s].type), "int", 3);
}
} else {
/* skip one value expression: stop at the next `key=` or at a brace */
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "{") && !lp_is(p, "}")){
if (lp_is(p, "(") || lp_is(p, "[")){ lp_skip_group(p); continue; }
int nx = ltok_next_sig(&D->lex, p->i);
if (lp_kind(p) == LT_ID && nx >= 0 && ltok_is(&D->lex, nx, "=")) break;
lp_adv(p);
}
}
}
if (lp_is(p, "{")){
int close = D->match[p->i];
lp_adv(p);
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "}")){
if (close >= 0 && p->i >= close) break;
int before = p->i;
lp_widget(p, owner, uisym);
if (p->i == before) lp_adv(p);
}
if (close >= 0) p->i = close + 1; else lp_adv(p);
lp_skipnl(p);
}
}
/* one top-level declaration */
static void lp_decl(LP* p, int parent){
LDoc* D = p->D;
char w[96]; lp_word(p, w, sizeof(w));
int decl_tok = p->i;
if (!strcmp(w, "import")){
lp_adv(p);
if (lp_kind(p) == LT_STR){
const LTok* t = &D->lex.v[p->i];
int n = ltok_len(t) - 2; if (n < 0) n = 0;
char* rel = malloc(n + 1); memcpy(rel, D->text + t->start + 1, n); rel[n] = 0;
D->imports = realloc(D->imports, (D->nimport + 1) * sizeof(char*));
D->imports[D->nimport++] = rel;
int s = lsym_add(D, LS_IMPORT, p->i, parent);
lcpy(D->sym[s].name, sizeof(D->sym[s].name), rel, (int)strlen(rel));
lp_adv(p);
}
return;
}
if (!strcmp(w, "property") || !strcmp(w, "model")){
int is_comp = !strcmp(w, "property");
lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, is_comp ? LS_COMPONENT : LS_ARCHETYPE, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
while (lp_kind(p) == LT_ANNO) lp_adv(p);
snprintf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %s {",
is_comp ? "property" : "model", D->sym[s].name);
if (!lp_is(p, "{")) return;
int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start;
D->sym[s].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
lp_adv(p);
int first = 1;
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "}")){
if (close >= 0 && p->i >= close) break;
if (lp_is(p, ",")){ lp_adv(p); continue; }
int ft = lp_name(p); if (ft < 0){ lp_adv(p); continue; }
if (is_comp){
int f = lsym_add(D, LS_FIELD, ft, s);
if (lp_is(p, ":")){
lp_adv(p); int tt = lp_name(p);
if (tt >= 0) lcpy(D->sym[f].type, sizeof(D->sym[f].type), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt]));
}
lcatf(D->sym[f].detail, sizeof(D->sym[f].detail), "%s.%s: %s", D->sym[s].name, D->sym[f].name, D->sym[f].type);
if (lp_is(p, "=")){ lp_adv(p); while (p->i < D->lex.n && !lp_is(p, ",") && !lp_is(p, "}") && lp_kind(p) != LT_EOF){ if (lp_is(p, "(") || lp_is(p, "{") || lp_is(p, "[")) lp_skip_group(p); else lp_adv(p); } }
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %s: %s", first ? "" : ",", D->sym[f].name, D->sym[f].type);
} else {
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %.*s", first ? "" : ",",
ltok_len(&D->lex.v[ft]), D->text + D->lex.v[ft].start);
}
first = 0;
}
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), " }");
if (close >= 0) p->i = close + 1; else lp_adv(p);
lp_skipnl(p);
return;
}
if (!strcmp(w, "const") || !strcmp(w, "var")){
int kind = !strcmp(w, "const") ? LS_CONST : LS_VAR;
lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, kind, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
if (lp_is(p, ":")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0) lcpy(D->sym[s].type, sizeof(D->sym[s].type), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt])); }
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %s: %s", w, D->sym[s].name, D->sym[s].type);
while (p->i < D->lex.n && lp_kind(p) != LT_NL && lp_kind(p) != LT_EOF){
if (lp_is(p, "(") || lp_is(p, "{") || lp_is(p, "[")) lp_skip_group(p); else p->i++;
}
lp_skipnl(p);
return;
}
if (!strcmp(w, "extern")){
lp_adv(p); if (lp_is(p, "fn")) lp_adv(p);
int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_EXTERN, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
char sig[224]; snprintf(sig, sizeof(sig), "extern fn %s", D->sym[s].name);
lp_params(p, s, LS_PARAM, sig, sizeof(sig));
if (lp_is(p, "->")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0){ lcpy(D->sym[s].type, sizeof(D->sym[s].type), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt])); lcatf(sig, sizeof(sig), " -> %s", D->sym[s].type); } }
lcpy(D->sym[s].detail, sizeof(D->sym[s].detail), sig, (int)strlen(sig));
while (p->i < D->lex.n && lp_kind(p) != LT_NL && lp_kind(p) != LT_EOF) p->i++;
lp_skipnl(p);
return;
}
if (!strcmp(w, "fn") || !strcmp(w, "pure") || !strcmp(w, "export")){
int exported = !strcmp(w, "export");
if (!strcmp(w, "pure") || exported){ lp_adv(p); if (lp_is(p, "pure")) lp_adv(p); }
if (lp_is(p, "fn")) lp_adv(p);
int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_FN, nt, parent);
D->sym[s].exported = exported;
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
char sig[224]; snprintf(sig, sizeof(sig), "%sfn %s", exported ? "export " : "", D->sym[s].name);
lp_params(p, s, LS_PARAM, sig, sizeof(sig));
if (lp_is(p, "->")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0) lcpy(D->sym[s].type, sizeof(D->sym[s].type), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt])); }
lcatf(sig, sizeof(sig), " -> %s", D->sym[s].type[0] ? D->sym[s].type : "void");
lcpy(D->sym[s].detail, sizeof(D->sym[s].detail), sig, (int)strlen(sig));
/* contracts sit between the signature and the body */
while (lp_is(p, "requires") || lp_is(p, "ensures") || lp_is(p, "invariant") || lp_is(p, "effects")){
lp_adv(p);
while (p->i < D->lex.n && lp_kind(p) != LT_NL && lp_kind(p) != LT_EOF && !lp_is(p, "{")){
if (lp_is(p, "(") || lp_is(p, "[")) lp_skip_group(p); else p->i++;
}
lp_skipnl(p);
}
if (lp_is(p, "{")){
int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start;
D->sym[s].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
/* params are visible for the whole body */
for (int q = 0; q < D->nsym; q++) if (D->sym[q].parent == s && D->sym[q].kind == LS_PARAM){
D->sym[q].body_start = D->sym[s].body_start; D->sym[q].body_end = D->sym[s].body_end;
}
lp_block(p, s, D->sym[s].body_end);
}
return;
}
if (!strcmp(w, "handler") || !strcmp(w, "edge")){
if (!strcmp(w, "edge")){ lp_adv(p); if (!lp_is(p, "handler")) return; }
lp_adv(p);
int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_SYSTEM, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
lcpy(D->sym[s].type, sizeof(D->sym[s].type), "Update", 6);
while (lp_kind(p) == LT_ANNO) lp_adv(p);
int vars[16]; int nv = 0; int have_query = 0;
for (;;){
if (lp_is(p, "phase")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0) lcpy(D->sym[s].type, sizeof(D->sym[s].type), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt])); }
else if (lp_is(p, "query")){ lp_adv(p); have_query = 1; nv = lp_varlist(p, vars, 16); break; }
else if (lp_is(p, "reads") || lp_is(p, "writes") || lp_is(p, "uses") || lp_is(p, "effects")){ lp_adv(p); if (lp_is(p, "[")) lp_skip_group(p); }
else if (lp_is(p, "needs")){ lp_adv(p); lp_name(p); if (lp_is(p, "[")) lp_skip_group(p); }
else break;
lp_skipnl(p);
}
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "system %s phase %s", D->sym[s].name, D->sym[s].type);
/* find the body first, so query bindings can be scoped to it */
int body_open = p->i;
if (have_query){
int save = p->i;
/* the query terms come before the body */
int scan = p->i;
while (scan < D->lex.n && !ltok_is(&D->lex, scan, "{") && D->lex.v[scan].kind != LT_EOF) scan++;
int close = (scan < D->lex.n) ? D->match[scan] : -1;
int bs = (scan < D->lex.n) ? D->lex.v[scan].start : 0;
int be = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
p->i = save;
lp_query_terms(p, s, vars, nv, bs, be);
body_open = p->i;
}
(void)body_open;
if (lp_is(p, "{")){
int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start;
D->sym[s].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
lp_block(p, s, D->sym[s].body_end);
}
return;
}
if (!strcmp(w, "ui")){
lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_UI, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "ui %s", D->sym[s].name);
/* the block name is a handle too */
{ int h = lsym_add(D, LS_WIDGET, nt, s);
snprintf(D->sym[h].name, sizeof(D->sym[h].name), "UI_%s", D->sym[s].name);
lcpy(D->sym[h].type, sizeof(D->sym[h].type), "int", 3);
lcatf(D->sym[h].detail, sizeof(D->sym[h].detail), "root handle of ui %s", D->sym[s].name); }
if (lp_is(p, "{")){
int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start;
D->sym[s].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
lp_adv(p);
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "}")){
if (close >= 0 && p->i >= close) break;
int before = p->i;
lp_widget(p, s, s);
if (p->i == before) lp_adv(p);
}
if (close >= 0) p->i = close + 1; else lp_adv(p);
lp_skipnl(p);
}
return;
}
if (!strcmp(w, "scene")){
lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_SCENE, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
int start = 0;
if (lp_is(p, "start")){ start = 1; lp_adv(p); }
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "scene %s%s", D->sym[s].name, start ? " start" : "");
if (lp_is(p, "{")){
int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start;
D->sym[s].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
lp_adv(p);
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "}")){
if (close >= 0 && p->i >= close) break;
int before = p->i;
if (lp_is(p, "on")){ lp_adv(p); lp_name(p); lp_block(p, s, D->sym[s].body_end); }
else if (lp_is(p, "layer")){
lp_adv(p); int lt = lp_name(p);
int ls = lt >= 0 ? lsym_add(D, LS_LAYER, lt, s) : -1;
if (ls >= 0) lcatf(D->sym[ls].detail, sizeof(D->sym[ls].detail), "layer %s of scene %s", D->sym[ls].name, D->sym[s].name);
if (lp_is(p, "{")){
int lc = D->match[p->i];
if (ls >= 0){ D->sym[ls].body_start = D->lex.v[p->i].start; D->sym[ls].body_end = lc >= 0 ? D->lex.v[lc].end : D->sym[s].body_end; }
lp_adv(p);
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "}")){
if (lc >= 0 && p->i >= lc) break;
int b2 = p->i; lp_decl(p, ls >= 0 ? ls : s);
if (p->i == b2) lp_adv(p);
}
if (lc >= 0) p->i = lc + 1; else lp_adv(p);
lp_skipnl(p);
}
} else lp_adv(p);
if (p->i == before) lp_adv(p);
}
if (close >= 0) p->i = close + 1; else lp_adv(p);
lp_skipnl(p);
}
return;
}
lp_adv(p);
}
static void ldoc_parse(LDoc* D){
D->nsym = 0;
for (int i = 0; i < D->nimport; i++) free(D->imports[i]);
free(D->imports); D->imports = 0; D->nimport = 0;
D->is_unit = 0; D->is_module = 0; D->unit[0] = 0;
LP p = { D, 0 };
lp_skipnl(&p);
while (lp_is(&p, "import")) { lp_decl(&p, -1); lp_skipnl(&p); }
int unit_parent = -1;
if (lp_is(&p, "program") || lp_is(&p, "program")){
D->is_module = lp_is(&p, "program");
D->is_unit = 1;
lp_adv(&p);
int nt = lp_name(&p);
if (nt >= 0){
unit_parent = lsym_add(D, LS_UNIT, nt, -1);
lcpy(D->unit, sizeof(D->unit), D->sym[unit_parent].name, (int)strlen(D->sym[unit_parent].name));
lcatf(D->sym[unit_parent].detail, sizeof(D->sym[unit_parent].detail), "%s %s", D->is_module ? "program" : "program", D->unit);
}
if (lp_is(&p, "{")){
int close = D->match[p.i];
if (unit_parent >= 0){
D->sym[unit_parent].body_start = D->lex.v[p.i].start;
D->sym[unit_parent].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
}
lp_adv(&p);
while (p.i < D->lex.n && lp_kind(&p) != LT_EOF && !lp_is(&p, "}")){
if (close >= 0 && p.i >= close) break;
int before = p.i; lp_decl(&p, unit_parent);
if (p.i == before) lp_adv(&p);
}
}
return;
}
/* A fragment: an imported file is a bare list of declarations. */
while (p.i < D->lex.n && lp_kind(&p) != LT_EOF){
int before = p.i; lp_decl(&p, -1);
if (p.i == before) lp_adv(&p);
}
}
#endif /* LUDIC_INDEX_H */

View file

@ -1,191 +0,0 @@
/* ============================================================================
* ludic_json.h — just enough JSON for LSP, and no dependencies.
*
* The rest of this toolchain is C with no third-party libraries, and the
* language server should not be the thing that drags a package manager into a
* project whose whole premise is a self-contained native pipeline. This is a
* recursive-descent reader and an escaping writer; nothing more.
* ==========================================================================*/
#ifndef LUDIC_JSON_H
#define LUDIC_JSON_H
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <math.h>
enum { JNULL, JBOOL, JNUM, JSTR, JARR, JOBJ };
typedef struct JVal JVal;
struct JVal {
int t;
double num;
int b;
char* s; /* JSTR: decoded UTF-8 */
JVal** kids; char** keys; int n, cap;
};
static JVal* jnew(int t){ JVal* v = calloc(1, sizeof(JVal)); v->t = t; return v; }
static void jfree(JVal* v){
if (!v) return;
for (int i = 0; i < v->n; i++){ jfree(v->kids[i]); free(v->keys ? v->keys[i] : 0); }
free(v->kids); free(v->keys); free(v->s); free(v);
}
static void jpush(JVal* v, const char* key, JVal* kid){
if (v->n >= v->cap){
v->cap = v->cap ? v->cap * 2 : 8;
v->kids = realloc(v->kids, v->cap * sizeof(JVal*));
v->keys = realloc(v->keys, v->cap * sizeof(char*));
}
v->keys[v->n] = key ? strdup(key) : 0;
v->kids[v->n++] = kid;
}
static void jskip(const char** p){ while (**p == ' ' || **p == '\t' || **p == '\n' || **p == '\r') (*p)++; }
static JVal* jparse_at(const char** p);
static void jutf8(char** o, unsigned cp){
if (cp < 0x80) *(*o)++ = (char)cp;
else if (cp < 0x800){ *(*o)++ = (char)(0xC0 | (cp >> 6)); *(*o)++ = (char)(0x80 | (cp & 63)); }
else if (cp < 0x10000){ *(*o)++ = (char)(0xE0 | (cp >> 12)); *(*o)++ = (char)(0x80 | ((cp >> 6) & 63)); *(*o)++ = (char)(0x80 | (cp & 63)); }
else { *(*o)++ = (char)(0xF0 | (cp >> 18)); *(*o)++ = (char)(0x80 | ((cp >> 12) & 63)); *(*o)++ = (char)(0x80 | ((cp >> 6) & 63)); *(*o)++ = (char)(0x80 | (cp & 63)); }
}
static char* jparse_string(const char** p){
if (**p != '"') return 0;
(*p)++;
const char* s = *p;
size_t cap = strlen(s) + 1;
char* out = malloc(cap); char* o = out;
while (**p && **p != '"'){
if (**p == '\\'){
(*p)++;
char c = **p; (*p)++;
switch (c){
case 'n': *o++ = '\n'; break;
case 't': *o++ = '\t'; break;
case 'r': *o++ = '\r'; break;
case 'b': *o++ = '\b'; break;
case 'f': *o++ = '\f'; break;
case 'u': {
unsigned cp = (unsigned)strtoul((char[5]){ (*p)[0], (*p)[1], (*p)[2], (*p)[3], 0 }, 0, 16);
*p += 4;
if (cp >= 0xD800 && cp < 0xDC00 && (*p)[0] == '\\' && (*p)[1] == 'u'){
unsigned lo = (unsigned)strtoul((char[5]){ (*p)[2], (*p)[3], (*p)[4], (*p)[5], 0 }, 0, 16);
*p += 6;
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
}
jutf8(&o, cp);
break;
}
default: *o++ = c;
}
} else *o++ = *(*p)++;
}
if (**p == '"') (*p)++;
*o = 0;
return out;
}
static JVal* jparse_at(const char** p){
jskip(p);
char c = **p;
if (c == '{'){
(*p)++; JVal* v = jnew(JOBJ);
for (;;){
jskip(p);
if (**p == '}'){ (*p)++; break; }
char* k = jparse_string(p);
jskip(p); if (**p == ':') (*p)++;
JVal* kid = jparse_at(p);
jpush(v, k ? k : "", kid);
free(k);
jskip(p);
if (**p == ',') (*p)++;
else if (**p == '}'){ (*p)++; break; }
else if (!**p) break;
}
return v;
}
if (c == '['){
(*p)++; JVal* v = jnew(JARR);
for (;;){
jskip(p);
if (**p == ']'){ (*p)++; break; }
jpush(v, 0, jparse_at(p));
jskip(p);
if (**p == ',') (*p)++;
else if (**p == ']'){ (*p)++; break; }
else if (!**p) break;
}
return v;
}
if (c == '"'){ JVal* v = jnew(JSTR); v->s = jparse_string(p); return v; }
if (!strncmp(*p, "true", 4)){ *p += 4; JVal* v = jnew(JBOOL); v->b = 1; return v; }
if (!strncmp(*p, "false", 5)){ *p += 5; JVal* v = jnew(JBOOL); v->b = 0; return v; }
if (!strncmp(*p, "null", 4)){ *p += 4; return jnew(JNULL); }
{ char* end; double d = strtod(*p, &end); if (end != *p){ *p = end; JVal* v = jnew(JNUM); v->num = d; return v; } }
(*p)++; /* unparsable: step past it */
return jnew(JNULL);
}
static JVal* jparse(const char* text){ const char* p = text; return jparse_at(&p); }
static JVal* jget(JVal* v, const char* key){
if (!v || v->t != JOBJ) return 0;
for (int i = 0; i < v->n; i++) if (v->keys[i] && !strcmp(v->keys[i], key)) return v->kids[i];
return 0;
}
static JVal* jat(JVal* v, int i){ return (v && i >= 0 && i < v->n) ? v->kids[i] : 0; }
static const char* jstr(JVal* v, const char* def){ return (v && v->t == JSTR && v->s) ? v->s : def; }
static int jint(JVal* v, int def){ return (v && v->t == JNUM) ? (int)v->num : def; }
static int jbool(JVal* v, int def){ return v ? (v->t == JBOOL ? v->b : (v->t == JNUM ? v->num != 0 : def)) : def; }
/* dotted lookup: jpath(msg, "params.textDocument.uri") */
static JVal* jpath(JVal* v, const char* path){
char buf[256]; snprintf(buf, sizeof(buf), "%s", path);
char* save = 0;
for (char* tok = strtok_r(buf, ".", &save); tok; tok = strtok_r(0, ".", &save)){
v = jget(v, tok);
if (!v) return 0;
}
return v;
}
/* ---------- writing -------------------------------------------------------*/
typedef struct { char* b; size_t n, cap; } JSB;
static void jsb_ensure(JSB* s, size_t add){
if (s->n + add + 1 > s->cap){ s->cap = (s->n + add + 1) * 2; s->b = realloc(s->b, s->cap); }
}
static void jsb_add(JSB* s, const char* z, size_t l){ jsb_ensure(s, l); memcpy(s->b + s->n, z, l); s->n += l; s->b[s->n] = 0; }
static void jsb_puts(JSB* s, const char* z){ jsb_add(s, z, strlen(z)); }
static void jsb_putc(JSB* s, char c){ jsb_add(s, &c, 1); }
static void jsb_putf(JSB* s, const char* fmt, ...){
char tmp[1024];
va_list ap; va_start(ap, fmt);
int n = vsnprintf(tmp, sizeof(tmp), fmt, ap);
va_end(ap);
if (n < (int)sizeof(tmp)){ jsb_add(s, tmp, n); return; }
char* big = malloc(n + 1);
va_start(ap, fmt); vsnprintf(big, n + 1, fmt, ap); va_end(ap);
jsb_add(s, big, n); free(big);
}
/* Escape a UTF-8 string as a JSON string literal, quotes included. */
static void jsb_str(JSB* s, const char* z){
jsb_putc(s, '"');
if (!z) z = "";
for (const unsigned char* p = (const unsigned char*)z; *p; p++){
switch (*p){
case '"': jsb_puts(s, "\\\""); break;
case '\\': jsb_puts(s, "\\\\"); break;
case '\n': jsb_puts(s, "\\n"); break;
case '\r': jsb_puts(s, "\\r"); break;
case '\t': jsb_puts(s, "\\t"); break;
default:
if (*p < 0x20) jsb_putf(s, "\\u%04x", *p);
else jsb_putc(s, (char)*p);
}
}
jsb_putc(s, '"');
}
static void jsb_kv_str(JSB* s, const char* k, const char* v){ jsb_str(s, k); jsb_putc(s, ':'); jsb_str(s, v); }
static void jsb_kv_int(JSB* s, const char* k, long v){ jsb_str(s, k); jsb_putf(s, ":%ld", v); }
static void jsb_kv_bool(JSB* s, const char* k, int v){ jsb_str(s, k); jsb_puts(s, v ? ":true" : ":false"); }
#endif /* LUDIC_JSON_H */

File diff suppressed because it is too large Load diff

View file

@ -53,22 +53,22 @@ typedef struct {
* declaration keyword, anything stmt() dispatches on is a statement keyword. */
static const char* LUDIC_KW_DECL[] = {
"program","import","property","model","enum","ui",
"const","var","fn","extern","handler","entry", 0
"const","var","fn","extern","handler","entry","event","scene", 0
};
static const char* LUDIC_KW_CLAUSE[] = {
"phase","query","on", 0
"phase","query","on","cancellable","public","layer","start", 0
};
/* Documented design targets the self-hosted parser does not accept yet. Kept
* out of the highlighted vocabulary (they would read as working keywords) until
* they are implemented; check-vocabulary.py verifies the lists above are a
* subset of what selfhost/parse*.ludic actually dispatches on. */
static const char* LUDIC_KW_RESERVED[] = {
"scene","layer","start", 0
0
};
static const char* LUDIC_KW_STMT[] = {
"let","return","if","else","while","for","in","spawn","despawn",
"enable","disable","match","machine","state","become","where",
"and","or","not","break","continue","new", 0
"and","or","not","break","continue","new","emit","cancel", 0
};
static const char* LUDIC_TYPES[] = {
"int","fixed","bool","entity","str","ptr","byte","words","fixeds","ptrs","void", 0

View file

@ -1,327 +0,0 @@
/* ============================================================================
* ludic_syntax.h — the lexical layer shared by every Ludic editor tool.
*
* One lexer, one vocabulary. `ludic-fmt`, `ludic-lsp` and the generated
* TextMate grammar all read their keyword/builtin/type tables from here, so an
* addition to the language shows up in every editor at once instead of drifting
* across six hand-maintained copies.
*
* Unlike the compiler's lexer (compiler/ludicc.c) this one is written for
* editors: it keeps comments, keeps newlines, records byte spans for every
* token, and never exits on bad input — a stray character becomes an LT_ERR
* token and lexing continues, because a file being typed into is malformed most
* of the time.
* ==========================================================================*/
#ifndef LUDIC_SYNTAX_H
#define LUDIC_SYNTAX_H
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
/* ---------- token kinds ---------------------------------------------------*/
enum {
LT_EOF, LT_NL, LT_COMMENT,
LT_ID, /* a plain identifier */
LT_KW, /* a reserved word (see KEYWORDS) */
LT_TYPE, /* a built-in type name: int fixed bool entity str ptr void */
LT_PHASE, /* Start Input FixedUpdate Update LateUpdate Render */
LT_BOOL, /* true false */
LT_INT, LT_FLOAT, LT_STR, LT_CHAR,
LT_ANNO, /* @deterministic — the '@' and the name as one token */
LT_OP,
LT_ERR
};
typedef struct {
int kind;
int start, end; /* byte offsets into the source buffer */
int line; /* 0-based */
int bad; /* set on an unterminated string / stray character */
} LTok;
typedef struct {
LTok* v; int n, cap;
const char* src;
int* linestart; int nline, caplin;
} LLex;
/* ---------- the vocabulary ------------------------------------------------*/
/* Reserved words, grouped so editors can colour them differently. The groups
* mirror the compiler's parser: anything parse_decl() dispatches on is a
* declaration keyword, anything stmt() dispatches on is a statement keyword. */
static const char* LUDIC_KW_DECL[] = {
"game","module","import","component","struct","archetype","enum","ui",
"const","var","fn","extern","system","main", 0
};
static const char* LUDIC_KW_CLAUSE[] = {
"phase","query","reads","writes", 0
};
/* Documented design targets the self-hosted parser does not accept yet. Kept
* out of the highlighted vocabulary (they would read as working keywords) until
* they are implemented; check-vocabulary.py verifies the lists above are a
* subset of what selfhost/parse*.ludic actually dispatches on. */
static const char* LUDIC_KW_RESERVED[] = {
"scene","layer","on","start", 0
};
static const char* LUDIC_KW_STMT[] = {
"let","return","if","else","when","while","for","in","spawn","despawn",
"match","machine","state","become","enter","where","and","or","not",
"break","continue","new", 0
};
static const char* LUDIC_TYPES[] = {
"int","fixed","bool","entity","str","ptr","void", 0
};
static const char* LUDIC_PHASES[] = {
"Start","Input","FixedUpdate","Update","LateUpdate","Render", 0
};
static const char* LUDIC_WIDGETS[] = {
"panel","col","row","label","button","image","spacer", 0
};
static const char* LUDIC_WIDGET_PROPS[] = {
"id","text","skin","image","align","w","h","pad","gap","size","font",
"inset","grow","bg","fg","border","focus","x","y", 0
};
/* A builtin is a name the runtime provides (`clear(c)` -> rt_clear). An
* intrinsic is a name the compiler lowers directly to libc/OS. Editors treat
* both as "standard library", but the signatures differ, so they stay apart. */
typedef struct { const char* name; const char* sig; const char* doc; } LBuiltin;
static const LBuiltin LUDIC_BUILTINS[] = {
{"min","min(a: int, b: int) -> int","Smaller of two integers."},
{"max","max(a: int, b: int) -> int","Larger of two integers."},
{"abs","abs(a: int) -> int","Absolute value."},
{"clamp","clamp(v: int, lo: int, hi: int) -> int","Constrain v to [lo, hi]."},
{"seed","seed(i: int)","Seed the deterministic RNG."},
{"rng_range","rng_range(lo: int, hi: int) -> int","Deterministic integer in [lo, hi]."},
{"rng_chance","rng_chance(pct: int) -> bool","True pct% of the time, deterministically."},
{"fx","fx(i: int) -> fixed","Widen an int to Q16.16 fixed-point."},
{"flr","flr(f: fixed) -> int","Truncate a fixed-point value toward zero."},
{"map_size","map_size(w: int, h: int)","Set the tilemap dimensions."},
{"map_row","map_row(y: int, row: str)","Fill one tilemap row from a string."},
{"tile","tile(x: int, y: int) -> int","Tile code at a map cell."},
{"clear","clear(color: int)","Clear the framebuffer to a 0xRRGGBB colour."},
{"present","present()","Push the framebuffer to the window (or out.ppm when headless)."},
{"fill_rect","fill_rect(x: int, y: int, w: int, h: int, color: int)","Filled rectangle."},
{"frame_rect","frame_rect(x: int, y: int, w: int, h: int, color: int)","One-pixel rectangle outline."},
{"put_px","put_px(x: int, y: int, color: int)","Write a single pixel."},
{"text","text(x: int, y: int, s: str, color: int, scale: int)","Draw text with the built-in 5x7 bitmap font."},
{"text_int","text_int(x: int, y: int, n: int, color: int, scale: int)","Draw an integer with the 5x7 bitmap font."},
{"font_load","font_load(path: str) -> int","Load a TrueType .ttf/.ttc; returns a font id."},
{"text_ttf","text_ttf(font: int, x: int, y: int, utf8: str, color: int, px: int)","Draw UTF-8 text with a TrueType font."},
{"text_w","text_w(font: int, utf8: str, px: int) -> int","Advance width of the string, in pixels."},
{"text_h","text_h(font: int, px: int) -> int","Line height of the font, in pixels."},
{"image_load","image_load(path: str) -> int","Decode a PNG into an image id."},
{"draw_image","draw_image(id: int, x: int, y: int)","Blit an image at its natural size."},
{"draw_image_scaled","draw_image_scaled(id: int, x: int, y: int, w: int, h: int)","Blit an image stretched to w x h."},
{"draw_9slice","draw_9slice(id: int, x: int, y: int, w: int, h: int, inset: int)","Nine-slice an image across a w x h box."},
{"load_png","load_png(path: str) -> int","Decode a PNG as a 16x16 sprite sheet; returns the first sprite id."},
{"load_sprites","load_sprites(path: str)","Load the sprite sheet used by draw_sprite."},
{"draw_sprite","draw_sprite(id: int, x: int, y: int)","Blit a sprite."},
{"draw_sprite_scaled","draw_sprite_scaled(id: int, x: int, y: int, scale: int)","Blit a sprite at an integer scale."},
{"ui_build","ui_build()","Construct every declared `ui` tree (loads skins and images)."},
{"ui_open","ui_open(id: int)","Make a ui tree active and focus its first button."},
{"ui_tick","ui_tick(key: int)","Feed a key to the UI: w/s move focus, space/enter activate."},
{"ui_render","ui_render()","Lay out and draw the active ui tree."},
{"ui_clicked","ui_clicked(id: int) -> bool","True on the frame a widget was activated."},
{"ui_set_text","ui_set_text(id: int, s: str)","Replace a widget's text."},
{"ui_set_int","ui_set_int(id: int, n: int)","Replace a widget's text with a number."},
{"ui_focus","ui_focus(id: int)","Move keyboard focus to a widget."},
{"ui_focused","ui_focused() -> int","Id of the focused widget."},
{"ui_visible","ui_visible(id: int, on: bool)","Show or hide a widget subtree."},
{"key","key() -> int","Key code pressed this frame, 0 if none."},
{"reg","reg(i: int) -> int","Read one of the 64 integer resources shared by systems."},
{"setreg","setreg(i: int, v: int)","Write one of the 64 integer resources."},
{"self","self() -> entity","The entity of the innermost query loop."},
{"save","save()","Write a binary snapshot of the whole ECS world."},
{"load","load() -> bool","Restore the snapshot; false if there is none."},
{"status","status(s: str)","Set the one-line status message."},
{"print_int","print_int(i: int)","Print an integer to stdout."},
{"quit","quit()","Stop the frame loop and exit."},
{0,0,0}
};
static const LBuiltin LUDIC_INTRINSICS[] = {
{"mem_alloc","mem_alloc(n: int) -> ptr","Allocate n bytes (malloc)."},
{"mem_realloc","mem_realloc(p: ptr, n: int) -> ptr","Resize a block to n bytes, preserving its contents (realloc)."},
{"os_argc","os_argc() -> int","Number of command-line arguments, argv[0] included."},
{"os_arg","os_arg(i: int) -> str","The i-th command-line argument."},
{"file_stderr","file_stderr() -> ptr","The standard error stream, for file_write."},
{"mem_free","mem_free(p: ptr)","Release an allocation."},
{"mem_copy","mem_copy(dst: ptr, src: ptr, n: int)","memcpy."},
{"mem_set","mem_set(p: ptr, byte: int, n: int)","memset."},
{"peek8","peek8(p: ptr, off: int) -> int","Read one byte."},
{"poke8","poke8(p: ptr, off: int, v: int)","Write one byte."},
{"peek32","peek32(p: ptr, off: int) -> int","Read a 32-bit word."},
{"poke32","poke32(p: ptr, off: int, v: int)","Write a 32-bit word."},
{"peekp","peekp(p: ptr, off: int) -> ptr","Read a pointer-sized word."},
{"pokep","pokep(p: ptr, off: int, v: ptr)","Write a pointer-sized word."},
{"peekf","peekf(p: ptr, off: int) -> fixed","Read a fixed-point word."},
{"pokef","pokef(p: ptr, off: int, v: fixed)","Write a fixed-point word."},
{"ptr_add","ptr_add(p: ptr, off: int) -> ptr","Offset a pointer by bytes."},
{"ptr_null","ptr_null() -> ptr","The null pointer."},
{"ptr_is_null","ptr_is_null(p: ptr) -> bool","Null test."},
{"as_fixed","as_fixed(i: int) -> fixed","Reinterpret an int as fixed (no conversion)."},
{"as_int","as_int(f: fixed) -> int","Reinterpret a fixed as int (no conversion)."},
{"file_open","file_open(path: str, mode: str) -> ptr","fopen."},
{"file_read","file_read(f: ptr, buf: ptr, n: int) -> int","fread."},
{"file_write","file_write(f: ptr, buf: ptr, n: int) -> int","fwrite."},
{"file_seek","file_seek(f: ptr, off: int, whence: int) -> int","fseek."},
{"file_tell","file_tell(f: ptr) -> int","ftell."},
{"file_close","file_close(f: ptr)","fclose."},
{"read_byte","read_byte() -> int","Read one byte from stdin, -1 at EOF."},
{"write_byte","write_byte(b: int)","Write one byte to stdout."},
{"print_str","print_str(s: str)","Write a string to stdout."},
{"str_len","str_len(s: str) -> int","Length of a string in bytes."},
{"shl","shl(v: int, n: int) -> int","Shift left."},
{"shr","shr(v: int, n: int) -> int","Logical shift right."},
{"band","band(a: int, b: int) -> int","Bitwise and."},
{"bor","bor(a: int, b: int) -> int","Bitwise or."},
{"bxor","bxor(a: int, b: int) -> int","Bitwise xor."},
{"bnot","bnot(a: int) -> int","Bitwise not."},
{"os_exit","os_exit(code: int)","Terminate the process."},
{"os_time","os_time() -> int","Seconds since the epoch."},
{"is_windowed","is_windowed() -> bool","True when the build has a window."},
{"game_title","game_title() -> str","The name from the `game` declaration."},
{"win_open","win_open(title: str, w: int, h: int, scale: int)","Open the platform window."},
{"win_poll","win_poll() -> int","Pump the event queue; returns a key code."},
{"win_present","win_present(px: ptr, w: int, h: int)","Blit a framebuffer to the window."},
{"win_running","win_running() -> bool","False once the window has been closed."},
{"win_close","win_close()","Close the platform window."},
{0,0,0}
};
static int lud_in(const char** set, const char* s){
for (int i = 0; set[i]; i++) if (!strcmp(set[i], s)) return 1;
return 0;
}
static const LBuiltin* lud_lookup(const LBuiltin* t, const char* s){
for (int i = 0; t[i].name; i++) if (!strcmp(t[i].name, s)) return &t[i];
return 0;
}
static int lud_is_keyword(const char* s){
return lud_in(LUDIC_KW_DECL, s) || lud_in(LUDIC_KW_CLAUSE, s) || lud_in(LUDIC_KW_STMT, s);
}
/* ---------- lexing --------------------------------------------------------*/
static void ltok_push(LLex* L, int kind, int start, int end, int line, int bad){
if (L->n >= L->cap){ L->cap = L->cap ? L->cap * 2 : 512; L->v = realloc(L->v, L->cap * sizeof(LTok)); }
L->v[L->n++] = (LTok){ kind, start, end, line, bad };
}
static void lline_push(LLex* L, int off){
if (L->nline >= L->caplin){ L->caplin = L->caplin ? L->caplin * 2 : 256; L->linestart = realloc(L->linestart, L->caplin * sizeof(int)); }
L->linestart[L->nline++] = off;
}
static const char* LUDIC_OPS2[] = { "->","+=","-=","*=","/=","==","!=","<=",">=","&&","||","..","=>", 0 };
/* Lex the whole buffer. Comments and newlines are kept — the formatter needs
* both, and a highlighter needs the comments. */
static void lud_lex(LLex* L, const char* src){
memset(L, 0, sizeof(*L));
L->src = src;
lline_push(L, 0);
int i = 0, line = 0;
while (src[i]){
char c = src[i];
if (c == '\n'){ ltok_push(L, LT_NL, i, i + 1, line, 0); i++; line++; lline_push(L, i); continue; }
if (c == ' ' || c == '\t' || c == '\r'){ i++; continue; }
if (c == '#'){ int s = i; while (src[i] && src[i] != '\n') i++; ltok_push(L, LT_COMMENT, s, i, line, 0); continue; }
if (c == '"'){
int s = i; i++; int bad = 0;
while (src[i] && src[i] != '"' && src[i] != '\n'){ if (src[i] == '\\' && src[i+1]) i += 2; else i++; }
if (src[i] == '"') i++; else bad = 1;
ltok_push(L, LT_STR, s, i, line, bad); continue;
}
if (c == '\''){
int s = i; i++; int bad = 0;
if (src[i] == '\\' && src[i+1]) i += 2; else if (src[i] && src[i] != '\n') i++;
if (src[i] == '\'') i++; else bad = 1;
ltok_push(L, LT_CHAR, s, i, line, bad); continue;
}
if (isdigit((unsigned char)c)){
int s = i;
if (c == '0' && (src[i+1] == 'x' || src[i+1] == 'X')){
i += 2; while (isxdigit((unsigned char)src[i])) i++;
ltok_push(L, LT_INT, s, i, line, 0); continue;
}
while (isdigit((unsigned char)src[i])) i++;
if (src[i] == '.' && isdigit((unsigned char)src[i+1])){
i++; while (isdigit((unsigned char)src[i])) i++;
ltok_push(L, LT_FLOAT, s, i, line, 0); continue;
}
ltok_push(L, LT_INT, s, i, line, 0); continue;
}
/* @name is one token: an annotation reads as a unit, and the formatter
* must never put a space between the sigil and the name. */
if (c == '@' && (isalpha((unsigned char)src[i+1]) || src[i+1] == '_')){
int s = i; i++; while (isalnum((unsigned char)src[i]) || src[i] == '_') i++;
ltok_push(L, LT_ANNO, s, i, line, 0); continue;
}
if (isalpha((unsigned char)c) || c == '_'){
int s = i; while (isalnum((unsigned char)src[i]) || src[i] == '_') i++;
int len = i - s; char w[128];
if (len < (int)sizeof(w)){ memcpy(w, src + s, len); w[len] = 0; } else { w[0] = 0; }
int k = LT_ID;
if (!strcmp(w, "true") || !strcmp(w, "false")) k = LT_BOOL;
else if (lud_in(LUDIC_TYPES, w)) k = LT_TYPE;
else if (lud_in(LUDIC_PHASES, w)) k = LT_PHASE;
else if (lud_is_keyword(w)) k = LT_KW;
ltok_push(L, k, s, i, line, 0); continue;
}
{
int matched = 0;
for (int k = 0; LUDIC_OPS2[k]; k++)
if (src[i] == LUDIC_OPS2[k][0] && src[i+1] == LUDIC_OPS2[k][1]){
ltok_push(L, LT_OP, i, i + 2, line, 0); i += 2; matched = 1; break;
}
if (matched) continue;
}
if (c == ';'){ ltok_push(L, LT_OP, i, i + 1, line, 0); i++; continue; }
if (strchr("+-*/%<>=(){}[],:.!@", c)){ ltok_push(L, LT_OP, i, i + 1, line, 0); i++; continue; }
/* Anything else is an error token — but a whole UTF-8 character's worth
* at a time. Splitting a multi-byte character into one token per byte
* would let a consumer that re-emits tokens (the formatter) put spaces
* inside it and corrupt the file. */
{
unsigned char u = (unsigned char)c;
int len = u < 0x80 ? 1 : u < 0xE0 ? 2 : u < 0xF0 ? 3 : 4;
for (int k = 1; k < len; k++) if (!src[i + k] || ((unsigned char)src[i + k] & 0xC0) != 0x80){ len = k; break; }
ltok_push(L, LT_ERR, i, i + len, line, 1); i += len;
}
}
ltok_push(L, LT_EOF, i, i, line, 0);
}
static void lud_lex_free(LLex* L){ free(L->v); free(L->linestart); memset(L, 0, sizeof(*L)); }
/* ---------- helpers over the token stream --------------------------------*/
static int ltok_len(const LTok* t){ return t->end - t->start; }
static int ltok_is(const LLex* L, int i, const char* s){
if (i < 0 || i >= L->n) return 0;
const LTok* t = &L->v[i]; int n = ltok_len(t);
return (int)strlen(s) == n && !strncmp(L->src + t->start, s, n);
}
/* Copy a token's text into a caller buffer; returns buf. */
static char* ltok_text(const LLex* L, int i, char* buf, int cap){
const LTok* t = &L->v[i]; int n = ltok_len(t);
if (n >= cap) n = cap - 1;
memcpy(buf, L->src + t->start, n); buf[n] = 0; return buf;
}
/* Index of the next token that is not a newline or comment, or -1. */
static int ltok_next_sig(const LLex* L, int i){
for (int j = i + 1; j < L->n; j++){
int k = L->v[j].kind;
if (k != LT_NL && k != LT_COMMENT) return j;
}
return -1;
}
static int ltok_prev_sig(const LLex* L, int i){
for (int j = i - 1; j >= 0; j--){
int k = L->v[j].kind;
if (k != LT_NL && k != LT_COMMENT) return j;
}
return -1;
}
#endif /* LUDIC_SYNTAX_H */

View file

@ -1,405 +0,0 @@
/* ============================================================================
* ludic_workspace.h — many documents, one project.
*
* Ludic programs are multi-file: `import "rules.ludic"` splices a fragment into
* whichever `game`/`module` file pulled it in. An imported fragment is NOT a
* standalone program — it has no `game` block — so running the compiler on it
* directly is meaningless. This layer keeps that straight: it knows which files
* form a compilation unit, which file is its root, and it resolves a name
* against the whole unit rather than one buffer.
* ==========================================================================*/
#ifndef LUDIC_WORKSPACE_H
#define LUDIC_WORKSPACE_H
#include "ludic_index.h"
#include <dirent.h>
#include <limits.h>
#include <sys/stat.h>
#include <unistd.h>
/* ---------- paths & URIs --------------------------------------------------*/
static char* lw_readfile(const char* path, long* out_n){
FILE* f = fopen(path, "rb"); if (!f) return 0;
fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET);
char* b = malloc(n + 1);
if (fread(b, 1, n, f) != (size_t)n){ fclose(f); free(b); return 0; }
b[n] = 0; fclose(f);
if (out_n) *out_n = n;
return b;
}
static int lw_hex(int c){ return c >= '0' && c <= '9' ? c - '0' : (c | 32) >= 'a' && (c | 32) <= 'f' ? (c | 32) - 'a' + 10 : -1; }
static char* lw_uri_to_path(const char* uri){
if (strncmp(uri, "file://", 7)) return strdup(uri);
const char* s = uri + 7;
/* file://host/path is not something an editor sends for local files */
char* out = malloc(strlen(s) + 1); int j = 0;
for (int i = 0; s[i]; ){
if (s[i] == '%' && lw_hex(s[i+1]) >= 0 && lw_hex(s[i+2]) >= 0){
out[j++] = (char)(lw_hex(s[i+1]) * 16 + lw_hex(s[i+2])); i += 3;
} else out[j++] = s[i++];
}
out[j] = 0;
return out;
}
static char* lw_path_to_uri(const char* path){
static const char* safe = "-_.~/abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
size_t cap = strlen(path) * 3 + 16;
char* out = malloc(cap);
strcpy(out, "file://");
int j = 7;
for (int i = 0; path[i]; i++){
unsigned char c = (unsigned char)path[i];
if (strchr(safe, c)) out[j++] = c;
else j += sprintf(out + j, "%%%02X", c);
}
out[j] = 0;
return out;
}
static char* lw_dirname(const char* p){
char* d = strdup(p); char* s = strrchr(d, '/');
if (s) *s = 0; else { free(d); d = strdup("."); }
return d;
}
static char* lw_join(const char* dir, const char* rel){
if (rel[0] == '/') return strdup(rel);
size_t n = strlen(dir) + strlen(rel) + 2;
char* p = malloc(n); snprintf(p, n, "%s/%s", dir, rel);
char real[PATH_MAX];
if (realpath(p, real)){ free(p); return strdup(real); }
return p;
}
static int lw_ends(const char* s, const char* suf){
size_t a = strlen(s), b = strlen(suf);
return a >= b && !strcmp(s + a - b, suf);
}
/* ---------- Markdown ------------------------------------------------------
* Fenced Ludic inside prose is real Ludic and deserves the same treatment. The
* trick that makes it free: copy the document and blank out every byte that is
* not inside a ```ludic fence. Offsets, lines and columns still match the file
* on disk exactly, so every feature built on top — highlighting, hover,
* go-to-definition, diagnostics — works on a .md without knowing about it. */
static char* lw_scrub_markdown(const char* src){
char* out = strdup(src);
int i = 0, n = (int)strlen(src);
while (i < n){
int ls = i, le = i;
while (le < n && src[le] != '\n') le++;
int ind = 0; while (ls + ind < le && src[ls + ind] == ' ') ind++;
int j = ls + ind; char m = j < le ? src[j] : 0;
int run = 0; while (j + run < le && src[j + run] == m) run++;
int fenced = (m == '`' || m == '~') && run >= 3;
int info = j + run;
int is_ludic = 0;
if (fenced){
int k = info; while (k < le && (src[k] == ' ' || src[k] == '\t')) k++;
is_ludic = (le - k >= 5) && !strncasecmp(src + k, "ludic", 5) &&
(le - k == 5 || src[k+5] == ' ' || src[k+5] == '\t' || src[k+5] == '\r');
}
/* blank the fence line itself either way */
for (int k = ls; k < le; k++) out[k] = ' ';
i = le < n ? le + 1 : n;
if (!fenced) continue;
/* inside a fence: keep the body only when the info string says ludic */
while (i < n){
int bs = i, be = i;
while (be < n && src[be] != '\n') be++;
int bi = 0; while (bs + bi < be && src[bs + bi] == ' ') bi++;
int cj = bs + bi, crun = 0;
while (cj + crun < be && src[cj + crun] == m) crun++;
int only = 1;
for (int k = cj + crun; k < be; k++) if (src[k] != ' ' && src[k] != '\r'){ only = 0; break; }
if (crun >= run && only){
for (int k = bs; k < be; k++) out[k] = ' ';
i = be < n ? be + 1 : n;
break;
}
if (!is_ludic) for (int k = bs; k < be; k++) out[k] = ' ';
i = be < n ? be + 1 : n;
}
}
return out;
}
/* ---------- documents -----------------------------------------------------*/
static void ldoc_settext(LDoc* D, char* raw){
if (D->text != D->raw) free(D->text);
free(D->raw);
D->raw = raw;
D->text = D->is_markdown ? lw_scrub_markdown(raw) : raw;
}
static void ldoc_reindex(LDoc* D){
lud_lex_free(&D->lex);
lud_lex(&D->lex, D->text);
ldoc_match_braces(D);
ldoc_parse(D);
free(D->cls);
D->cls = calloc(D->lex.n + 1, 1);
}
static LDoc* lw_new_doc(const char* path, char* text){
LDoc* D = calloc(1, sizeof(LDoc));
char real[PATH_MAX];
D->path = realpath(path, real) ? strdup(real) : strdup(path);
D->uri = lw_path_to_uri(D->path);
D->is_markdown = lw_ends(D->path, ".md") || lw_ends(D->path, ".markdown");
ldoc_settext(D, text);
ldoc_reindex(D);
return D;
}
static void lw_add(LIndex* X, LDoc* D){
if (X->n >= X->cap){ X->cap = X->cap ? X->cap * 2 : 32; X->d = realloc(X->d, X->cap * sizeof(LDoc*)); }
X->d[X->n++] = D;
}
static LDoc* lw_by_path(LIndex* X, const char* path){
char real[PATH_MAX];
const char* p = realpath(path, real) ? real : path;
for (int i = 0; i < X->n; i++) if (!strcmp(X->d[i]->path, p)) return X->d[i];
return 0;
}
static LDoc* lw_by_uri(LIndex* X, const char* uri){
char* p = lw_uri_to_path(uri);
LDoc* D = lw_by_path(X, p);
if (!D) for (int i = 0; i < X->n; i++) if (!strcmp(X->d[i]->uri, uri)){ D = X->d[i]; break; }
free(p);
return D;
}
/* Load a file into the index if it is not there yet. */
static LDoc* lw_ensure(LIndex* X, const char* path){
LDoc* D = lw_by_path(X, path);
if (D) return D;
char* text = lw_readfile(path, 0);
if (!text) return 0;
D = lw_new_doc(path, text);
lw_add(X, D);
return D;
}
/* Walk the workspace once at startup so that go-to-definition works across
* files the editor has never opened. Skips the usual noise directories. */
static void lw_scan(LIndex* X, const char* dir, int depth){
if (depth > 8) return;
DIR* d = opendir(dir); if (!d) return;
struct dirent* e;
while ((e = readdir(d))){
if (e->d_name[0] == '.') continue;
if (!strcmp(e->d_name, "build") || !strcmp(e->d_name, "node_modules") ||
!strcmp(e->d_name, "target") || !strcmp(e->d_name, "out")) continue;
char p[PATH_MAX]; snprintf(p, sizeof(p), "%s/%s", dir, e->d_name);
struct stat st; if (stat(p, &st)) continue;
if (S_ISDIR(st.st_mode)) lw_scan(X, p, depth + 1);
else if (lw_ends(e->d_name, ".ludic")) lw_ensure(X, p);
}
closedir(d);
}
/* ---------- compilation units --------------------------------------------
* `related` = every document that shares a compilation unit with D: the unit
* root that (transitively) imports D, plus everything that root imports. */
static void lw_imports_of(LIndex* X, LDoc* D, LDoc** out, int* n, int max){
for (int i = 0; i < D->nimport; i++){
char* dir = lw_dirname(D->path);
char* full = lw_join(dir, D->imports[i]);
free(dir);
LDoc* I = lw_ensure(X, full);
free(full);
if (!I) continue;
int seen = 0; for (int k = 0; k < *n; k++) if (out[k] == I) seen = 1;
if (seen || *n >= max) continue;
out[(*n)++] = I;
lw_imports_of(X, I, out, n, max);
}
}
static int lw_related(LIndex* X, LDoc* D, LDoc** out, int max){
int n = 0;
if (!D) return 0;
out[n++] = D;
lw_imports_of(X, D, out, &n, max);
/* pull in any unit root that reaches D, and that root's other imports */
for (int i = 0; i < X->n && n < max; i++){
LDoc* R = X->d[i];
if (!R->is_unit || R == D) continue;
LDoc* reach[128]; int rn = 0;
lw_imports_of(X, R, reach, &rn, 128);
int hits = 0; for (int k = 0; k < rn; k++) if (reach[k] == D) hits = 1;
if (!hits) continue;
int seen = 0; for (int k = 0; k < n; k++) if (out[k] == R) seen = 1;
if (!seen && n < max) out[n++] = R;
for (int k = 0; k < rn && n < max; k++){
int s2 = 0; for (int q = 0; q < n; q++) if (out[q] == reach[k]) s2 = 1;
if (!s2) out[n++] = reach[k];
}
}
return n;
}
/* The file `ludicc` should actually be pointed at when checking D. */
static LDoc* lw_unit_root(LIndex* X, LDoc* D){
if (!D || D->is_markdown) return 0;
if (D->is_unit) return D;
LDoc* rel[128]; int n = lw_related(X, D, rel, 128);
/* a `game` beats a `module`: it is the one that actually compiles */
for (int i = 0; i < n; i++) if (rel[i]->is_unit && !rel[i]->is_module && !rel[i]->is_markdown) return rel[i];
for (int i = 0; i < n; i++) if (rel[i]->is_unit && !rel[i]->is_markdown) return rel[i];
return 0;
}
/* ---------- name resolution ----------------------------------------------*/
static int lsym_is_toplevel(int kind){
switch (kind){
case LS_COMPONENT: case LS_ARCHETYPE: case LS_CONST: case LS_VAR:
case LS_FN: case LS_EXTERN: case LS_SYSTEM: case LS_UI: case LS_WIDGET:
case LS_SCENE: case LS_LAYER: case LS_UNIT:
return 1;
default: return 0;
}
}
static int lsym_is_local(int kind){
return kind == LS_LOCAL || kind == LS_PARAM || kind == LS_QUERYVAR || kind == LS_STATE;
}
/* The innermost local binding of `name` visible at byte offset `off`. */
static int lw_find_local(LDoc* D, const char* name, int off){
int best = -1;
for (int i = 0; i < D->nsym; i++){
LSym* s = &D->sym[i];
if (!lsym_is_local(s->kind) || strcmp(s->name, name)) continue;
if (off < s->body_start || off > s->body_end) continue;
if (best < 0 || s->body_start >= D->sym[best].body_start) best = i;
}
return best;
}
static int lw_find_top(LIndex* X, LDoc* D, const char* name, LDoc** owner){
LDoc* rel[128]; int n = lw_related(X, D, rel, 128);
for (int i = 0; i < n; i++)
for (int k = 0; k < rel[i]->nsym; k++)
if (lsym_is_toplevel(rel[i]->sym[k].kind) && !strcmp(rel[i]->sym[k].name, name)){
*owner = rel[i]; return k;
}
/* fall back to the whole workspace — better a cross-unit jump than none */
for (int i = 0; i < X->n; i++)
for (int k = 0; k < X->d[i]->nsym; k++)
if (lsym_is_toplevel(X->d[i]->sym[k].kind) && !strcmp(X->d[i]->sym[k].name, name)){
*owner = X->d[i]; return k;
}
return -1;
}
/* The component a dotted receiver has, e.g. `p` in `p.x` when p came from a
* query binding or a typed parameter. Returns the component symbol or -1. */
static int lw_receiver_component(LIndex* X, LDoc* D, int dot_tok, LDoc** owner){
int recv = ltok_prev_sig(&D->lex, dot_tok);
if (recv < 0 || D->lex.v[recv].kind != LT_ID) return -1;
char name[96]; ltok_text(&D->lex, recv, name, sizeof(name));
int l = lw_find_local(D, name, D->lex.v[recv].start);
const char* ty = 0;
if (l >= 0) ty = D->sym[l].type;
else {
LDoc* o; int t = lw_find_top(X, D, name, &o);
if (t >= 0) ty = o->sym[t].type;
}
if (!ty || !ty[0]) return -1;
return lw_find_top(X, D, ty, owner);
}
/* ---------- semantic classification --------------------------------------*/
static int lw_first_on_line(LDoc* D, int tok){
int p = ltok_prev_sig(&D->lex, tok);
return p < 0 || D->lex.v[p].line != D->lex.v[tok].line;
}
static int lw_in_ui_body(LDoc* D, int off){
for (int i = 0; i < D->nsym; i++)
if (D->sym[i].kind == LS_UI && off > D->sym[i].body_start && off < D->sym[i].body_end) return 1;
return 0;
}
static void lw_classify(LIndex* X, LDoc* D){
if (!D->cls) D->cls = calloc(D->lex.n + 1, 1);
for (int i = 0; i < D->lex.n; i++){
const LTok* t = &D->lex.v[i];
int c = SC_NONE;
switch (t->kind){
case LT_COMMENT: c = SC_COMMENT; break;
case LT_STR: case LT_CHAR: c = SC_STRING; break;
case LT_INT: case LT_FLOAT: c = SC_NUMBER; break;
case LT_BOOL: case LT_KW: c = SC_KEYWORD; break;
case LT_TYPE: c = SC_TYPE; break;
case LT_PHASE: c = SC_PHASE; break;
case LT_ANNO: c = SC_ANNOTATION; break;
case LT_OP: c = SC_OPERATOR; break;
case LT_ID: c = SC_UNKNOWN; break;
default: c = SC_NONE; break;
}
if (t->kind != LT_ID){ D->cls[i] = (unsigned char)c; continue; }
char name[96]; ltok_text(&D->lex, i, name, sizeof(name));
/* 1. the token IS a declaration's name */
int decl = -1;
for (int s = 0; s < D->nsym; s++) if (D->sym[s].tok == i){ decl = s; break; }
if (decl >= 0){
switch (D->sym[decl].kind){
case LS_COMPONENT: c = SC_COMPONENT; break;
case LS_ARCHETYPE: c = SC_ARCHETYPE; break;
case LS_FIELD: c = SC_FIELD; break;
case LS_CONST: c = SC_CONST; break;
case LS_VAR: c = SC_MODVAR; break;
case LS_FN: case LS_EXTERN: c = SC_FUNCTION; break;
case LS_SYSTEM: c = SC_SYSTEM; break;
case LS_UI: c = SC_UI; break;
case LS_WIDGET: c = SC_CONST; break; /* UI_Foo is a handle */
case LS_SCENE: c = SC_SCENE; break;
case LS_LAYER: c = SC_LAYER; break;
case LS_PARAM: c = SC_PARAM; break;
case LS_QUERYVAR: case LS_LOCAL: case LS_STATE: c = SC_VARIABLE; break;
case LS_UNIT: c = SC_UI; break;
default: c = SC_UNKNOWN;
}
D->cls[i] = (unsigned char)c; continue;
}
/* 2. member access: `p.x` */
int prev = ltok_prev_sig(&D->lex, i);
if (prev >= 0 && ltok_is(&D->lex, prev, ".")){ D->cls[i] = SC_FIELD; continue; }
int next = ltok_next_sig(&D->lex, i);
int followed_by_eq = next >= 0 && ltok_is(&D->lex, next, "=");
/* 3. inside a `ui` block a bare word is a widget type and `k=` a prop —
* and those names (text, image, size) collide with builtins, so this
* has to be decided before the builtin table is consulted. */
if (lw_in_ui_body(D, t->start)){
if (followed_by_eq){ D->cls[i] = SC_PROP; continue; }
if (lw_first_on_line(D, i) || (prev >= 0 && (ltok_is(&D->lex, prev, "{") || ltok_is(&D->lex, prev, "}")))){
D->cls[i] = lud_in(LUDIC_WIDGETS, name) ? SC_WIDGET : SC_UI; continue;
}
}
/* 4. a local binding in scope */
int l = lw_find_local(D, name, t->start);
if (l >= 0){ D->cls[i] = D->sym[l].kind == LS_PARAM ? SC_PARAM : SC_VARIABLE; continue; }
/* 5. a declaration somewhere in the compilation unit */
LDoc* owner = 0; int top = lw_find_top(X, D, name, &owner);
if (top >= 0){
switch (owner->sym[top].kind){
case LS_COMPONENT: c = SC_COMPONENT; break;
case LS_ARCHETYPE: c = SC_ARCHETYPE; break;
case LS_CONST: c = SC_CONST; break;
case LS_VAR: c = SC_MODVAR; break;
case LS_FN: case LS_EXTERN: c = SC_FUNCTION; break;
case LS_SYSTEM: c = SC_SYSTEM; break;
case LS_UI: c = SC_UI; break;
case LS_WIDGET: c = SC_CONST; break;
case LS_SCENE: c = SC_SCENE; break;
case LS_LAYER: c = SC_LAYER; break;
default: c = SC_UNKNOWN;
}
D->cls[i] = (unsigned char)c; continue;
}
/* 6. the runtime surface */
if (lud_lookup(LUDIC_BUILTINS, name) || lud_lookup(LUDIC_INTRINSICS, name)){ D->cls[i] = SC_BUILTIN; continue; }
/* 7. a record field initialiser: `Pos = { x = 10 }` */
if (followed_by_eq){ D->cls[i] = SC_FIELD; continue; }
D->cls[i] = SC_UNKNOWN;
}
}
#endif /* LUDIC_WORKSPACE_H */

View file

@ -1,59 +0,0 @@
/* migrate_records.c — one-time migration for Rule A (named fields use ':').
* Records appear ONLY inside `spawn` blocks, so we track spawn context and, for
* every single '=' inside one:
* - '=' followed by '{' is a component init `Comp = { … }` -> delete it (`Comp { … }`)
* - otherwise is a field binding `field = value` -> rewrite to ':'
* A '=' at a real boundary is a pure re-spelling; the driver's IR-identity check
* (migrated program compiles to the same IR) is the proof.
* usage: migrate_records <file> -> migrated source to stdout
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ludic_syntax.h"
int main(int argc, char** argv){
if (argc < 2){ fprintf(stderr, "usage: migrate_records <file>\n"); return 2; }
FILE* f = fopen(argv[1], "rb");
if (!f){ fprintf(stderr, "cannot open %s\n", argv[1]); return 2; }
fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET);
char* src = malloc(n + 1); fread(src, 1, n, f); src[n] = 0; fclose(f);
LLex L; lud_lex(&L, src);
char* rep = calloc(L.n, 1); /* '=' -> ':' */
char* del = calloc(L.n, 1); /* drop the '=' (component init) */
int sp_stack[1024]; int bp = 0; int spawn_active = 0; int pendingSpawn = 0;
for (int i = 0; i < L.n; i++){
int k = L.v[i].kind; if (k == LT_EOF) break;
if (k == LT_NL || k == LT_COMMENT) continue;
if (k == LT_KW && ltok_is(&L, i, "spawn")) pendingSpawn = 1;
int op1 = (k == LT_OP && ltok_len(&L.v[i]) == 1);
char c0 = L.src[L.v[i].start];
if (op1 && c0 == '{'){
if (bp < 1024){ sp_stack[bp] = (pendingSpawn || spawn_active > 0) ? 1 : 0; if (sp_stack[bp]) spawn_active++; bp++; }
pendingSpawn = 0;
} else if (op1 && c0 == '}'){
if (bp > 0){ bp--; if (sp_stack[bp] && spawn_active > 0) spawn_active--; }
} else if (op1 && c0 == '='){
if (spawn_active > 0){
int nx = ltok_next_sig(&L, i);
if (nx >= 0 && L.v[nx].kind == LT_OP && ltok_len(&L.v[nx]) == 1 && L.src[L.v[nx].start] == '{') del[i] = 1;
else rep[i] = 1;
}
}
}
/* map flags onto byte positions so the rebuild can peek one char ahead */
char* repb = calloc(n + 1, 1); char* delb = calloc(n + 1, 1);
for (int i = 0; i < L.n; i++){ if (rep[i]) repb[L.v[i].start] = 1; if (del[i]) delb[L.v[i].start] = 1; }
for (int b = 0; b < n; ){
if (repb[b]){ putchar(':'); b++; continue; } /* '=' -> ':' */
if (delb[b]){ b++; if (b < n && src[b] == ' ') b++; continue; } /* drop '=' and one space */
if (src[b] == ' ' && b + 1 < n && repb[b + 1]){ b++; continue; } /* drop the space before ':' -> `field: v` */
putchar(src[b]); b++;
}
free(repb); free(delb);
lud_lex_free(&L); free(rep); free(del); free(src);
return 0;
}

View file

@ -1,119 +0,0 @@
/* sepfix.c — one-time migration for Rule B: insert `;` at statement boundaries
* that are currently spelled with only whitespace. Reuses the toolchain lexer.
*
* A boundary is inserted between two significant, same-line tokens prev,cur when
* - we are at paren/bracket depth 0 (not inside a call's args or an index), AND
* - we are NOT inside a `ui` block (widget props are `k=v`, space-separated,
* and the parser does not skip newlines between them), AND
* - prev can END an operand/statement, AND cur can START a statement.
* Inserting `;` (which the lexer maps to a newline token) at a REAL boundary is
* a semantic no-op under today's permissive parser — the IR-identity check in
* the driver is the proof. Wrong insertions change the IR and are rejected.
*
* usage: sepfix <in.ludic> -> writes migrated source to stdout
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ludic_syntax.h"
static const char* STMT_START[] = {
"let","return","if","when","while","for","spawn","despawn",
"match","machine","become","enter","break","continue", 0
};
static int ender(const LLex* L, int i){
const LTok* t = &L->v[i];
switch (t->kind){
case LT_ID: case LT_INT: case LT_FLOAT: case LT_STR: case LT_CHAR:
case LT_BOOL: case LT_TYPE: case LT_PHASE: return 1;
case LT_OP: {
if (ltok_len(t) != 1) return 0;
char c = L->src[t->start];
if (c == ']'){
/* `]` closing an empty `[]` is a slice-TYPE marker (`[]Node`),
* not an operand end — the type name that follows continues it. */
int p = ltok_prev_sig(L, i);
if (p >= 0 && L->v[p].kind == LT_OP && ltok_len(&L->v[p]) == 1 && L->src[L->v[p].start] == '[') return 0;
return 1;
}
return (c == ')' || c == '}'); }
case LT_KW: { char b[32]; ltok_text(L, i, b, sizeof b);
return !strcmp(b, "break") || !strcmp(b, "continue"); }
default: return 0;
}
}
static int starter(const LLex* L, int i){
const LTok* t = &L->v[i];
if (t->kind == LT_ID || t->kind == LT_INT || t->kind == LT_FLOAT ||
t->kind == LT_STR || t->kind == LT_CHAR || t->kind == LT_BOOL) return 1;
if (t->kind == LT_KW){ char b[32]; ltok_text(L, i, b, sizeof b); return lud_in(STMT_START, b); }
return 0; /* an operator never starts a statement here */
}
int main(int argc, char** argv){
if (argc < 2){ fprintf(stderr, "usage: sepfix <file>\n"); return 2; }
FILE* f = fopen(argv[1], "rb");
if (!f){ fprintf(stderr, "sepfix: cannot open %s\n", argv[1]); return 2; }
fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET);
char* src = malloc(n + 1); fread(src, 1, n, f); src[n] = 0; fclose(f);
LLex L; lud_lex(&L, src);
/* insert[k] = 1 means: emit a ';' immediately after token k's bytes */
char* insert = calloc(L.n, 1);
int paren = 0; /* unclosed ( or [ */
/* ui suppression: brace depth stack, marking which open braces are ui/widget */
int ui_stack[512]; int bp = 0; int ui_active = 0;
int pendingUiOpen = 0; /* saw `ui` or a widget word; the next { is a ui brace */
int prev = -1; /* previous significant token index */
for (int i = 0; i < L.n; i++){
int k = L.v[i].kind;
if (k == LT_EOF) break;
if (k == LT_NL || k == LT_COMMENT) continue;
char c0 = L.src[L.v[i].start];
int op1 = (k == LT_OP && ltok_len(&L.v[i]) == 1);
/* track ui context by the `ui` KEYWORD only — it brackets the whole
* widget tree via its root brace, so every nested widget brace is
* already inside ui_active. (Do NOT key off widget words like col/row/
* image/label: those are also ordinary variable names, and matching them
* would wrongly suppress real statement boundaries.) */
if (k == LT_KW && ltok_is(&L, i, "ui")) pendingUiOpen = 1;
if (op1 && c0 == '{'){
if (bp < 512){ ui_stack[bp] = pendingUiOpen; bp++; if (pendingUiOpen) ui_active++; }
pendingUiOpen = 0;
} else if (op1 && c0 == '}'){
if (bp > 0){ bp--; if (ui_stack[bp]) { if (ui_active) ui_active--; } }
} else if (op1 && (c0 == '(' || c0 == '[')){ paren++; if (getenv("SEPFIX_TRACE")) fprintf(stderr, " L%d '%c' -> paren=%d\n", L.v[i].line + 1, c0, paren); }
else if (op1 && (c0 == ')' || c0 == ']')){ if (paren > 0) paren--; if (getenv("SEPFIX_TRACE")) fprintf(stderr, " L%d '%c' -> paren=%d\n", L.v[i].line + 1, c0, paren); }
/* candidate boundary between prev and this token */
if (prev >= 0 && paren == 0 && !ui_active &&
L.v[prev].line == L.v[i].line &&
ender(&L, prev) && starter(&L, i)){
insert[prev] = 1;
}
prev = i;
}
if (getenv("SEPFIX_DEBUG")) fprintf(stderr, "END STATE %s: paren=%d ui_active=%d bp=%d\n", argv[1], paren, ui_active, bp);
/* rebuild: copy bytes, dropping in ';' right after any token flagged */
for (int i = 0, t = 0; i < n; ){
/* find if a token ends exactly at i and is flagged */
putchar(src[i]);
i++;
/* emit ';' after the last byte of a flagged token */
for (; t < L.n; t++){
if (L.v[t].end == i && insert[t]){ putchar(';'); break; }
if (L.v[t].end > i) break;
}
}
lud_lex_free(&L); free(insert); free(src);
return 0;
}

View file

@ -1,52 +0,0 @@
/* migrate_ui.c — one-time migration for Rule A in `ui` blocks: widget props go
* from `key=value` to `key: value`. Every '=' inside a `ui` block is a prop
* separator (values are expressions, never contain a top-level '='), so we track
* ui context (armed by the `ui` keyword's root brace) and rewrite each '=' to a
* ':' with canonical spacing (`id: Root`). The driver's IR-identity check proves
* it is a pure re-spelling (the parser builds the same E_FINIT nodes).
* usage: migrate_ui <file> -> migrated source to stdout
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ludic_syntax.h"
int main(int argc, char** argv){
if (argc < 2){ fprintf(stderr, "usage: migrate_ui <file>\n"); return 2; }
FILE* f = fopen(argv[1], "rb");
if (!f){ fprintf(stderr, "cannot open %s\n", argv[1]); return 2; }
fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET);
char* src = malloc(n + 1); fread(src, 1, n, f); src[n] = 0; fclose(f);
LLex L; lud_lex(&L, src);
char* repb = calloc(n + 1, 1); /* byte position of a '=' to rewrite as ':' */
int ui_stack[1024]; int bp = 0; int ui_active = 0; int pendingUi = 0;
for (int i = 0; i < L.n; i++){
int k = L.v[i].kind; if (k == LT_EOF) break;
if (k == LT_NL || k == LT_COMMENT) continue;
if (k == LT_KW && ltok_is(&L, i, "ui")) pendingUi = 1;
int op1 = (k == LT_OP && ltok_len(&L.v[i]) == 1);
char c0 = L.src[L.v[i].start];
if (op1 && c0 == '{'){
if (bp < 1024){ ui_stack[bp] = (pendingUi || ui_active > 0) ? 1 : 0; if (ui_stack[bp]) ui_active++; bp++; }
pendingUi = 0;
} else if (op1 && c0 == '}'){
if (bp > 0){ bp--; if (ui_stack[bp] && ui_active > 0) ui_active--; }
} else if (op1 && c0 == '=' && ui_active > 0){
repb[L.v[i].start] = 1;
}
}
for (int b = 0; b < n; ){
if (repb[b]){ /* '=' -> ': ' with a single trailing space */
putchar(':'); b++;
if (b < n && src[b] != ' ' && src[b] != '\n') putchar(' ');
continue;
}
if (src[b] == ' ' && b + 1 < n && repb[b + 1]){ b++; continue; } /* drop the space before ':' */
putchar(src[b]); b++;
}
lud_lex_free(&L); free(repb); free(src);
return 0;
}