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 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 07:45:26 +03:00
parent 411206e45a
commit 12172d7b75
20 changed files with 1762 additions and 0 deletions

View file

@ -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<NetMessage>`, `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<NetFact>` | `{ 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.

View file

@ -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
}

View file

@ -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
}

View file

@ -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"

View file

@ -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

View file

@ -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
}

View file

@ -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<NetFact> = 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<NetFact> {
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() }

View file

@ -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 } }
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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())
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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 }

View file

@ -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
}

View file

@ -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<NetMessage> = null
export function net_inbox() -> Queue<NetMessage> {
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)
}

View file

@ -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 }

View file

@ -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)
}
}

View file

@ -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 } }