Repository navigation
Releases: NewbieOrange/ShadowLAN
Release list
v3.1.0
ShadowLAN 3.1.0
A latency and bufferbloat release. Games now hear from each other as fast as the network allows, and bulk transfers still run at
full link speed, matching a plain TCP connection over the same path.
The wire protocol is unchanged (PVER 3 / UVER 3): 3.0.0 and 3.1.0 hooks and relays work together. Deploy the new server.py on
your relay to get the relay-side improvements.
Highlights
-
Low latency on every way a game waits for data. Before, the hook checked for tunneled data on a timer, which added delay
depending on how the game read its sockets:- TCP with a blocking
recv: about 10 ms - TCP or UDP with
select,pollorWSAPoll: about 25 ms (51 ms for blocking UDP receives on Windows)
Now a game wakes up the moment data arrives: about 0.1 ms round trip on Linux and about 0.5 ms on Windows (measured under Wine),
not counting the network. - TCP with a blocking
-
No more busy-spinning. Games waiting on a tunneled TCP connection with
poll/selectused to spin a CPU core at 60–99%.
They now sleep until data arrives. -
Much less bufferbloat during bulk transfers. On a 20 Mbit/s link with 40 ms round trip, a message sent on the same connection
as a running download used to wait about 4 seconds. Now it waits about 240 ms, which is less than a plain kernel TCP connection on
the same path (about 555 ms). Throughput is unchanged. -
UDP-over-TCP stays responsive under load. When game traffic exceeds the link, stale datagrams are dropped instead of queuing
without limit. Latency stays around 200 ms instead of growing past several seconds.
Fixes
- Linux: blocking UDP receives no longer hang. A plain blocking
recvfromon a tunneled UDP socket could block forever. - Startup deadlock when several threads bind at once. Two threads binding sockets at the same time could leave every later
bind()in the process hanging. - Data at process exit. A game that sent data and exited immediately could lose the tail of it. Two causes are fixed: the
hook's exit flush could silently skip a stream, and the relay closed a departing machine's open connections before their last bytes
arrived. - End of stream is reported to
poll/select. When the other side closes a TCP connection, the game'spoll/selectnow
reports it as readable, as a real kernel does. - New hosted UDP sessions get their first reply immediately instead of after up to 100 ms.
- wclient relay connections now have Nagle's algorithm disabled, avoiding delays on small writes.
Bandwidth
Bulk throughput matches a direct kernel TCP connection over the same links:
| Link | 3.0.0 | 3.1.0 | Plain kernel TCP |
|---|---|---|---|
| 20 Mbit/s, 40 ms RTT | 19.1 Mbit/s | 19.1 Mbit/s | 19.0 Mbit/s |
| 200 Mbit/s, 100 ms RTT | 187 Mbit/s | 186 Mbit/s | 181 Mbit/s |
Tunnel connections now let the operating system size their buffers automatically. Before, 3.0.0 requested fixed 4 MB buffers, which
turned that off. On a typical VPS the kernel cuts such a request down to about 425 KB, which limited throughput on long, fast
paths.
Under the hood
- Every waiting call (
recv,recvfrom,connect,send,poll,ppoll,select,pselect,WSAPoll) is woken by the hook
as soon as data arrives, instead of rechecking on a timer. - Unsent data is kept small (
TCP_NOTSENT_LOWATon Linux and the relay). The app-side send queue per connection is 256 KB, down
from 4 MB. A single larger send is still accepted in parts, as a real socket does. - Each control message goes out in one TCP segment instead of three.
Upgrading
- Relay: replace
server.pyand restart. Existing 3.0.0 hooks keep working. - Players: replace the hook files from the package for your platform. A 3.1.0 hook works with a 3.0.0 relay too, but you only
get the full improvement with both upgraded.
Assets
shadowlan-v3.1.0-windows-amd64.zip:lan_hook64.dll,lan_hook32.dll,injector.exe, relay and client scripts, docsshadowlan-v3.1.0-linux-amd64.tar.gz:lan_hook.so, relay and client scripts, docs
v3.0.0
ShadowLAN 3.0.0
Range: v2.0.0 (2026-09-09) → v3.0.0 (2026-09-13).
Hook and relay must be deployed together. A 2.x peer is dropped at registration (NODE version check). Old hooks cannot parse a 3.0.0 ASSIGN.
Breaking
- Control protocol is PVER 3 (was 2). Registration is the only versioned frame; gating it gates streams too.
ASSIGNno longer carries a trailinglink_idbyte. Payload is11+8*n(vnode, net, bits, membership only).- UDP source identity is the sender socket’s bound vport — the port a real NIC would stamp. The v2 slot-mark (
link_id*256+slot) is gone from the wire. Apps that foldrecvfromsources into peer state and dial them now get a real, dialable port. STREQis exactly 10 bytes (sid + gport + opener vnode). Short frames are rejected (noovirt=0fallback).
Protocol and relay
connect()completes when a dest link claims the stream (STOKat claim = SYN/ACK).STJOINEDstarts the raw pipe. A bridge failure after that is a post-connect reset, which real TCP can do too.- Half-close:
T_STSHUTFINs one direction and leaves the reverse alive. Implicit (port-only) streams now record the winning joiner so a joineeSTSHUTactually reaches the opener. - Designated-host election is gone. Implicit dials fan out like LAN ARP; every live link is asked, only the process that listens claims.
NODE_F_HOSTis an ordering stamp for wclient--hostmigration, not an election. - Membership lists only nodes with a live link. A fully dark node keeps its virtual IP for reconnect (up to
NODE_TTL) but disappears from peer tables immediately. - Beacons are forwarded, never cached or replayed. Same-process control links get NIC-once delivery (no duplicate beacons that fold phantom peers).
- UDP-over-TCP links are addressable (
("tcp", writer)), so rooms behind unfriendly NAT can start a join instead of dropping P2P asno-udp-endpoint. - Implicit claims use a short preferential window (
IMPLICIT_GRACE_S): all claims collected, freshesthost_claimwins, the rest getBUSY. - Accepted tunnel sockets get the same TCP tuning as the hook (Nagle off, large windows on the SYN).
- Stream verdicts match a kernel: nobody claims → framed
NO_ROUTE(fast refuse); claim then vanish → transport EOF/reset, not a 10 s stall.
Hook — LAN fidelity
- Accept door. Accepted game sockets present the opener’s vnode on
accept,getpeername, andgetsockname. Local port is the listen vport, not an aliased kernel ephemeral. Learn matches the bridge ephemeral — never “first live hosted row” (that stole the opener vnode on same-box hairpins). - Same-box hairpin. A process dialing its own vnode uses this process’s alias real port, not the vport number (which another node on the same kernel may own).
- Recv gather.
recvreturns the full in-queue thatFIONREADreported, not only the first chunk. Short first-chunk pops left lobby parsers waiting on a length prefix forever. - Hosted UDP identity. Session
ovirtis the parsed vnode, not the first four ASCII bytes of"10.200.…". - Replacement atomicity. A new
STREQfor the same(peer, gport)retires the old hosted session before claiming (kernel: a new connection implies the old is dead). - Close / duplex.
shutdown(SHUT_RD/WR),close()flushes the out-queue, peer FIN half-closes instead of killing both pumps, FIN vs RST distinguished (recv0 vsECONNRESET).SO_ERRORandselect/pollconnect edges match the kernel (no vacuous writable while still connecting). - Exit drain. Queued stream bytes and the control-frame queue flush on process exit (
ExitProcess/exit/_exit), so parting frames land instead of peers waiting out an app timeout. - Hosted pump. Unwritten game bytes are held and retried; the pump no longer drops a remainder when the relay write would block (that silently corrupted streams and looked like app-side stalls).
- Event-driven IO. Control link and per-stream pumps wake on kernel readiness plus a self-wake pair. Claim round-trip is sub-millisecond locally; poll slices are idle backstops only.
- Bind ledger. Node-scoped shared registry (Windows
Local\mapping / Linuxshm_open+flock). Same-node binds collide like a real kernel; different nodes on one OS alias underneath and never see each other. Dead-owner slots are reclaimed.SO_REUSEADDRUDP sharing matches the kernel (both sides must set it).bind(0)underLAN_ONLYallocates from the node ephemeral space. - Last-error.
hk_bindhas a single exit that snapshotsWSAGetLastError/errnoaround logging, so apps no longer seeerr=2for a refused bind. - Windows ledger crash. Startup AV from
GetProcessTimeson a kernelbase out-param is gone (PEB create-time +GetExitCodeProcessliveness only). Registry init is content-based; the section create handle stays open for the process lifetime. - Own-vnode dials loop back correctly (compare against
g_node, not the vnode number). - sid seeding is 64-bit on both Windows ABIs (the old
unsigned longfold was a no-op on Win32/Win64). - Channel policy after the mark fix: same-box double-node runs behave like two machines (forward and reverse both bridge). The old co-host yield guard and any-proto bridge backstop are gone — they existed only to paper over the mark leak.
Hook — structure
- One translation unit per
hk_*.c.hk_coreholds wire ops, tables,DLOCK, and policy.lan_hook.cis the version stamp only. Linux ishk_linux.c; Windows ishk_winsock/hk_wicmp/hk_winnic/hk_wininst. - Compile defaults to
-j$(nproc); a parent jobserver or explicit-jstill wins. Objects rebuild from headers, not from every sibling.c. pack.shlists every artifact and refuses a Windows zip that containslan_hook.so(or a Linux tarball that contains the DLLs).
Client
- wclient stream pumps are strongly referenced (
WinClient._spawn) so the loop cannot GC a live pipe. - wclient parses
STREQwith the shared 10-byte decoder.
Tests and harness
make -C hook testruns every Linux suite in parallel (runtests.py -j, ports fromtestutil.free_port()).test-alladds Wine to the same pool.- New / extended guards:
test_fullduplex(close, half-close, gather),test_bindfidelity,test_aliasbridge(same-box hairpin, accept-door triple, forward channel),test_socket_doors,test_stfail_semantics(including implicitSTSHUT),test_burst_connect,test_exitdrain,test_treeid.
Packages
shadowlan-v3.0.0-linux-amd64.tar.gz—lan_hook.so+ Python + docsshadowlan-v3.0.0-windows-amd64.zip— both DLLs + injector + Python + docs
v2.0.0
v1.0.0
v0.1.0
chore: ignore build binaries