Every per-entity store (@S_ components, @H_ flags, alive, kind, freelist, owners) is a heap block
L_grow doubles from 1024 as L_alloc hands out a slot past it, the new slots zeroed; each site loads
the store's base where it indexes it (ecs_base, its registers %ecsb* so a raw function's t0 labels
cannot collide). Prop.has bounds against @L_cap, Pool.capacity answers it, a mod's registered
stores grow with the rest, every main grows the stores once before anything reads them. A snapshot
records its slot count first and a load grows to it before reading back. The overflow stop of
1c7ce84 is gone with the wall. ludic-dev test 305 passed, selfhost-test 33 passed; 1000 / 5000 /
100000 entities spawn and count.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
381 lines
17 KiB
Text
381 lines
17 KiB
Text
# emit_net.ludic — NETWORKING N2–N6 codegen (see the Networking design on the wiki: Design/Networking).
|
||
#
|
||
# 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.
|
||
|
||
function net_field_ibytes(ty: pointer) -> int { if (llty(ty) == "i8") { return 1 }; return 4 }
|
||
function net_field_bytes(ty: pointer) -> pointer { if (llty(ty) == "i8") { return "1" }; return "4" }
|
||
|
||
# total replicated bytes for model m (compile-time constant)
|
||
function 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 += net_field_ibytes(c.kids[fj].ty) }; fj += 1 }
|
||
}
|
||
}
|
||
ci += 1
|
||
}
|
||
return total
|
||
}
|
||
|
||
function net_model_syncs(m: Node) -> bool { return net_model_bytes(m) > 0 }
|
||
|
||
function 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 += 1 }
|
||
return false
|
||
}
|
||
|
||
# ---- N3: ownership -----------------------------------------------------------
|
||
function 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 += 1 }
|
||
return false
|
||
}
|
||
|
||
# ---- N5: role-tagged handlers ------------------------------------------------
|
||
# A handler tagged @Server (ival==1) or @Predicted (ival==2) has a network role.
|
||
function 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 += 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.
|
||
function net_any() -> bool { return net_has_sync() or net_has_owned() or net_has_role() }
|
||
|
||
# ---- diagnostics -------------------------------------------------------------
|
||
function net_warn(msg: pointer) -> 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).
|
||
function 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 += 1
|
||
}
|
||
if not any { net_warn(`model {m.s} @Syncs {cn} but it has no @Sync fields — nothing replicates`) }
|
||
}
|
||
}
|
||
ci += 1
|
||
}
|
||
}
|
||
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.
|
||
function 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 eb1 = ecs_base(`S_{cn}`)
|
||
let s = nreg(); emit(" "); emit(s); emit(" = getelementptr inbounds %Cmp_"); emit(cn); emit(", ptr "); emit(eb1); emit(", 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 += net_field_ibytes(c.kids[fj].ty)
|
||
}
|
||
fj += 1
|
||
}
|
||
}
|
||
}
|
||
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.
|
||
function 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 eb2 = ecs_base(`S_{cn}`)
|
||
let s = nreg(); emit(" "); emit(s); emit(" = getelementptr inbounds %Cmp_"); emit(cn); emit(", ptr "); emit(eb2); emit(", 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 += net_field_ibytes(c.kids[fj].ty)
|
||
}
|
||
fj += 1
|
||
}
|
||
}
|
||
}
|
||
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.
|
||
function emit_net_dispatch() -> void {
|
||
let me = itoa(MAX_ENT)
|
||
|
||
emit("define i32 @ludic_serialize(i32 %e, ptr %buf) {\nentry:\n")
|
||
let eb3 = ecs_base("L_kind")
|
||
emit(" %kp = getelementptr inbounds i32, ptr "); emit(eb3); emit(", 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 += 1
|
||
}
|
||
i += 1
|
||
}
|
||
emit(" ret i32 0\n}\n\n")
|
||
|
||
emit("define void @ludic_apply(i32 %e, ptr %buf, i32 %len) {\nentry:\n")
|
||
let eb4 = ecs_base("L_kind")
|
||
emit(" %kp = getelementptr inbounds i32, ptr "); emit(eb4); emit(", 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 += 1
|
||
}
|
||
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")
|
||
let eb5 = ecs_base("L_kind")
|
||
emit(" %kp = getelementptr inbounds i32, ptr "); emit(eb5); emit(", 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 += 1
|
||
}
|
||
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.
|
||
function emit_net_owner() -> void {
|
||
let me = itoa(MAX_ENT)
|
||
emit("define i32 @L_owner(i32 %e) {\nentry:\n")
|
||
let eb6 = ecs_base("L_owner_arr")
|
||
emit(" %p = getelementptr inbounds i32, ptr "); emit(eb6); emit(", 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")
|
||
let eb7 = ecs_base("L_owner_arr")
|
||
emit(" %p = getelementptr inbounds i32, ptr "); emit(eb7); emit(", 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")
|
||
let eb8 = ecs_base("L_owner_arr")
|
||
emit(" %p = getelementptr inbounds i32, ptr "); emit(eb8); emit(", 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.
|
||
function 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.
|
||
|
||
function net_has_remote() -> bool {
|
||
var i = 0
|
||
while i < len(g_events) { if (g_events[i].ty != null) { return true }; i += 1 }
|
||
return false
|
||
}
|
||
# stable wire id for an event = its index in g_events (same program both peers)
|
||
function net_event_id(name: pointer) -> int {
|
||
var i = 0
|
||
while i < len(g_events) { if (g_events[i].s == name) { return i }; i += 1 }
|
||
return -1
|
||
}
|
||
# the transport symbols: an `extern fn` override, else the built-in loopback.
|
||
function net_send_sym() -> pointer { let x = find_extern("net_send"); if (x != null) { return x.a.s }; return "L_net_send" }
|
||
function net_poll_sym() -> pointer { 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.
|
||
function 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 += net_field_ibytes(ev.kids[f].ty)
|
||
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 += 1
|
||
}
|
||
emit(" br label %loop\n") # unknown id: skip, keep draining
|
||
emit("fin:\n ret void\n}\n\n")
|
||
}
|
||
|
||
# ---- driver ------------------------------------------------------------------
|
||
function 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 += 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()
|
||
}
|
||
}
|