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:
parent
96d01e45ab
commit
bca8f126fc
67 changed files with 24066 additions and 9309 deletions
354
SCENES-DESIGN.md
Normal file
354
SCENES-DESIGN.md
Normal 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.*
|
||||
Loading…
Add table
Add a link
Reference in a new issue