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

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 Gravity # a whole model sits out every query
disable AiThink # a handler stops running each phase
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