From 12172d7b75808cccd753f969e5645ce7aff33762 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Fri, 25 Sep 2026 07:45:26 +0300 Subject: [PATCH] feat(packages): ludic.net - a party over UDP: peers, reliable ordered delivery, the hello's frame and the host's pids, join codes, room codes through a matchmaker with STUN, punching and a relay, the MTU cap and the wire codec; messages in on an inbox, the session's end and a silent guest as facts Co-Authored-By: Claude Opus 5.5 --- packages/ludic.net/README.md | 93 ++++++++ packages/ludic.net/codec.ludic | 109 ++++++++++ packages/ludic.net/codes.ludic | 100 +++++++++ packages/ludic.net/index.ludic | 21 ++ packages/ludic.net/package.ludic | 5 + packages/ludic.net/peers.ludic | 110 ++++++++++ packages/ludic.net/ports.ludic | 96 +++++++++ packages/ludic.net/punch.ludic | 57 +++++ packages/ludic.net/queries.ludic | 17 ++ packages/ludic.net/receive.ludic | 91 ++++++++ packages/ludic.net/relay.ludic | 89 ++++++++ packages/ludic.net/room.ludic | 83 +++++++ packages/ludic.net/room_connect.ludic | 47 ++++ packages/ludic.net/room_tick.ludic | 106 +++++++++ packages/ludic.net/send.ludic | 81 +++++++ packages/ludic.net/session.ludic | 106 +++++++++ packages/ludic.net/state.ludic | 97 +++++++++ packages/ludic.net/stun.ludic | 68 ++++++ packages/ludic.net/tests/net_test.ludic | 275 ++++++++++++++++++++++++ packages/ludic.net/tick.ludic | 111 ++++++++++ 20 files changed, 1762 insertions(+) create mode 100644 packages/ludic.net/README.md create mode 100644 packages/ludic.net/codec.ludic create mode 100644 packages/ludic.net/codes.ludic create mode 100644 packages/ludic.net/index.ludic create mode 100644 packages/ludic.net/package.ludic create mode 100644 packages/ludic.net/peers.ludic create mode 100644 packages/ludic.net/ports.ludic create mode 100644 packages/ludic.net/punch.ludic create mode 100644 packages/ludic.net/queries.ludic create mode 100644 packages/ludic.net/receive.ludic create mode 100644 packages/ludic.net/relay.ludic create mode 100644 packages/ludic.net/room.ludic create mode 100644 packages/ludic.net/room_connect.ludic create mode 100644 packages/ludic.net/room_tick.ludic create mode 100644 packages/ludic.net/send.ludic create mode 100644 packages/ludic.net/session.ludic create mode 100644 packages/ludic.net/state.ludic create mode 100644 packages/ludic.net/stun.ludic create mode 100644 packages/ludic.net/tests/net_test.ludic create mode 100644 packages/ludic.net/tick.ludic diff --git a/packages/ludic.net/README.md b/packages/ludic.net/README.md new file mode 100644 index 00000000..ae11a85e --- /dev/null +++ b/packages/ludic.net/README.md @@ -0,0 +1,93 @@ +# ludic.net + +A small party over UDP: a host and up to eleven guests, packets with reliable ordered delivery, +the frame of a handshake, join codes, and room codes through a matchmaker with STUN, hole punching +and a relay for the routers punching cannot get through. Uses +[`ludic.base`](../ludic.base/README.md) and nothing else. + +```ludic +import "ludic.net" +``` + +What a message says, what each kind means, what a hello carries and why a host refuses one are +the game's. The package gets bytes there once, in order, and says what happened to the session. + +## The wire + +A packet is a 20-byte header - `"ML"`, the protocol byte, `1`, the session token, the sender's pid, +the highest reliable id taken in order, a message count - then messages: a kind, a flags byte, a +reliable id when the message is reliable, a 16-bit length and the payload. A reliable message is +kept until acknowledged and sent again every quarter second; a receiver takes them strictly in +order and drops what arrives early (it comes again). An unreliable one half a second old is +dropped. A message is at most `NET_MTU - 40` bytes and is cut, silently, past that; a string is at +most 200 bytes (`nw_s`), a longer one is `nw_text` with its own cap. Numbers are little-endian and +a float goes as its bits. + +The host is pid 1 and hands out 2 upwards (`net_accept`). Kind 1 is `NET_HELLO`, the one kind a +stranger may send a host; kind 0 is the heartbeat, never delivered. Until the game accepts a +stranger, nothing else from it reaches the inbox. + +## Config and ports + +```ludic +property NetConfig { + proto (1), port (27415: an address typed without one) + signal_url ("": room codes off), stun_host, stun_port (27400), stun_at ("ip:port" instead) + no_stun, force_relay # tests on one machine + relay_port0 (60000), seed # a relayed guest's port base; the STUN dice +} +port NetWorld { log: fn(string) -> void, trace: fn() -> bool } # unbound: silence +port NetSocket { open, close, send, recv, from_ip, from_port, port, local_ip } # unbound: Udp +``` + +## API + +| | | +| --- | --- | +| `net_config(c)` | before the first session | +| `net_host(port, token) -> bool` | open a socket and take guests (and a room, when the matchmaker is on); false: `net_why()` | +| `net_hello_set()` | the message just written is the hello: sent again on every dial | +| `net_join(ip, port) -> bool`, `net_room_join(code) -> bool` | dial an address, or ask the matchmaker for a room's host | +| `net_accept(slot) -> pid`, `net_joined_as(token, pid)` | the host lets a stranger in; a guest was let in | +| `net_dial(ip, port)`, `net_follow(ip, port)`, `net_stand_alone()`, `net_become_host(token)`, `net_room_host()` | a refused guest dialling again, a guest following its host elsewhere, a hand-off's heir | +| `net_hello_wait(secs)` | a slow host gets this much longer (a map switch) | +| `net_begin(dt)`, `net_update(dt)`, `net_end()` | a frame, with the game's own reading and writing between | +| `net_keepalive()`, `net_flush_all()` | a load holding the frame; a machine about to let go | +| `net_reset()`, `net_lose(why)`, `net_fresh()` | leave; out with a reason (`NET_DOWN`); a lost session read and done with | +| `np_queue(slot, kind, rel)`, `np_queue_all(kind, rel, except)`, `np_flush(slot)`, `np_close(slot)`, `np_of_pid(pid)` | the written message to a peer or to all | +| `net_close_when_sent(slot, secs)` | let a peer go once it has taken what was queued for it (`NET_CLOSED`) | +| `nw_begin`, `nw_i`, `nw_b`, `nw_f`, `nw_s`, `nw_text`, `nw_len` | write a message | +| `net_inbox() -> Queue`, `net_read(m)`, `nr_i`, `nr_b`, `nr_f`, `nr_s`, `nr_text`, `nr_bad()` | what came in, in order, and reading it (a short message reads zeros and sets `nr_bad`) | +| `net_copy_read()`, `net_written()` | pass the message read on; the message written, as if it came in | +| `nb_put8`, `nb_put32`, `nb_get8`, `nb_get32`, `nb_copy` | bytes | +| `net_code_of(ip, port)`, `net_join_code()`, `net_parse_code(text)`, `net_code_ip()`, `net_code_port()`, `net_is_room(text)` | ten letters for an address and port, and back; an `a.b.c.d[:port]` is taken too | +| `net_state()`, `net_live()`, `net_active()`, `net_hosting()`, `net_joined()`, `net_joining()`, `net_my_pid()`, `net_token()`, `net_time()`, `net_why()`, `net_port()`, `net_join_ip()`, `net_join_port()`, `net_peer_on/pid/ip/port/heard(slot)`, `net_peer_count()`, `net_room_on()`, `net_room()`, `net_room_asking()`, `net_via_relay()`, `net_outside_ip/port()` | what the game asks | +| `net_bytes_out()`, `net_kind_bytes(kind)`, `net_kind_out_clear()` | what a party costs the upload | +| `net_facts() -> Queue` | `{ what, slot, pid, why, text }`: `NET_GONE` (a guest went silent), `NET_CLOSED`, `NET_DOWN` (why: `NET_WHY_*`, text: the room nobody had), `NET_ROOM` (text: the code), `NET_ROOM_FAILED` | + +A frame, as the game runs it: + +```ludic +net_begin(dt) # time, and what came in onto the inbox +let got = q_drain(net_inbox()) # the game's own dispatch +for i in 0 .. len(got) { if net_peer_on(got[i].slot) { net_read(got[i]) ... } } +net_update(dt) # the matchmaker, the hello, silence +... queue this frame's messages ... +net_end() # heartbeats, sending, slots let go +q_drain(net_facts()) +``` + +The words for a reason are the game's: `NET_WHY_*` is a code, and `NET_WHY_GAME` means the game +said why itself before `net_lose`. + +## Tests + +```bash +ludic test packages/ludic.net +``` + +A fake socket: the codec, the codes, a host taking a stranger only with a hello, delivery in order, +resending until taken, a stale unreliable message dropped, the hello's timeout, a silent guest, a +slot let go once it took its last message, and no matchmaker. The matchmaker, STUN and the relay +are covered by the game's `tests/net.sh` (`NET_ROOM=1`, `NET_RELAY=1`) against +`tools/partyserver` on localhost. diff --git a/packages/ludic.net/codec.ludic b/packages/ludic.net/codec.ludic new file mode 100644 index 00000000..f280ab83 --- /dev/null +++ b/packages/ludic.net/codec.ludic @@ -0,0 +1,109 @@ +# codec.ludic - bytes little-endian, one message written at a time (nw_*) and one read (nr_*). +# Numbers go as their raw bits: a float as float_bits, never rounded and never fixed. +export function nb_put8(p: []byte, o: int, v: int) -> void { p[o] = v & 255 } + +export function nb_put32(p: []byte, o: int, v: int) -> void { + p[o] = v & 255 + p[o + 1] = (v >> 8) & 255 + p[o + 2] = (v >> 16) & 255 + p[o + 3] = (v >> 24) & 255 +} + +export function nb_get8(p: []byte, o: int) -> int { return p[o] & 255 } + +export function nb_get32(p: []byte, o: int) -> int { + return (p[o] & 255) | ((p[o + 1] & 255) << 8) | ((p[o + 2] & 255) << 16) | ((p[o + 3] & 255) << 24) +} + +# n bytes from src at s to dst at d, forwards: within one buffer it may only move bytes DOWN +export function nb_copy(dst: []byte, d: int, src: []byte, s: int, n: int) -> void { + for i in 0 .. n { dst[d + i] = src[s + i] } +} + +# the writer: past the MTU it counts on and keeps nothing, and np_queue cuts the message there +export function nw_begin() -> void { + net__init() + net__wl = 0 +} + +export function nw_i(v: int) -> void { + if net__wl + 4 <= NET_MTU { nb_put32(net__wb, net__wl, v) } + net__wl += 4 +} + +export function nw_b(v: bool) -> void { if v { nw_i(1) } else { nw_i(0) } } + +export function nw_f(v: float) -> void { nw_i(float_bits(v)) } + +# a string of at most 200 bytes; nw_text takes a longer one up to its own cap +export function nw_s(s: string) -> void { nw_text(s, 200) } + +export function nw_text(s: string, cap: int) -> void { + var n = len(s) + if n > cap { n = cap } + nw_i(n) + for i in 0 .. n { + if net__wl < NET_MTU { net__wb[net__wl] = s[i] } + net__wl += 1 + } +} + +export function nw_len() -> int { return net__wl } + +# the reader: a short or bad message reads as zeros and sets nr_bad() +export function nr_i() -> int { + if net__rp + 4 > net__rl { + net__rbad = true + return 0 + } + let v = nb_get32(net__rb, net__rp) + net__rp += 4 + return v +} + +export function nr_b() -> bool { return nr_i() != 0 } + +export function nr_f() -> float { return float_from_bits(nr_i()) } + +export function nr_s() -> string { return nr_text(200) } + +export function nr_text(cap: int) -> string { + let n = nr_i() + if n < 0 or n > cap or net__rp + n > net__rl { + net__rbad = true + return "" + } + if net__tb == null { net__tb = buffer(NET_MTU) } + nb_copy(net__tb, 0, net__rb, net__rp, n) + net__rp += n + return text_of(net__tb, n) +} + +export function nr_bad() -> bool { return net__rbad } + +# a message that came in, made the one nr_* reads +export function net_read(m: NetMessage) -> void { + net__init() + nb_copy(net__rb, 0, m.data, 0, m.len) + net__rl = m.len + net__rp = 0 + net__rbad = false +} + +# the message just written, as if it had come in (a loopback: a test, a host telling itself) +export function net_written() -> NetMessage { + net__init() + let m = new NetMessage + m.from = net__my_pid + m.len = min(net__wl, NET_MTU) + m.data = buffer(m.len) + nb_copy(m.data, 0, net__wb, 0, m.len) + return m +} + +# the message being read, copied out as the next one to write (a host passing it on) +export function net_copy_read() -> void { + nw_begin() + nb_copy(net__wb, 0, net__rb, 0, net__rl) + net__wl = net__rl +} diff --git a/packages/ludic.net/codes.ludic b/packages/ludic.net/codes.ludic new file mode 100644 index 00000000..0e2aed8d --- /dev/null +++ b/packages/ludic.net/codes.ludic @@ -0,0 +1,100 @@ +# codes.ludic - an address and a port as ten letters in two fives, from an alphabet with no 0/O or +# 1/I: what a host reads out. An "a.b.c.d:port" or a bare "a.b.c.d" is taken too; a room code is +# six of the same letters. +const NET_ALPHA: string = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +export function net_code_of(ip: int, port: int) -> string { + let a = NET_ALPHA + let buf = buffer(11) + # 48 bits: the port's 16, then the address's 32, five at a time from the top + var o = 0 + for k in 0 .. 10 { + let shift = 45 - k * 5 + var v = 0 + for b in 0 .. 5 { + let bit = shift + 4 - b + var x = 0 + if bit >= 32 { x = (port >> (bit - 32)) & 1 } else if bit >= 0 { x = (ip >> bit) & 1 } + v = (v << 1) | x + } + buf[o] = a[v] + o += 1 + if k == 4 { + buf[o] = '-' + o += 1 + } + } + return text_of(buf, o) +} + +# this machine's code: its address on the network and the socket's port +export function net_join_code() -> string { + var ip = NetSocket.local_ip() + if ip == 0 { ip = Udp.ip("127.0.0.1") } + return net_code_of(ip, net_port()) +} + +function net__letter(c: int) -> int { + var ch = c + if ch >= 'a' and ch <= 'z' { ch = ch - 32 } + if ch == 'O' { ch = '0' } + if ch == 'I' { ch = '1' } + let a = NET_ALPHA + for i in 0 .. 32 { if a[i] == ch { return i } } + return -1 +} + +# a code or an address: true when net_code_ip() / net_code_port() say where +export function net_parse_code(text: string) -> bool { + let p = text + var colon = -1 + var dots = 0 + for i in 0 .. len(p) { + if p[i] == ':' { colon = i } + if p[i] == '.' { dots += 1 } + } + if dots == 3 { return net__parse_addr(text, colon) } + var hi = 0 + var lo = 0 + var n = 0 + for i in 0 .. len(p) { + let c = p[i] + if c == '-' or c == ' ' { continue } + let v = net__letter(c) + if v < 0 { return false } + # 50 bits arrive; the top two are padding. Keep the port (bits 47..32) and the address. + for b in 0 .. 5 { + let bit = (v >> (4 - b)) & 1 + let pos = 49 - n * 5 - b + if pos >= 32 and pos < 48 { hi = hi | (bit << (pos - 32)) } + if pos >= 0 and pos < 32 { lo = lo | (bit << pos) } + } + n += 1 + } + if n != 10 { return false } + net__code_ip = lo + net__code_port = hi + return net__code_ip != 0 and net__code_port > 0 +} + +function net__parse_addr(text: string, colon: int) -> bool { + var host = text + net__code_port = net__conf().port + if colon > 0 { + host = text[0..colon] + net__code_port = Text.to_int(text[colon + 1..len(text)]) + } + net__code_ip = Udp.ip(host) + return net__code_ip != 0 and net__code_port > 0 and net__code_port < 65536 +} + +export function net_code_ip() -> int { return net__code_ip } +export function net_code_port() -> int { return net__code_port } + +# six of the code's letters: a room at the matchmaker +export function net_is_room(text: string) -> bool { + let p = text + if len(p) != 6 { return false } + for i in 0 .. 6 { if net__letter(p[i]) < 0 { return false } } + return true +} diff --git a/packages/ludic.net/index.ludic b/packages/ludic.net/index.ludic new file mode 100644 index 00000000..419fb1b0 --- /dev/null +++ b/packages/ludic.net/index.ludic @@ -0,0 +1,21 @@ +# ludic.net - the transport a party plays over: what a message says and what each kind means are +# the game's; getting it there, once, in order, through two home routers, is this +module ludic_net uses ludic_base +numbers float +import "ludic.base" +import "state.ludic" +import "ports.ludic" +import "codec.ludic" +import "peers.ludic" +import "send.ludic" +import "receive.ludic" +import "session.ludic" +import "tick.ludic" +import "queries.ludic" +import "codes.ludic" +import "stun.ludic" +import "room.ludic" +import "room_connect.ludic" +import "room_tick.ludic" +import "punch.ludic" +import "relay.ludic" diff --git a/packages/ludic.net/package.ludic b/packages/ludic.net/package.ludic new file mode 100644 index 00000000..901a8ece --- /dev/null +++ b/packages/ludic.net/package.ludic @@ -0,0 +1,5 @@ +# ludic.net - a small party over UDP: peers, packets, reliable ordered delivery, the handshake's +# frame, join codes, room codes through a matchmaker with STUN, punching and a relay. See README.md. +package "ludic.net" +version "0.1.0" +kind source diff --git a/packages/ludic.net/peers.ludic b/packages/ludic.net/peers.ludic new file mode 100644 index 00000000..8706d345 --- /dev/null +++ b/packages/ludic.net/peers.ludic @@ -0,0 +1,110 @@ +# peers.ludic - who this machine talks to, and the written message queued for them +function net__find(ip: int, port: int) -> int { + for i in 0 .. NET_PEERS { + let p = net__peers[i] + if p.on and p.ip == ip and p.port == port { return i } + } + return -1 +} + +# the slot talking to network player `pid`, or -1 +export function np_of_pid(pid: int) -> int { + if net__peers == null { return -1 } + for i in 0 .. NET_PEERS { + let p = net__peers[i] + if p.on and p.pid == pid { return i } + } + return -1 +} + +function net__open(slot: int, ip: int, port: int, pid: int) -> void { + let p = net__peers[slot] + p.on = true + p.ip = ip + p.port = port + p.pid = pid + p.heard = net__time + p.next_id = 1 + p.recv_id = 0 + p.acked = 0 + p.ack_due = false + p.queue = new []NetMsg + p.closing = false +} + +export function np_close(slot: int) -> void { + if net__peers == null or slot < 0 or slot >= NET_PEERS { return } + let p = net__peers[slot] + p.on = false + p.closing = false + p.queue = new []NetMsg +} + +# the written message, for one peer (a reliable one is kept until it is taken) +export function np_queue(slot: int, kind: int, rel: bool) -> void { + if net__peers == null or slot < 0 or slot >= NET_PEERS { return } + let p = net__peers[slot] + if not p.on { return } + let m = new NetMsg + m.kind = kind + m.rel = rel + m.sent = -1.0 + m.made = net__time + var n = net__wl + if n > NET_MTU - 40 { n = NET_MTU - 40 } + m.len = n + m.data = buffer(n) + nb_copy(m.data, 0, net__wb, 0, n) + if rel { + m.id = p.next_id + p.next_id += 1 + } + push(p.queue, m) +} + +# to everyone this machine talks to (a host: every guest but `except`; a guest: the host) +export function np_queue_all(kind: int, rel: bool, except: int) -> void { + if net__peers == null { return } + for i in 0 .. NET_PEERS { if net__peers[i].on and i != except { np_queue(i, kind, rel) } } +} + +export function net_peer_on(slot: int) -> bool { + if net__peers == null or slot < 0 or slot >= NET_PEERS { return false } + return net__peers[slot].on +} + +export function net_peer_pid(slot: int) -> int { + if not net_peer_on(slot) { return 0 } + return net__peers[slot].pid +} + +export function net_peer_ip(slot: int) -> int { + if not net_peer_on(slot) { return 0 } + return net__peers[slot].ip +} + +export function net_peer_port(slot: int) -> int { + if not net_peer_on(slot) { return 0 } + return net__peers[slot].port +} + +export function net_peer_heard(slot: int) -> float { + if not net_peer_on(slot) { return 0.0 } + return net__peers[slot].heard +} + +# let the slot go once it has taken everything queued for it so far, or after `secs` +export function net_close_when_sent(slot: int, secs: float) -> void { + if not net_peer_on(slot) { return } + let p = net__peers[slot] + p.closing = true + p.close_id = p.next_id - 1 + p.close_t = net__time + secs +} + +# the slots in use (a host's count of guests, a welcome not yet sent included) +export function net_peer_count() -> int { + var n = 0 + for i in 0 .. NET_PEERS { if net_peer_on(i) { n += 1 } } + return n +} diff --git a/packages/ludic.net/ports.ludic b/packages/ludic.net/ports.ludic new file mode 100644 index 00000000..742522fd --- /dev/null +++ b/packages/ludic.net/ports.ludic @@ -0,0 +1,96 @@ +# ports.ludic - the game's config, the socket and log it lends, and the facts it drains +export property NetConfig { + proto: int = 1 # a packet's protocol byte; two builds that differ here ignore each other + port: int = 27415 # what an address typed without one means + signal_url: string = "" # the matchmaker (HTTP); "" means room codes are off + stun_host: string = "" # STUN and the relay (UDP), by name + stun_port: int = 27400 + stun_at: string = "" # "ip:port" instead of the name (a test server) + no_stun: bool = false # a test on one machine has no outside to ask about + force_relay: bool = false # a room join goes through the relay at once + relay_port0: int = 60000 # a relayed guest, as the host sees it: the relay's address, this + its session + seed: int = 1 # the STUN transaction's dice +} + +# what the game hears about: a guest gone silent (slot, pid), a slot let go once it had taken +# its last message (slot, pid), this machine out of the session (why), a room code (text) or a +# matchmaker that failed a host (why) +export const NET_GONE: int = 0 +export const NET_CLOSED: int = 1 +export const NET_DOWN: int = 2 +export const NET_ROOM: int = 3 +export const NET_ROOM_FAILED: int = 4 + +export const NET_WHY_GAME: int = 0 # the game's own words, set before net_lose +export const NET_WHY_HOST_SILENT: int = 1 +export const NET_WHY_NO_ANSWER: int = 2 # a hello nobody answered +export const NET_WHY_NO_SOCKET: int = 3 +export const NET_WHY_PORT_TAKEN: int = 4 +export const NET_WHY_NO_MATCHMAKER: int = 5 +export const NET_WHY_MATCHMAKER_SILENT: int = 6 +export const NET_WHY_NO_ROOM_GIVEN: int = 7 +export const NET_WHY_NO_ROOM: int = 8 +export const NET_WHY_BAD_ADDRESS: int = 9 + +export property NetFact { + what: int = 0, + slot: int = -1, + pid: int = 0, + why: int = 0, + text: string = "" +} + +# how the game hears the transport talk (unbound: silence) and whether to log every packet +export port NetWorld { + log: fn(string) -> void = fn net__no_log + trace: fn() -> bool = fn net__no +} + +# the socket (unbound: Udp); a test lends a fake one +export port NetSocket { + open: fn(int) -> int = fn net__udp_open + close: fn(int) -> void = fn net__udp_close + send: fn(int, int, int, []byte, int) -> int = fn net__udp_send + recv: fn(int, []byte, int) -> int = fn net__udp_recv + from_ip: fn(int) -> int = fn net__udp_from_ip + from_port: fn(int) -> int = fn net__udp_from_port + port: fn(int) -> int = fn net__udp_port + local_ip: fn() -> int = fn net__udp_local_ip +} + +var net__cfg: NetConfig = null +var net__facts: Queue = null + +export function net_config(c: NetConfig) -> void { net__cfg = c } + +function net__conf() -> NetConfig { + if net__cfg == null { net__cfg = new NetConfig } + return net__cfg +} + +export function net_facts() -> Queue { + if net__facts == null { net__facts = queue_new("net.facts") } + return net__facts +} + +function net__fact(what: int, slot: int, pid: int, why: int, text: string) -> void { + let f = new NetFact + f.what = what + f.slot = slot + f.pid = pid + f.why = why + f.text = text + q_push(net_facts(), f) +} + +function net__no_log(s: string) -> void { } +function net__no() -> bool { return false } +function net__log(s: string) -> void { NetWorld.log(s) } +function net__udp_open(port: int) -> int { return Udp.open(port) } +function net__udp_close(h: int) -> void { Udp.close(h) } +function net__udp_send(h: int, ip: int, port: int, b: []byte, n: int) -> int { return Udp.send(h, ip, port, b, n) } +function net__udp_recv(h: int, b: []byte, cap: int) -> int { return Udp.recv(h, b, cap) } +function net__udp_from_ip(h: int) -> int { return Udp.from_ip(h) } +function net__udp_from_port(h: int) -> int { return Udp.from_port(h) } +function net__udp_port(h: int) -> int { return Udp.port(h) } +function net__udp_local_ip() -> int { return Udp.local_ip() } diff --git a/packages/ludic.net/punch.ludic b/packages/ludic.net/punch.ludic new file mode 100644 index 00000000..91811b69 --- /dev/null +++ b/packages/ludic.net/punch.ludic @@ -0,0 +1,57 @@ +# punch.ludic - the host sends a few packets to each new guest's outside address, so its router +# expects the guest, while the guest's hello goes the other way: both routers have seen traffic +# go out, so the replies come in +var net__seen: []string = null # guests already punched for +var net__punch_ip: words = null +var net__punch_port: words = null +var net__punch_left: words = null +var net__punch_t: float = 0.0 +var net__punch_b: []byte = null # the punch itself, "MP" and two zeros, made once + +function net__new_guest(pub: string, local: string) -> void { + let key = `{pub}|{local}` + if net__seen == null { net__seen = new []string } + for i in 0 .. len(net__seen) { if net__seen[i] == key { return } } + push(net__seen, key) + net__log(`room {net__room}: a guest at {pub} / {local}`) + if len(pub) > 0 and net_parse_code(pub) { net__punch_add(net__code_ip, net__code_port) } + if net_parse_code(local) { net__punch_add(net__code_ip, net__code_port) } +} + +function net__punch_add(ip: int, port: int) -> void { + if net__punch_ip == null { + net__punch_ip = words(16) + net__punch_port = words(16) + net__punch_left = words(16) + for k in 0 .. 16 { net__punch_left[k] = 0 } + } + for k in 0 .. 16 { + if net__punch_left[k] > 0 { continue } + net__punch_ip[k] = ip + net__punch_port[k] = port + net__punch_left[k] = 10 + return + } +} + +# twice a second while a guest is new: a packet the guest's side ignores, which is the point +function net__punch_tick(dt: float) -> void { + if net__punch_ip == null { return } + net__punch_t = net__punch_t - dt + if net__punch_t > 0.0 { return } + net__punch_t = 0.5 + if net__punch_b == null { + net__punch_b = buffer(4) + nb_put8(net__punch_b, 0, 'M') + nb_put8(net__punch_b, 1, 'P') + } + for k in 0 .. 16 { + if net__punch_left[k] <= 0 { continue } + NetSocket.send(net__sock, net__punch_ip[k], net__punch_port[k], net__punch_b, 4) + net__punch_left[k] -= 1 + } +} + +function net__punch_clear() -> void { + if net__punch_left != null { for k in 0 .. 16 { net__punch_left[k] = 0 } } +} diff --git a/packages/ludic.net/queries.ludic b/packages/ludic.net/queries.ludic new file mode 100644 index 00000000..d075114d --- /dev/null +++ b/packages/ludic.net/queries.ludic @@ -0,0 +1,17 @@ +# queries.ludic - what the game asks of the session +export function net_state() -> int { return net__state } +export function net_live() -> bool { return net__state == NET_HOSTING or net__state == NET_JOINED } +export function net_active() -> bool { return net__state != NET_IDLE and net__state != NET_LOST } +export function net_hosting() -> bool { return net__state == NET_HOSTING } +export function net_joined() -> bool { return net__state == NET_JOINED } +export function net_joining() -> bool { return net__state == NET_JOINING } +export function net_my_pid() -> int { return net__my_pid } +export function net_token() -> int { return net__token } +export function net_time() -> float { return net__time } +export function net_why() -> int { return net__why } +export function net_join_ip() -> int { return net__join_ip } +export function net_join_port() -> int { return net__join_port } +export function net_port() -> int { + if net__sock <= 0 { return 0 } + return NetSocket.port(net__sock) +} diff --git a/packages/ludic.net/receive.ludic b/packages/ludic.net/receive.ludic new file mode 100644 index 00000000..3d4c16d9 --- /dev/null +++ b/packages/ludic.net/receive.ludic @@ -0,0 +1,91 @@ +# receive.ludic - what arrived, unwrapped from the relay, checked, acknowledged and put in order +# on the inbox. A reliable message is taken strictly in order; one that arrives early is dropped +# and comes again. +function net__pump() -> void { + if net__sock <= 0 { return } + for guard in 0 .. 200 { + let n = NetSocket.recv(net__sock, net__in, NET_MTU + 64) + if n <= 0 { return } + let n2 = net__unwrap(n, NetSocket.from_ip(net__sock), NetSocket.from_port(net__sock)) + if n2 > 0 { net__packet(n2, net__from_ip, net__from_port) } + } +} + +function net__packet(n: int, ip: int, port: int) -> void { + if NetWorld.trace() { net__log(`in {n} bytes from {Udp.ip_text(ip)}:{port}`) } + if n < 20 { return } + if net__stun_packet(n) { return } # what the router looks like from outside + if nb_get8(net__in, 0) != 'M' or nb_get8(net__in, 1) != 'L' { return } # a punch ("MP") or noise + if nb_get8(net__in, 2) != net__conf().proto { return } + let slot = net__slot_for(n, ip, port) + if slot < 0 { return } + let p = net__peers[slot] + p.heard = net__time + # only what this connection has sent can be acknowledged: a packet from the one before it + # (a re-join) carries the old numbers and would drop the new hello as already taken + let ack = nb_get32(net__in, 12) + if ack > p.acked and ack < p.next_id { p.acked = ack } + net__messages(slot, n) +} + +# the known peer at that address, a stranger's new slot (a host, and only with a hello), or -1 +function net__slot_for(n: int, ip: int, port: int) -> int { + let token = nb_get32(net__in, 4) + var slot = net__find(ip, port) + if slot >= 0 { + if token != net__token and net__state != NET_JOINING and net__peers[slot].pid != 0 { return -1 } + return slot + } + if net__state != NET_HOSTING { return -1 } + if n < 28 or nb_get8(net__in, 20) != NET_HELLO { return -1 } + for i in 1 .. NET_PEERS { + if not net__peers[i].on { + net__open(i, ip, port, 0) + return i + } + } + return -1 +} + +function net__messages(slot: int, n: int) -> void { + let p = net__peers[slot] + let from = nb_get32(net__in, 8) + let count = nb_get32(net__in, 16) + var o = 20 + for c in 0 .. count { + if o + 4 > n { return } + let kind = nb_get8(net__in, o) + let rel = nb_get8(net__in, o + 1) == 1 + o += 2 + var id = 0 + if rel { + if o + 4 > n { return } + id = nb_get32(net__in, o) + o += 4 + } + let ml = nb_get8(net__in, o) | (nb_get8(net__in, o + 1) << 8) + o += 2 + if ml < 0 or o + ml > n { return } + var take = true + if rel { + p.ack_due = true + if id != p.recv_id + 1 { take = false } else { p.recv_id = id } + } + if take { net__deliver(slot, from, kind, o, ml) } + o += ml + } +} + +# onto the inbox - except a heartbeat, and on a host anything but a hello before its welcome +function net__deliver(slot: int, from: int, kind: int, o: int, ml: int) -> void { + if kind == NET_BEAT { return } + if net__state == NET_HOSTING and net__peers[slot].pid < 2 and kind != NET_HELLO { return } + let m = new NetMessage + m.slot = slot + m.from = from + m.kind = kind + m.len = ml + m.data = buffer(ml) + nb_copy(m.data, 0, net__in, o, ml) + q_push(net_inbox(), m) +} diff --git a/packages/ludic.net/relay.ludic b/packages/ludic.net/relay.ludic new file mode 100644 index 00000000..9cceee6d --- /dev/null +++ b/packages/ludic.net/relay.ludic @@ -0,0 +1,89 @@ +# relay.ludic - a router that maps every destination to a new port defeats punching, so a guest +# the host does not answer goes through the party server: "MLRG" + room + packet out, "MLRS" + +# session + packet back. The host keeps its address there fresh ("MLRH" + room) and sees each +# relayed guest as the relay's address with a port of relay_port0 + its session. +var net__relay_ip: int = 0 +var net__relay_port: int = 0 +var net__via_relay: bool = false # a guest talking to its host through the relay +var net__from_ip: int = 0 +var net__from_port: int = 0 + +function net__take_relay(v: Val) -> void { + let r = net__json_str(v, "relay") + if len(r) > 0 and net_parse_code(r) { + net__relay_ip = net__code_ip + net__relay_port = net__code_port + } +} + +function net__code_bytes(b: []byte, o: int) -> void { + let cp = net__room + for k in 0 .. 6 { + var c = 0 + if k < len(cp) { c = cp[k] } + nb_put8(b, o + k, c) + } +} + +function net__relay_hello() -> void { + let b = buffer(10) + nb_put8(b, 0, 'M') + nb_put8(b, 1, 'L') + nb_put8(b, 2, 'R') + nb_put8(b, 3, 'H') + net__code_bytes(b, 4) + NetSocket.send(net__sock, net__relay_ip, net__relay_port, b, 10) +} + +function net__use_relay() -> void { + net__via_relay = true + net__log(`room {net__room}: no answer directly, through the relay`) + net_dial(net__relay_ip, net__relay_port) +} + +export function net_via_relay() -> bool { return net__via_relay } + +function net__relayed(p: NetPeer) -> bool { + return net__relay_ip != 0 and p.ip == net__relay_ip and (p.port >= net__conf().relay_port0 or net__via_relay) +} + +# a packet of `o` bytes in net__out, to peer p: straight, or wrapped for the relay +function net__relay_send(p: NetPeer, o: int) -> int { + if not net__relayed(p) { return NetSocket.send(net__sock, p.ip, p.port, net__out, o) } + var hdr = 10 + if not net__via_relay { hdr = 6 } + var k = o - 1 + while k >= 0 { + net__out[k + hdr] = net__out[k] + k -= 1 + } + nb_put8(net__out, 0, 'M') + nb_put8(net__out, 1, 'L') + nb_put8(net__out, 2, 'R') + if net__via_relay { + nb_put8(net__out, 3, 'G') + net__code_bytes(net__out, 4) + } else { + let sid = p.port - net__conf().relay_port0 + nb_put8(net__out, 3, 'S') + nb_put8(net__out, 4, (sid >> 8) & 255) + nb_put8(net__out, 5, sid & 255) + } + let sent = NetSocket.send(net__sock, net__relay_ip, net__relay_port, net__out, o + hdr) + nb_copy(net__out, 0, net__out, hdr, o) # the packet back where it was + return sent +} + +# a relayed packet is unwrapped in place and given the sender it stands for; the game packet's +# length comes back, 0 when there is nothing for the game +function net__unwrap(n: int, ip: int, port: int) -> int { + net__from_ip = ip + net__from_port = port + if n < 6 or net__relay_ip == 0 or ip != net__relay_ip or port != net__relay_port { return n } + if nb_get8(net__in, 0) != 'M' or nb_get8(net__in, 1) != 'L' or nb_get8(net__in, 2) != 'R' or nb_get8(net__in, 3) != 'S' { return n } + let sid = (nb_get8(net__in, 4) << 8) | nb_get8(net__in, 5) + nb_copy(net__in, 0, net__in, 6, n - 6) + # the host: this guest's session; a guest: the relay itself is its host + if sid > 0 { net__from_port = net__conf().relay_port0 + sid } + return n - 6 +} diff --git a/packages/ludic.net/room.ludic b/packages/ludic.net/room.ludic new file mode 100644 index 00000000..31fb2099 --- /dev/null +++ b/packages/ludic.net/room.ludic @@ -0,0 +1,83 @@ +# room.ludic - a party across two home routers. The host registers its outside and inside +# addresses with the matchmaker and is given a six-letter room code; a guest hands over the code +# and its own addresses and gets the host's back. Behind one router, the inside addresses. +const NET_R_IDLE: int = 0 +const NET_R_STUN: int = 1 # asking what this machine looks like from outside +const NET_R_REGISTER: int = 2 # the host: asking for a room +const NET_R_ROOM: int = 3 # the host: registered, watching for guests +const NET_R_ASK: int = 4 # a guest: asking the room for the host +const NET_R_DONE: int = 5 # a guest: the hello is on its way + +var net__r_state: int = 0 +var net__r_role: int = 0 # 1 host, 2 guest +var net__r_t: float = 0.0 +var net__r_tries: int = 0 +var net__pub_ip: int = 0 +var net__pub_port: int = 0 +var net__room: string = "" # the host's room code, or the one a guest asked for +var net__http: int = 0 +var net__poll_t: float = 0.0 +var net__relay_t: float = 0.0 +var net__dial_heard: float = 0.0 + +# room codes are on when the game named a matchmaker +export function net_room_on() -> bool { return len(net__conf().signal_url) > 0 } +export function net_room() -> string { return net__room } +export function net_room_asking() -> bool { return net__r_state == NET_R_STUN or net__r_state == NET_R_ASK } + +# the host (after net_host or net_become_host): a room at the matchmaker, if there is one +export function net_room_host() -> void { + if not net_room_on() { return } + net__r_role = 1 + net__room = "" + net__seen = new []string + net__stun_begin() +} + +# a guest by room code: the matchmaker's answer brings the host's address (the hello is set first) +export function net_room_join(code: string) -> bool { + if not net_room_on() { + net__why = NET_WHY_NO_MATCHMAKER + return false + } + net__init() + if not net__socket() { return false } + net__r_role = 2 + net__room = Text.upper(code) + net__wait_more = 20 # the matchmaker answers before the host does + net__wait_for_host() + net__stun_begin() + return true +} + +function net__stun_begin() -> void { + net__r_state = NET_R_STUN + net__r_t = 0.0 + net__r_tries = 0 + net__pub_ip = 0 + net__pub_port = 0 + if net__conf().no_stun { + net__r_tries = 3 + return + } + net__stun_send() +} + +function net__room_body(role_key: string) -> string { + var pub = "" + if net__pub_ip != 0 { pub = net__addr_text(net__pub_ip, net__pub_port) } + return "{\"" + role_key + "\": \"" + pub + "\", \"local\": \"" + net__local_text() + "\"}" +} + +function net__json_str(v: Val, key: string) -> string { + if v == null or value_has(v, key) == 0 { return "" } + return value_as_str(value_get(v, key)) +} + +function net__addr_text(ip: int, port: int) -> string { return `{Udp.ip_text(ip)}:{port}` } + +function net__local_text() -> string { + var ip = NetSocket.local_ip() + if ip == 0 { ip = Udp.ip("127.0.0.1") } + return net__addr_text(ip, net_port()) +} diff --git a/packages/ludic.net/room_connect.ludic b/packages/ludic.net/room_connect.ludic new file mode 100644 index 00000000..1be28dc9 --- /dev/null +++ b/packages/ludic.net/room_connect.ludic @@ -0,0 +1,47 @@ +# room_connect.ludic - the matchmaker's answer: a guest dials the host it named (or is out), +# and leaving lets the room go +# the matchmaker let this machine down: a guest is out, a host keeps its code-less party +function net__room_fail(why: int) -> void { + net__log(`matchmaker: {why}`) + let was_guest = net__r_role == 2 + let room = net__room + net__r_state = NET_R_IDLE + net__r_role = 0 + if was_guest { net__lose_with(why, room) } else { net__fact(NET_ROOM_FAILED, -1, 0, why, room) } +} + +# the guest: to the host's outside address, or its inside one behind the same router +function net__room_connect(host_pub: string, host_local: string) -> void { + var target = host_pub + var same_router = false + if len(host_pub) > 0 and net__pub_ip != 0 and net_parse_code(host_pub) and net__code_ip == net__pub_ip { same_router = true } + if len(host_pub) == 0 or same_router { target = host_local } + if not net_parse_code(target) { + net__room_fail(NET_WHY_BAD_ADDRESS) + return + } + net__r_state = NET_R_DONE + net__r_t = 0.0 + net__state = NET_IDLE # net_join wants a fresh start + net_join(net__code_ip, net__code_port) + net__dial_heard = net__peers[0].heard + if net__conf().force_relay and net__relay_ip != 0 { # a test of the relay alone + net__use_relay() + return + } + net__log(`room {net__room}: dialling {target}`) +} + +function net__room_reset() -> void { + if net__http != 0 { + Http.free(net__http) + net__http = 0 + } + net__r_state = NET_R_IDLE + net__r_role = 0 + net__room = "" + net__via_relay = false + net__relay_ip = 0 + net__relay_port = 0 + net__punch_clear() +} diff --git a/packages/ludic.net/room_tick.ludic b/packages/ludic.net/room_tick.ludic new file mode 100644 index 00000000..1e5708e2 --- /dev/null +++ b/packages/ludic.net/room_tick.ludic @@ -0,0 +1,106 @@ +# room_tick.ludic - the matchmaker's side of a frame: STUN asked three times, the room registered +# or asked for, the host's look at who has asked, and a guest's switch to the relay +function net__room_tick(dt: float) -> void { + if net__r_state == NET_R_IDLE or net__r_role == 0 { return } + net__r_t = net__r_t + dt + if net__r_state == NET_R_STUN { + net__room_stun_tick() + return + } + if net__r_state == NET_R_REGISTER or net__r_state == NET_R_ASK or (net__r_state == NET_R_ROOM and net__http != 0) { + net__room_answer() + return + } + if net__r_state == NET_R_DONE { + # four seconds and not one packet back from the host: through the relay instead (a slow + # welcome is not a closed router - anything heard means the direct path works) + let silent = net__peers[0].heard == net__dial_heard + let wait = net__conf().force_relay or (silent and net__r_t > 4.0) + if not net__via_relay and net__state == NET_JOINING and net__relay_ip != 0 and wait { net__use_relay() } + return + } + if net__r_state == NET_R_ROOM { net__room_watch(dt) } +} + +# a second and a half for an answer, asked three times; without one, inside addresses only +function net__room_stun_tick() -> void { + if net__pub_ip == 0 and net__r_t > 0.5 and net__r_tries < 3 { + net__r_tries += 1 + net__r_t = 0.0 + net__stun_send() + return + } + if net__pub_ip == 0 and net__r_tries < 3 { return } + let base = net__conf().signal_url + if net__r_role == 1 { + net__http = Http.post(`{base}/rooms`, net__room_body("host")) + net__r_state = NET_R_REGISTER + } else { + net__http = Http.post(`{base}/rooms/{net__room}/join`, net__room_body("pub")) + net__r_state = NET_R_ASK + } + net__r_t = 0.0 +} + +function net__room_answer() -> void { + let st = Http.poll(net__http) + if st < 0 { + if net__r_t > 12.0 { + Http.free(net__http) + net__http = 0 + net__room_fail(NET_WHY_MATCHMAKER_SILENT) + } + return + } + var v: Val = null + if st > 0 and Http.ok(net__http) { v = Json.parse(Http.text(net__http)) } + Http.free(net__http) + net__http = 0 + if net__r_state == NET_R_REGISTER { + let code = net__json_str(v, "code") + if len(code) == 0 { + net__room_fail(NET_WHY_NO_ROOM_GIVEN) + return + } + net__room = code + net__take_relay(v) + net__r_state = NET_R_ROOM + net__poll_t = 0.0 + net__log(`room {code}`) + net__fact(NET_ROOM, -1, 0, 0, code) + return + } + if net__r_state == NET_R_ASK { + if v == null { + net__room_fail(NET_WHY_NO_ROOM) + return + } + net__take_relay(v) + net__room_connect(net__json_str(v, "host"), net__json_str(v, "local")) + return + } + # the host's look at the room + if v != null and value_has(v, "guests") != 0 { + let l = value_get(v, "guests") + for i in 0 .. value_count(l) { + let g = value_at(l, i) + net__new_guest(net__json_str(g, "pub"), net__json_str(g, "local")) + } + } +} + +# the host, registered: its relay address kept fresh, new guests punched for, the room looked at +function net__room_watch(dt: float) -> void { + net__relay_t = net__relay_t - dt + if net__relay_t < 0.0 and net__relay_ip != 0 { + net__relay_t = 3.0 + net__relay_hello() + } + net__punch_tick(dt) + net__poll_t = net__poll_t - dt + if net__poll_t < 0.0 { + net__poll_t = 1.0 + net__http = Http.get(`{net__conf().signal_url}/rooms/{net__room}`) + net__r_t = 0.0 + } +} diff --git a/packages/ludic.net/send.ludic b/packages/ludic.net/send.ludic new file mode 100644 index 00000000..336b1817 --- /dev/null +++ b/packages/ludic.net/send.ludic @@ -0,0 +1,81 @@ +# send.ludic - everything due goes out now, in as many packets as it takes (up to eight a call). +# A packet is a 20-byte header - "ML", the protocol, 1, the session token, the sender's pid, the +# highest reliable id taken in order, a message count - then each message: kind, flags, a +# reliable id when it is reliable, a 16-bit length, the payload. A reliable message goes again +# every quarter second until acknowledged; an unreliable one half a second old is dropped. +export function np_flush(slot: int) -> void { + if not net_peer_on(slot) { return } + let p = net__peers[slot] + for pk in 0 .. 8 { + if not net__flush_one(p) { return } + } +} + +function net__flush_one(p: NetPeer) -> bool { + var o = 20 + var count = 0 + let keep = new []NetMsg + for i in 0 .. len(p.queue) { + let m = p.queue[i] + if m.rel and m.id <= p.acked { continue } # taken: drop it + if not m.rel and net__time - m.made > 0.5 { continue } # stale + var due = true + if m.rel and not (m.sent < 0.0) and net__time - m.sent < 0.25 { due = false } + var size = 4 + m.len + if m.rel { size += 4 } + if due and o + size <= NET_MTU and count < 250 { + o = net__put_msg(o, m) + count += 1 + m.sent = net__time + if net__kind_out != null and m.kind < 64 { net__kind_out[m.kind] += size } + if m.rel { push(keep, m) } # kept until acknowledged + } else { + push(keep, m) # not due, or waits for the next packet + } + } + p.queue = keep + if count == 0 and not p.ack_due { return false } + nb_put8(net__out, 0, 'M') + nb_put8(net__out, 1, 'L') + nb_put8(net__out, 2, net__conf().proto) + nb_put8(net__out, 3, 1) + nb_put32(net__out, 4, net__token) + nb_put32(net__out, 8, net__my_pid) + nb_put32(net__out, 12, p.recv_id) + nb_put32(net__out, 16, count) + let sent = net__relay_send(p, o) + if sent > 0 { net__bytes_out += sent + 28 } # with the IP and UDP headers: what the line carries + if NetWorld.trace() { net__log(`out {o} bytes to {Udp.ip_text(p.ip)}:{p.port}: {sent}`) } + p.ack_due = false + return count > 0 +} + +function net__put_msg(at: int, m: NetMsg) -> int { + var o = at + nb_put8(net__out, o, m.kind) + nb_put8(net__out, o + 1, 0) + if m.rel { nb_put8(net__out, o + 1, 1) } + o += 2 + if m.rel { + nb_put32(net__out, o, m.id) + o += 4 + } + nb_put8(net__out, o, m.len & 255) + nb_put8(net__out, o + 1, (m.len >> 8) & 255) + o += 2 + nb_copy(net__out, o, m.data, 0, m.len) + return o + m.len +} + +# the per-kind byte count starts again (a game measuring what a party costs its upload) +export function net_kind_out_clear() -> void { + net__kind_out = words(64) + for k in 0 .. 64 { net__kind_out[k] = 0 } +} + +export function net_kind_bytes(kind: int) -> int { + if net__kind_out == null or kind < 0 or kind >= 64 { return 0 } + return net__kind_out[kind] +} + +export function net_bytes_out() -> int { return net__bytes_out } diff --git a/packages/ludic.net/session.ludic b/packages/ludic.net/session.ludic new file mode 100644 index 00000000..d0ba1996 --- /dev/null +++ b/packages/ludic.net/session.ludic @@ -0,0 +1,106 @@ +# session.ludic - hosting, joining and leaving. The host is pid 1 and hands out 2 upwards; this +# machine's pid is what its packets say. A token picked by the host names the session. +var net__why: int = 0 + +# open a socket on `port` and take guests; false (net_why) when the port is taken +export function net_host(port: int, token: int) -> bool { + net__init() + if net_live() { return true } + net__sock = NetSocket.open(port) + if net__sock <= 0 { + net__sock = 0 + net__why = NET_WHY_PORT_TAKEN + return false + } + for i in 0 .. NET_PEERS { np_close(i) } + net_become_host(token) + net__log(`hosting on port {NetSocket.port(net__sock)}`) + net_room_host() + return true +} + +# dial a host at an address; the hello (net_hello_set) goes until a welcome or a refusal +export function net_join(ip: int, port: int) -> bool { + net__init() + if net_live() { return false } + if not net__socket() { return false } + for i in 0 .. NET_PEERS { np_close(i) } + net__token = 0 + net__my_pid = 0 + net__state = NET_JOINING + net_dial(ip, port) + net__log(`joining {Udp.ip_text(ip)}:{port}`) + return true +} + +function net__socket() -> bool { + if net__sock <= 0 { net__sock = NetSocket.open(0) } + if net__sock > 0 { return true } + net__sock = 0 + net__why = NET_WHY_NO_SOCKET + return false +} + +# the message just written is the hello: what a host is asked to let this machine in with +export function net_hello_set() -> void { + net__init() + nb_copy(net__hello_b, 0, net__wb, 0, net__wl) + net__hello_l = net__wl + if net__hello_l > NET_MTU - 40 { net__hello_l = NET_MTU - 40 } +} + +# (again) to that address, with a fresh connection and the hello sent anew +export function net_dial(ip: int, port: int) -> void { + net__init() + np_close(0) + net__join_ip = ip + net__join_port = port + net__open(0, ip, port, 1) + net__hello_t = -1.0 +} + +# a host that set out on a new trip, or a new host: the same socket asks to be let in there +export function net_follow(ip: int, port: int) -> void { + net_dial(ip, port) + net__token = 0 + net__my_pid = 0 + net__state = NET_JOINING +} + +# the old host is gone and this machine keeps the socket, alone (it may become the host) +export function net_stand_alone() -> void { net__state = NET_IDLE } + +export function net_become_host(token: int) -> void { + net__init() + net__state = NET_HOSTING + net__my_pid = 1 + net__next_pid = 2 + net__token = token +} + +# a welcome: this machine is `pid` in the session `token` +export function net_joined_as(token: int, pid: int) -> void { + net__token = token + net__my_pid = pid + net__state = NET_JOINED + net__wait_more = 0 +} + +# the host lets the stranger in `slot` in: its pid, from 2 upwards +export function net_accept(slot: int) -> int { + if net__state != NET_HOSTING or not net_peer_on(slot) { return 0 } + let p = net__peers[slot] + if p.pid >= 2 { return p.pid } + p.pid = net__next_pid + net__next_pid += 1 + return p.pid +} + +# a slow host is given this many more seconds to answer (a map switch, a matchmaker) +export function net_hello_wait(secs: int) -> void { net__wait_more = secs } + +# a room code: joining, with no address yet - the matchmaker's answer brings one +function net__wait_for_host() -> void { + net__state = NET_JOINING + net__hello_t = 0.0 +} diff --git a/packages/ludic.net/state.ludic b/packages/ludic.net/state.ludic new file mode 100644 index 00000000..c03228be --- /dev/null +++ b/packages/ludic.net/state.ludic @@ -0,0 +1,97 @@ +# state.ludic - the wire's numbers, a peer, a queued message, what came in, and the session +export const NET_MTU: int = 1200 +export const NET_PEERS: int = 12 # a host: slot 0 unused, a guest each after; a guest: 0 is the host +export const NET_TIMEOUT: int = 10 # seconds of silence before a peer is gone +export const NET_HELLO: int = 1 # the one kind a stranger may send a host; every other is the game's +const NET_BEAT: int = 0 # a heartbeat, never delivered + +export const NET_IDLE: int = 0 +export const NET_HOSTING: int = 1 +export const NET_JOINING: int = 2 +export const NET_JOINED: int = 3 +export const NET_LOST: int = 4 + +# a message on its way out, kept until acknowledged when it is reliable +property NetMsg { + kind: int = 0, + rel: bool = false, + id: int = 0, + sent: float = 0.0, # net seconds it was last sent; -1 never + len: int = 0, + made: float = 0.0, # net seconds it was queued + data: []byte +} + +property NetPeer { + on: bool = false, + ip: int = 0, + port: int = 0, + pid: int = 0, + heard: float = 0.0, # net seconds of the last packet from it + next_id: int = 1, # the next reliable id to send it + recv_id: int = 0, # the highest reliable id taken from it, in order + acked: int = 0, # the highest id it has taken from us + ack_due: bool = false, + queue: []NetMsg, + closing: bool = false, # let go once it has taken close_id + close_id: int = 0, + close_t: float = 0.0 # ... or at this net time, whichever comes first +} + +# a message that came in, in order: read it with net_read(m) and nr_* +export property NetMessage { + slot: int = 0, # the peer slot it came from + from: int = 0, # the sender's pid, as its header says + kind: int = 0, + len: int = 0, + data: []byte +} + +var net__state: int = 0 +var net__sock: int = 0 +var net__time: float = 0.0 +var net__token: int = 0 +var net__my_pid: int = 0 +var net__next_pid: int = 2 +var net__peers: []NetPeer = null +var net__in: []byte = null +var net__out: []byte = null +var net__wb: []byte = null # the message being written +var net__wl: int = 0 +var net__rb: []byte = null # the message being read +var net__rl: int = 0 +var net__rp: int = 0 +var net__rbad: bool = false +var net__tb: []byte = null # text read out of a message, through one scratch buffer +var net__hello_b: []byte = null # the game's hello, sent again on every dial +var net__hello_l: int = 0 +var net__hello_t: float = 0.0 # seconds since it went; -1 not yet +var net__wait_more: int = 0 # extra seconds a slow host is given (a map switch, a matchmaker) +var net__beat_t: float = 0.0 +var net__join_ip: int = 0 +var net__join_port: int = 0 +var net__code_ip: int = 0 +var net__code_port: int = 0 +var net__bytes_out: int = 0 +var net__kind_out: words = null +var net__inbox: Queue = null + +export function net_inbox() -> Queue { + if net__inbox == null { net__inbox = queue_new("net.inbox") } + return net__inbox +} + +function net__init() -> void { + if net__peers != null { return } + net__peers = new []NetPeer + for i in 0 .. NET_PEERS { + let p = new NetPeer + p.queue = new []NetMsg + push(net__peers, p) + } + net__in = buffer(NET_MTU + 64) + net__out = buffer(NET_MTU + 64) + net__wb = buffer(NET_MTU) + net__rb = buffer(NET_MTU) + net__hello_b = buffer(NET_MTU) +} diff --git a/packages/ludic.net/stun.ludic b/packages/ludic.net/stun.ludic new file mode 100644 index 00000000..870376de --- /dev/null +++ b/packages/ludic.net/stun.ludic @@ -0,0 +1,68 @@ +# stun.ludic - from the party's own socket, ask a public STUN server what this machine looks like +# from outside: the router's address and the port it mapped +var net__stun_ip: int = 0 +var net__stun_port: int = 0 +var net__txn: words = null +var net__dice: Rng = null + +function net__stun_send() -> void { + if net__txn == null { net__txn = words(3) } + if net__dice == null { net__dice = rng_new(net__conf().seed) } + if net__stun_ip == 0 { + let c = net__conf() + if len(c.stun_at) > 0 and net_parse_code(c.stun_at) { + net__stun_ip = net__code_ip + net__stun_port = net__code_port + } else if len(c.stun_host) > 0 { + let host: pointer = c.stun_host + net__stun_ip = Udp.resolve(host) + net__stun_port = c.stun_port + } + if net__stun_ip == 0 { return } + } + let b = buffer(20) + nb_put8(b, 0, 0) # a binding request, no attributes + nb_put8(b, 1, 1) + nb_put8(b, 2, 0) + nb_put8(b, 3, 0) + nb_put8(b, 4, 0x21) # the magic cookie + nb_put8(b, 5, 0x12) + nb_put8(b, 6, 0xA4) + nb_put8(b, 7, 0x42) + for k in 0 .. 3 { net__txn[k] = rng_between(net__dice, 0, 2000000000) } + for k in 0 .. 12 { nb_put8(b, 8 + k, (net__txn[k / 4] >> ((k % 4) * 8)) & 255) } + NetSocket.send(net__sock, net__stun_ip, net__stun_port, b, 20) +} + +function net__be16(p: []byte, o: int) -> int { return (nb_get8(p, o) << 8) | nb_get8(p, o + 1) } + +function net__be32(p: []byte, o: int) -> int { return (net__be16(p, o) << 16) | net__be16(p, o + 2) } + +# a STUN reply: true when it was one (whatever it said) +function net__stun_packet(n: int) -> bool { + if n < 20 or nb_get8(net__in, 0) != 1 or nb_get8(net__in, 1) != 1 { return false } + if net__be32(net__in, 4) != 0x2112A442 { return false } + let len = net__be16(net__in, 2) + var o = 20 + while o + 4 <= 20 + len and o + 4 <= n { + let t = net__be16(net__in, o) + let l = net__be16(net__in, o + 2) + if (t == 0x0020 or t == 0x0001) and l >= 8 and o + 12 <= n and nb_get8(net__in, o + 5) == 1 { + var port = net__be16(net__in, o + 6) + var ip = net__be32(net__in, o + 8) + if t == 0x0020 { + port = port ^ 0x2112 + ip = ip ^ 0x2112A442 + } + net__pub_ip = ip + net__pub_port = port + net__log(`outside we are {net__addr_text(ip, port)}`) + } + o += 4 + ((l + 3) / 4) * 4 + } + return true +} + +# what this machine looks like from outside, once STUN has said (0 until then) +export function net_outside_ip() -> int { return net__pub_ip } +export function net_outside_port() -> int { return net__pub_port } diff --git a/packages/ludic.net/tests/net_test.ludic b/packages/ludic.net/tests/net_test.ludic new file mode 100644 index 00000000..9bd804bc --- /dev/null +++ b/packages/ludic.net/tests/net_test.ludic @@ -0,0 +1,275 @@ +# net_test.ludic - the codec, the codes, a host taking a stranger's hello, reliable delivery in +# order, a guest's hello and its timeout, a silent guest and a slot let go - over a fake socket +import "ludic.net" +import "ludic.base" +program NetTest { + numbers float + property Pkt { ip: int = 0, port: int = 0, len: int = 0, data: []byte } + var sent: []Pkt = null + var inbound: []Pkt = null + var from_ip: int = 0 + var from_port: int = 0 + + function fake_open(port: int) -> int { return 5 } + function fake_close(h: int) -> void { } + function fake_port(h: int) -> int { return 40000 } + function fake_local() -> int { return Udp.ip("10.0.0.5") } + function fake_from_ip(h: int) -> int { return from_ip } + function fake_from_port(h: int) -> int { return from_port } + function fake_send(h: int, ip: int, port: int, b: []byte, n: int) -> int { + let p = new Pkt + p.ip = ip + p.port = port + p.len = n + p.data = buffer(n) + nb_copy(p.data, 0, b, 0, n) + push(sent, p) + return n + } + function fake_recv(h: int, b: []byte, cap: int) -> int { + if len(inbound) == 0 { return 0 } + let p = inbound[0] + let rest = new []Pkt + for i in 1 .. len(inbound) { push(rest, inbound[i]) } + inbound = rest + nb_copy(b, 0, p.data, 0, p.len) + from_ip = p.ip + from_port = p.port + return p.len + } + bind NetSocket { open: fn fake_open, close: fn fake_close, send: fn fake_send, recv: fn fake_recv, from_ip: fn fake_from_ip, from_port: fn fake_from_port, port: fn fake_port, local_ip: fn fake_local } + + function fresh() -> void { + sent = new []Pkt + inbound = new []Pkt + net_reset() + q_clear(net_inbox()) + q_clear(net_facts()) + } + + # a packet from ip:port: token, pid, ack, then one message of `kind` (reliable with `id` > 0) + function arrive(ip: int, port: int, token: int, pid: int, ack: int, kind: int, id: int, payload: int) -> void { + let b = buffer(40) + nb_put8(b, 0, 'M') + nb_put8(b, 1, 'L') + nb_put8(b, 2, 1) + nb_put8(b, 3, 1) + nb_put32(b, 4, token) + nb_put32(b, 8, pid) + nb_put32(b, 12, ack) + nb_put32(b, 16, 1) + var o = 20 + nb_put8(b, o, kind) + nb_put8(b, o + 1, 0) + o += 2 + if id > 0 { + nb_put8(b, o - 1, 1) + nb_put32(b, o, id) + o += 4 + } + nb_put8(b, o, 4) + nb_put8(b, o + 1, 0) + nb_put32(b, o + 2, payload) + let p = new Pkt + p.ip = ip + p.port = port + p.len = o + 6 + p.data = b + push(inbound, p) + } + + function frame(dt: float) -> void { + net_begin(dt) + net_update(dt) + net_end() + } + + # the host with a guest let in at 10.0.0.9:5000 as pid 2 + function host_with_guest() -> int { + fresh() + expect(net_host(27415, 77)) + arrive(Udp.ip("10.0.0.9"), 5000, 0, 0, 0, NET_HELLO, 1, 42) + net_begin(0.01) + let got = q_drain(net_inbox()) + expect_eq(len(got), 1) + return net_accept(got[0].slot) + } + + function facts_of(what: int) -> []NetFact { + let all = q_drain(net_facts()) + let out = new []NetFact + for i in 0 .. len(all) { if all[i].what == what { push(out, all[i]) } } + return out + } + + test "the codec carries ints, bools, floats and text, and a short message reads bad" { + fresh() + nw_begin() + nw_i(-7) + nw_b(true) + nw_f(2.5) + nw_s("maroon") + net_read(net_written()) + expect_eq(nr_i(), -7) + expect(nr_b()) + expect_near(nr_f(), 2.5, 0.0001) + expect(nr_s() == "maroon") + expect(not nr_bad()) + expect_eq(nr_i(), 0) + expect(nr_bad()) + } + + test "a join code decodes to its address and port; an address and a room code are taken too" { + fresh() + let ip = Udp.ip("192.168.1.20") + let code = net_code_of(ip, 27415) + expect_eq(len(code), 11) + expect(net_parse_code(code)) + expect_eq(net_code_ip(), ip) + expect_eq(net_code_port(), 27415) + expect(net_parse_code(Text.lower(code))) + expect(net_parse_code("10.1.2.3:4000")) + expect_eq(net_code_port(), 4000) + expect(net_parse_code("10.1.2.3")) + expect_eq(net_code_port(), 27415) + expect(not net_parse_code("HELLO")) + expect(net_is_room("abcdef")) + expect(not net_is_room("ABCDE1")) + } + + test "a host takes a stranger only with a hello, and hands out pids from 2" { + let pid = host_with_guest() + expect_eq(pid, 2) + expect(net_hosting()) + expect_eq(net_my_pid(), 1) + arrive(Udp.ip("10.0.0.10"), 6000, 0, 0, 0, 9, 1, 1) + net_begin(0.01) + expect_eq(q_len(net_inbox()), 0) + arrive(Udp.ip("10.0.0.11"), 6000, 0, 0, 0, NET_HELLO, 1, 1) + net_begin(0.01) + let got = q_drain(net_inbox()) + expect_eq(net_accept(got[0].slot), 3) + } + + test "a message's payload reads back as it was written" { + host_with_guest() + arrive(Udp.ip("10.0.0.9"), 5000, 77, 2, 0, 9, 2, 1234) + net_begin(0.01) + let got = q_drain(net_inbox()) + expect_eq(len(got), 1) + expect_eq(got[0].kind, 9) + expect_eq(got[0].from, 2) + net_read(got[0]) + expect_eq(nr_i(), 1234) + expect(not nr_bad()) + nr_i() + expect(nr_bad()) + } + + test "a reliable message that arrives early is dropped and taken in order" { + let slot = np_of_pid(host_with_guest()) + arrive(Udp.ip("10.0.0.9"), 5000, 77, 2, 0, 9, 3, 30) + net_begin(0.01) + expect_eq(q_len(net_inbox()), 0) + arrive(Udp.ip("10.0.0.9"), 5000, 77, 2, 0, 9, 2, 20) + arrive(Udp.ip("10.0.0.9"), 5000, 77, 2, 0, 9, 3, 30) + net_begin(0.01) + let got = q_drain(net_inbox()) + expect_eq(len(got), 2) + net_read(got[1]) + expect_eq(nr_i(), 30) + expect_eq(got[1].slot, slot) + } + + test "a reliable message goes again until it is taken" { + let slot = np_of_pid(host_with_guest()) + net_end() + sent = new []Pkt + nw_begin() + nw_i(5) + np_queue(slot, 9, true) + frame(0.01) + expect_eq(len(sent), 1) + frame(0.1) + expect_eq(len(sent), 1) + frame(0.2) + expect_eq(len(sent), 2) + arrive(Udp.ip("10.0.0.9"), 5000, 77, 2, 1, 9, 0, 0) + frame(0.3) + let n = len(sent) + frame(0.3) + expect_eq(len(sent), n) + } + + test "an unreliable message half a second old is dropped" { + let slot = np_of_pid(host_with_guest()) + net_end() + sent = new []Pkt + nw_begin() + np_queue(slot, 9, false) + net_begin(0.6) + net_end() + expect_eq(len(sent), 0) + } + + test "a guest sends its hello until nobody has answered for too long" { + fresh() + nw_begin() + nw_s("v1") + net_hello_set() + expect(net_join(Udp.ip("10.0.0.1"), 27415)) + expect(net_joining()) + frame(0.1) + expect_eq(len(sent), 1) + expect_eq(nb_get8(sent[0].data, 20), NET_HELLO) + for i in 0 .. 110 { frame(0.1) } + expect_eq(net_state(), NET_LOST) + let down = facts_of(NET_DOWN) + expect_eq(len(down), 1) + expect_eq(down[0].why, NET_WHY_NO_ANSWER) + net_fresh() + expect_eq(net_state(), NET_IDLE) + } + + test "a welcome makes the guest that pid in that session" { + fresh() + nw_begin() + net_hello_set() + net_join(Udp.ip("10.0.0.1"), 27415) + net_joined_as(99, 4) + expect(net_live()) + expect_eq(net_my_pid(), 4) + expect_eq(net_token(), 99) + } + + test "a guest silent for the timeout is gone" { + let pid = host_with_guest() + let slot = np_of_pid(pid) + for i in 0 .. 105 { frame(0.1) } + let gone = facts_of(NET_GONE) + expect_eq(len(gone), 1) + expect_eq(gone[0].pid, pid) + expect_eq(gone[0].slot, slot) + expect(not net_peer_on(slot)) + } + + test "a slot is let go once it has taken its last message" { + let slot = np_of_pid(host_with_guest()) + nw_begin() + np_queue(slot, 9, true) + net_close_when_sent(slot, 20.0) + frame(0.01) + expect(net_peer_on(slot)) + arrive(Udp.ip("10.0.0.9"), 5000, 77, 2, 1, 9, 0, 0) + frame(0.01) + expect(not net_peer_on(slot)) + expect_eq(len(facts_of(NET_CLOSED)), 1) + } + + test "with no matchmaker, a room code is refused" { + fresh() + expect(not net_room_on()) + expect(not net_room_join("ABCDEF")) + expect_eq(net_why(), NET_WHY_NO_MATCHMAKER) + } +} diff --git a/packages/ludic.net/tick.ludic b/packages/ludic.net/tick.ludic new file mode 100644 index 00000000..99431fa7 --- /dev/null +++ b/packages/ludic.net/tick.ludic @@ -0,0 +1,111 @@ +# tick.ludic - a frame of the session, in three calls with the game's between them: net_begin +# (time, and what came in onto the inbox), net_update (the matchmaker, the hello, silence) and, +# once the game has queued its own, net_end (heartbeats, sending, letting slots go) +export function net_begin(dt: float) -> void { + if not net_active() { return } + net__time = net__time + dt + net__pump() +} + +export function net_update(dt: float) -> void { + if not net_active() { return } + net__room_tick(dt) + if net__state == NET_JOINING { + net__hello_tick(dt) + return + } + for i in 0 .. NET_PEERS { + let p = net__peers[i] + if not p.on or net__time - p.heard <= float(NET_TIMEOUT) { continue } + if net__state == NET_JOINED { + net_lose(NET_WHY_HOST_SILENT) + return + } + let gone = p.pid + np_close(i) + net__fact(NET_GONE, i, gone, 0, "") + } +} + +function net__hello_tick(dt: float) -> void { + if net__hello_t < 0.0 { + nb_copy(net__wb, 0, net__hello_b, 0, net__hello_l) + net__wl = net__hello_l + np_queue(0, NET_HELLO, true) + net__hello_t = 0.0 + } + net__hello_t = net__hello_t + dt + if net__hello_t > float(NET_TIMEOUT + net__wait_more) { + net_lose(NET_WHY_NO_ANSWER) + return + } + np_flush(0) +} + +export function net_end() -> void { + if not net_live() { return } + let beat = net__time - net__beat_t >= 1.0 + if beat { net__beat_t = net__time } + for i in 0 .. NET_PEERS { + let p = net__peers[i] + if not p.on { continue } + if beat { + nw_begin() + np_queue(i, NET_BEAT, false) + } + np_flush(i) + if p.closing and (p.acked >= p.close_id or net__time > p.close_t) { + let pid = p.pid + np_close(i) + net__fact(NET_CLOSED, i, pid, 0, "") + } + } +} + +# while a load holds the frame: keep answering, so nobody times out on this machine +export function net_keepalive() -> void { + if not net_live() { return } + net__pump() + for i in 0 .. NET_PEERS { + let p = net__peers[i] + if p.on { + p.heard = net__time + nw_begin() + np_queue(i, NET_BEAT, false) + np_flush(i) + } + } +} + +# every queue to its peer now, three times over (a machine about to let go) +export function net_flush_all() -> void { + for t in 0 .. 3 { for i in 0 .. NET_PEERS { np_flush(i) } } +} + +# the session is over for this machine: every slot, the socket and the matchmaker let go +export function net_reset() -> void { + if net__peers != null { for i in 0 .. NET_PEERS { np_close(i) } } + if net__sock > 0 { + NetSocket.close(net__sock) + net__sock = 0 + } + net__state = NET_IDLE + net__my_pid = 0 + net__wait_more = 0 + net__room_reset() +} + +# out of the session, and why: the game hears NET_DOWN and says so in its own words +export function net_lose(why: int) -> void { net__lose_with(why, "") } + +# ... with what the reason is about (the room code nobody had) +function net__lose_with(why: int, text: string) -> void { + net_reset() + net__state = NET_LOST + net__why = why + net__log(`lost ({why})`) + net__fact(NET_DOWN, -1, 0, why, text) +} + +# a lost session read and done with: idle again +export function net_fresh() -> void { if net__state == NET_LOST { net__state = NET_IDLE } }