/* * steamlink.h -- VWE Steam transport core (Ethernet frames over Steam P2P). * * Shared VERBATIM by two consumers, which is why it is a free-function * namespace and not a class: * * the DOSBox-X fork's `backend=steam` NE2000 backend (ethernet_steam.cpp) * -- run by every REMOTE player; no Npcap on those machines at all; * * the session host's standalone steam<->pcap gateway (steam-gw/main.cpp), * which bridges the tunnel onto the Network Bridge so TeslaConsole * (200.0.0.10) and the host's own pcap pod can reach the remote pods. * * WHY L2: the 1995 mesh (WATTCP + netnub + L4NetworkManager) dials peers by * raw IP on one flat subnet and ARPs for them. NAT/UDP hole-punching at L3 * cannot carry that; tunnelling whole Ethernet FRAMES preserves the * same-switch semantics the DOS binaries were built for. See * ../STEAM-NOTES.md and ../steam/SESSION-CONTRACT.md. * * Threading: NOT thread-safe by design. Every entry point is called from a * single thread -- in the fork that is the emulator thread (GetPackets runs * off NE2000_Poller, a TIMER tick handler, i.e. once per emulated * millisecond), in the gateway it is the one service loop. Do not call from * two threads. * * Steamworks is bound at RUNTIME (LoadLibrary/GetProcAddress on * steam_api64.dll), exactly like the pcap backend binds WPCAP.DLL. A build * made without the SDK DLL present still runs -- `backend=steam` just fails * Init() with a readable message, the same way a missing Npcap fails pcap. * * Windows-only for now (the whole VWE deployment is), but nothing here is * structurally Windows-bound apart from the DLL loader. */ #ifndef VWE_STEAMLINK_H #define VWE_STEAMLINK_H #include #include #include #include #include namespace vwesteam { /* ------------------------------------------------------------------ * * Wire format (channel `Config::channel`, default 0) * * One Steam message == one record. First byte is the record type. * * 'F' frame : ['F'][raw Ethernet frame, 14..1514 bytes] * 'H' hello : ['H'][u8 ver=1][u8 mac[6]][u32 ip, BIG-endian][char name[16]] * * HELLO is the authoritative SteamID64 <-> MAC binding. It is sent when a * session comes up and then periodically (fast until the peer has answered, * slow after) so a peer that restarts its pod is re-learned without any * lobby round trip. Learning the src MAC off an 'F' frame is the fallback * for a HELLO that raced past us. * * Everything is sent RELIABLE + NoNagle. Rationale (do not "optimize" this * to unreliable without re-reading it): the payload is guest TCP driven by * WATTCP's DOS-era retransmit timers, which are multi-second. One dropped * segment on an unreliable tunnel therefore costs seconds of visible stall, * whereas the reliable channel's RTT-based retransmit costs one RTT. The * usual TCP-over-TCP meltdown argument needs an AGGRESSIVE inner * retransmitter; WATTCP is the opposite. Reliable is also ORDERED (matches * the no-reorder model the Phase 0 latency gate validated) and fragments * transparently far past 1514 bytes, which retires MTU as an issue. * ------------------------------------------------------------------ */ enum : uint8_t { REC_FRAME = 'F', REC_HELLO = 'H' }; /* Largest Ethernet frame we relay (no jumbo; the NE2000 can't make one). */ enum { VWE_MAX_FRAME = 1514 }; struct Config { /* Allowlist AND initial peer set: SteamID64s of the other pods in this * session. Steam session requests from an identity that is not in here * are refused -- with a private lobby that is the whole security story, * which is adequate for a friends group on app 480. */ std::vector peers; /* Optional: a Steam lobby to pull the roster from instead of / in * addition to `peers` (Phase 3). 0 = don't touch matchmaking. */ uint64_t lobby = 0; /* The session host, i.e. the peer running the steam<->pcap gateway * (SESSION-CONTRACT.md section 2: exactly one peer carries * "gateway": true). 0 = no gateway in this session. * * This is the DEFAULT ROUTE. A remote pod's frames for TeslaConsole at * 200.0.0.10 carry the console's real NIC MAC, which is a host-LAN * address and will never appear in the peer table, so without a default * route the only way to deliver them is to flood every peer -- which * duplicates the whole console<->pod stream (the egg transfer above all) * to all 7 other players and pays for it on everyone's uplink. */ uint64_t gateway = 0; /* Steam networking channel. Keep 0 unless two links share a process. */ int channel = 0; /* Short label announced in HELLO and printed in logs (pilot/slot name). * Truncated to 15 chars + NUL on the wire. */ std::string name; }; /* Log sink. The fork points this at LOG_MSG, the gateway at stderr. Set it * BEFORE Init() if you want to see Init()'s own diagnostics. */ typedef void (*LogFn)(const char* msg); void SetLog(LogFn fn); /* Bring the link up. * self_mac the guest's 6-byte MAC ([ne2000] macaddr). REQUIRED and must be * stable -- it is what peers route to. A random MAC breaks the * mesh on every restart. * self_ip the guest's IPv4 in HOST byte order (diagnostics + the * gateway's a-priori 02:00:xx:xx:xx:xx sanity check). 0 = unknown. * err human-readable reason on failure. * Returns false if steam_api64.dll is missing, Steam isn't running, the user * isn't logged in, or the SDK rejected init. Refcounted with Shutdown(). */ bool Init(const Config& cfg, const uint8_t self_mac[6], uint32_t self_ip, std::string& err); /* Tear down: closes every peer session and releases the SDK. */ void Shutdown(); /* Service Steam: runs the manual-dispatch callback pump (session requests -> * accept-if-allowlisted, session failures -> log + mark for re-HELLO, lobby * roster deltas when Config::lobby is set) and re-sends HELLOs when due. * * MUST be called regularly -- the fork calls it at the top of GetPackets(), * i.e. once per emulated ms, which is also why no separate thread or timer is * needed anywhere. */ void Pump(); /* Queue one Ethernet frame to the peer(s) it is addressed to. Frames shorter * than 14 or longer than VWE_MAX_FRAME are dropped. * * Routing: * broadcast / multicast -> every peer (ARP has to reach everyone) * unicast to a learned MAC -> that one peer * unicast to an unknown MAC -> Config::gateway if set, else every peer * * The unknown-unicast case is almost always console-bound traffic, which is * why the gateway is the default route rather than a flood. Falling back to * flooding when no gateway is configured keeps a gateway-less session (two * players, no console) working, and also covers the narrow race where a peer's * MAC is not yet learned -- narrow because ARP is broadcast, so a peer is * normally bound before anyone unicasts to it. */ void SendFrame(const uint8_t* frame, int len); /* Drain received frames, invoking `cb` for each. Returns how many were * delivered. NOTE: this hands over EVERY frame a peer sent us -- the caller * applies its own acceptance policy (the fork mimics the NE2000's * dst==self||broadcast rule so behaviour matches the pcap backend's BPF; the * gateway injects everything onto the bridge unmodified). The pointer is only * valid inside the callback. */ int PollFrames(const std::function& cb); /* Our own SteamID64 (0 before a successful Init). */ uint64_t SelfSteamID(); /* Route diagnostics: relayed (SDR/TURN) vs direct P2P, and the switch between * them. Call AFTER Init; safe to skip entirely. * * Why it is a log firehose and not a clean bool: the flag that answers this, * k_nSteamNetworkConnectionInfoFlags_Relayed, lives in SteamNetConnectionInfo_t * and needs an HSteamNetConnection to read. ISteamNetworkingMessages never * gives one out -- the SDK says so at SteamNetworkingMessage_t::m_conn: "Not * used when using the ISteamNetworkingMessages interface". So the only route * left is Steam's own debug output, which names the path it chose and logs the * relay->direct upgrade when it happens. * * `level` is Valve's ESteamNetworkingSocketsDebugOutputType: 4 = Warning, * 5 = Msg (route changes, the useful setting), 6 = Verbose, 7 = Debug, * 8 = Everything (a wall of per-packet text). Output goes to the same LogFn * sink as everything else. Degrades to a logged notice if this steam_api64.dll * has no ISteamNetworkingUtils. */ void SetRouteDiagnostics(int level); /* --- introspection, for the gateway's BPF and for status lines ---------- */ /* Bumps every time the known set of peer MACs changes (HELLO from a new peer, * a peer's MAC changing after a pod restart, a session dropping). The gateway * watches this to know when to recompile + reinstall its pcap filter; polling * a counter keeps that policy in the gateway instead of in a callback. */ uint32_t PeerEpoch(); struct PeerInfo { uint64_t steamId; uint8_t mac[6]; bool macKnown; /* false until HELLO or a learned src MAC */ uint32_t ip; /* host order; 0 if unknown */ char name[16]; bool sessionUp; bool isGateway; /* the session host; the default route (see Config) */ uint64_t framesTx, framesRx; }; /* Snapshot of the peer table (for logs, status panels and the gateway's * filter build). Returns the number written. */ size_t Peers(std::vector& out); /* Add/remove a peer at runtime (lobby deltas, or a launcher that learns a * late joiner). Adding an already-known peer is a no-op. */ void AddPeer(uint64_t steamId); void RemovePeer(uint64_t steamId); /* Aggregate counters, for the throttled "STEAM TX/RX #n" log lines that mirror * the pcap backend's. */ void Counters(uint64_t& tx, uint64_t& rx, uint64_t& txDropped); } // namespace vwesteam #endif // VWE_STEAMLINK_H