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
670
EVENTS-DESIGN.md
Normal file
670
EVENTS-DESIGN.md
Normal 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.*
|
||||
158
LANGUAGE.md
158
LANGUAGE.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
348
LIFECYCLE-DESIGN.md
Normal file
348
LIFECYCLE-DESIGN.md
Normal 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
338
MOBILE-DESIGN.md
Normal 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
505
NETWORKING-DESIGN.md
Normal 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
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.*
|
||||
27
examples/cancel.ludic
Normal file
27
examples/cancel.ludic
Normal 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
38
examples/detach.ludic
Normal 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
25
examples/events.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
27
examples/layer_events.ludic
Normal file
27
examples/layer_events.ludic
Normal 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
16
examples/mod_events.ludic
Normal 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
45
examples/net_demo.ludic
Normal 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
24
examples/net_echo.ludic
Normal 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
25
examples/net_owner.ludic
Normal 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
33
examples/net_roles.ludic
Normal 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
29
examples/net_rpc.ludic
Normal 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
33
examples/net_rt.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
29
examples/net_snapshot.ludic
Normal file
29
examples/net_snapshot.ludic
Normal 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
40
examples/net_sync.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
examples/program_events.ludic
Normal file
18
examples/program_events.ludic
Normal 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
32
examples/promote.ludic
Normal 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()
|
||||
}
|
||||
}
|
||||
26
examples/prop_events.ludic
Normal file
26
examples/prop_events.ludic
Normal 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
38
examples/reason.ludic
Normal 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
21
examples/recurse.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
22
examples/scene_events.ludic
Normal file
22
examples/scene_events.ludic
Normal 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() } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
21
examples/scoped.ludic
Normal 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
27
examples/world_dyn.ludic
Normal 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
22
examples/world_get.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
17
examples/world_mixed.ludic
Normal file
17
examples/world_mixed.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
26
examples/world_query.ludic
Normal file
26
examples/world_query.ludic
Normal 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
30
examples/world_scan.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
20
examples/world_spawn.ludic
Normal file
20
examples/world_spawn.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
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}`) }
|
||||
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")
|
||||
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) {
|
||||
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
373
selfhost/emit_net.ludic
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
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")
|
||||
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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
21758
selfhost/ludicc.seed.ll
21758
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
135
test.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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 "cc: $main -> $OUT/$name"
|
||||
$CC $CFLAGS "$main" -o "$OUT/$name"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
563
tools/ludic-tools/fmt.ludic
Normal 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
2409
tools/ludic-tools/lsp.ludic
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 */
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue