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

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

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

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

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

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

373
selfhost/emit_net.ludic Normal file
View file

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