/* * steamlink.cpp -- VWE Steam transport core. Implements steamlink.h; that * header carries the API contract, the wire format and the * why-reliable-not-unreliable rationale. Addressing, the session file and the * gateway's forwarding rules live in ../steam/SESSION-CONTRACT.md. * * This file is compiled VERBATIM into two binaries -- the fork's * `backend=steam` NE2000 backend and the host's steam<->pcap gateway -- so it * pulls in nothing from DOSBox-X (no dosbox.h, no logging.h, no config.h) and * never printf()s. Every diagnostic goes out through the LogFn the caller * installs; that is the only reason the two consumers can route logs * differently without a fork of this file. * * Steamworks is bound at RUNTIME, the same way ethernet_pcap.cpp binds * WPCAP.DLL: a build made on a machine with no Steam SDK still compiles and * runs, and `backend=steam` just fails Init() with a readable message the way * a missing Npcap fails the pcap backend. */ #include "steamlink.h" #include #include #include #include #include #include namespace vwesteam { static LogFn g_log = 0; void SetLog(LogFn fn) { g_log = fn; } static void Log(const char* fmt, ...) { if (!g_log) return; char buf[512]; va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); buf[sizeof(buf) - 1] = 0; g_log(buf); } /* Powers of 2 only, so a whole mission's frames cost ~20 log lines. Same * throttle the pcap backend's "PCAP TX/RX #n" lines use. */ static inline bool LogNth(unsigned long long n) { return (n & (n - 1)) == 0; } } // namespace vwesteam #if defined(WIN32) || defined(_WIN32) #include #ifdef VWE_STEAM_USE_SDK_HEADERS /* Validation build: -DVWE_STEAM_USE_SDK_HEADERS -I/public. The struct * declarations below are then the SDK's own and the static_asserts check * Valve's layout instead of our transcription of it. */ #include #include #include #include #include #endif namespace vwesteam { /* ------------------------------------------------------------------ * * Protocol constants (SDK name in the comment; we spell them locally so * the SDK-headers build cannot collide with Valve's enums). * ------------------------------------------------------------------ */ enum { IDENTITY_TYPE_STEAMID = 16, /* k_ESteamNetworkingIdentityType_SteamID */ SEND_NONAGLE = 1, /* k_nSteamNetworkingSend_NoNagle */ SEND_RELIABLE = 8, /* k_nSteamNetworkingSend_Reliable */ SEND_FLAGS = SEND_RELIABLE | SEND_NONAGLE, RESULT_OK = 1, /* k_EResultOK */ CB_SESSION_REQUEST = 1251, /* SteamNetworkingMessagesSessionRequest_t */ CB_SESSION_FAILED = 1252, /* SteamNetworkingMessagesSessionFailed_t */ CB_LOBBY_CHAT_UPDATE = 506, /* LobbyChatUpdate_t */ CHAT_MEMBER_ENTERED = 0x0001/* k_EChatMemberStateChangeEntered */ }; namespace sdkabi { #ifdef VWE_STEAM_USE_SDK_HEADERS typedef ::SteamNetworkingIdentity SteamNetworkingIdentity; typedef ::SteamNetworkingMessage_t SteamNetworkingMessage_t; typedef ::CallbackMsg_t CallbackMsg_t; #else /* Hand-declared, ABI-compatible with Steamworks SDK 1.62 on x86-64. Only the * three structs that cross the DLL boundary are here; everything else the * flat API takes is a scalar. */ struct SteamNetworkingIdentity { int32_t m_eType; /* ESteamNetworkingIdentityType */ int32_t m_cbSize; /* 8 when carrying a SteamID64 */ union { uint64_t m_steamID64; uint8_t m_genericBytes[32]; char m_szUnknownRawString[128]; uint32_t m_reserved[32]; /* this is what pins the struct at 136 bytes */ }; }; struct SteamNetworkingMessage_t { void* m_pData; int32_t m_cbSize; uint32_t m_conn; /* HSteamNetConnection */ SteamNetworkingIdentity m_identityPeer; int64_t m_nConnUserData; int64_t m_usecTimeReceived; int64_t m_nMessageNumber; void (*m_pfnFreeData)(SteamNetworkingMessage_t*); void (*m_pfnRelease)(SteamNetworkingMessage_t*); int32_t m_nChannel; int32_t m_nFlags; int64_t m_nUserData; uint16_t m_idxLane; uint16_t _pad1__; }; struct CallbackMsg_t { int32_t m_hSteamUser; /* HSteamUser */ int32_t m_iCallback; uint8_t* m_pubParam; int32_t m_cubParam; }; #endif /* VWE_STEAM_USE_SDK_HEADERS */ /* m_eType is an int here and a Valve enum in the SDK build; the casts keep one * body compiling both ways. */ static inline void IdentitySetSteamID(SteamNetworkingIdentity& id, uint64_t steamId) { memset(&id, 0, sizeof(id)); id.m_eType = static_cast(IDENTITY_TYPE_STEAMID); id.m_cbSize = static_cast(sizeof(uint64_t)); id.m_steamID64 = steamId; } static inline uint64_t IdentityGetSteamID(const SteamNetworkingIdentity& id) { if ((int)id.m_eType != IDENTITY_TYPE_STEAMID) return 0; return id.m_steamID64; } } // namespace sdkabi /* ================================================================== * * LAYOUT IS LOAD-BEARING -- READ BEFORE TOUCHING THE STRUCTS ABOVE. * * steam_api64.dll allocates SteamNetworkingMessage_t and the callback * payloads; we only index into them. A wrong offset is not a compile * error and not a crash, it is silent memory corruption inside a * process that runs all evening. * * Layout transcribed from Steamworks SDK 1.62 (steamnetworkingtypes.h, * steamclientpublic.h, isteamclient.h), x86-64. To CHECK it against the * real headers rather than trust the transcription, build this file with * -DVWE_STEAM_USE_SDK_HEADERS -I/public -- the asserts below then * run against Valve's own declarations and any drift fails the build. * ================================================================== */ #ifdef __GNUC__ #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Winvalid-offsetof" /* SDK structs carry methods */ #endif static_assert(sizeof(void*) == 8, "steam_api64.dll is x86-64 -- build steamlink.cpp 64-bit"); static_assert(sizeof(sdkabi::SteamNetworkingIdentity) == 136, "SteamNetworkingIdentity must be 136 bytes"); static_assert(offsetof(sdkabi::SteamNetworkingMessage_t, m_identityPeer) == 16, "SteamNetworkingMessage_t::m_identityPeer must sit at offset 16"); static_assert(offsetof(sdkabi::SteamNetworkingMessage_t, m_pfnRelease) == 184, "SteamNetworkingMessage_t tail drifted -- re-check against the SDK"); static_assert(sizeof(sdkabi::SteamNetworkingMessage_t) == 216, "SteamNetworkingMessage_t must be 216 bytes"); static_assert(sizeof(sdkabi::CallbackMsg_t) == 24, "CallbackMsg_t must be 24 bytes"); #ifdef VWE_STEAM_USE_SDK_HEADERS static_assert((int)CB_SESSION_REQUEST == (int)SteamNetworkingMessagesSessionRequest_t::k_iCallback, ""); static_assert((int)CB_SESSION_FAILED == (int)SteamNetworkingMessagesSessionFailed_t::k_iCallback, ""); static_assert((int)CB_LOBBY_CHAT_UPDATE == (int)LobbyChatUpdate_t::k_iCallback, ""); static_assert((int)IDENTITY_TYPE_STEAMID == (int)k_ESteamNetworkingIdentityType_SteamID, ""); static_assert((int)SEND_FLAGS == (int)(k_nSteamNetworkingSend_Reliable | k_nSteamNetworkingSend_NoNagle), ""); #endif #ifdef __GNUC__ #pragma GCC diagnostic pop #endif using sdkabi::SteamNetworkingIdentity; using sdkabi::SteamNetworkingMessage_t; using sdkabi::CallbackMsg_t; /* ------------------------------------------------------------------ * * Runtime-bound entry points (flat C exports of steam_api64.dll -- the * same surface every non-C++ language binding consumes, and the only * one that is stable enough to GetProcAddress). * ------------------------------------------------------------------ */ static int (__cdecl *SteamInitFlat)(char* errmsg) = 0; /* 0 == OK */ static bool (__cdecl *SteamInitOld)(void) = 0; /* pre-1.59 fallback */ static void (__cdecl *SteamShutdown)(void) = 0; static int32_t (__cdecl *SteamGetPipe)(void) = 0; static void (__cdecl *DispatchInit)(void) = 0; static void (__cdecl *DispatchRunFrame)(int32_t pipe) = 0; static bool (__cdecl *DispatchGetNext)(int32_t pipe, CallbackMsg_t* out) = 0; static void (__cdecl *DispatchFreeLast)(int32_t pipe) = 0; static void* (__cdecl *GetMessagesIface)(void) = 0; static int (__cdecl *MsgSendToUser)(void* self, const SteamNetworkingIdentity* to, const void* data, uint32_t len, int flags, int channel) = 0; static int (__cdecl *MsgReceiveOnChannel)(void* self, int channel, SteamNetworkingMessage_t** out, int max) = 0; static bool (__cdecl *MsgAcceptSession)(void* self, const SteamNetworkingIdentity* who) = 0; static bool (__cdecl *MsgCloseSession)(void* self, const SteamNetworkingIdentity* who) = 0; static void (__cdecl *MsgRelease)(SteamNetworkingMessage_t* msg) = 0; static void* (__cdecl *GetUserIface)(void) = 0; static uint64_t (__cdecl *UserGetSteamID)(void* self) = 0; /* optional -- absence degrades to "no lobby roster", never a failed Init */ static void* (__cdecl *GetMatchmakingIface)(void) = 0; static uint64_t (__cdecl *LobbyJoin)(void* self, uint64_t lobby) = 0; static int (__cdecl *LobbyNumMembers)(void* self, uint64_t lobby) = 0; static uint64_t (__cdecl *LobbyMemberByIndex)(void* self, uint64_t lobby, int i) = 0; /* optional -- route diagnostics only (SetRouteDiagnostics) */ typedef void (__cdecl *SteamDebugOutputFn)(int level, const char* msg); static void* (__cdecl *GetUtilsIface)(void) = 0; static void (__cdecl *UtilsSetDebugOutput)(void* self, int level, SteamDebugOutputFn fn) = 0; /* Version-suffixed accessors change between SDK releases, so try an ordered * list and log which one bound -- that log line is the first thing to look at * when a new Steam client ships and Init suddenly fails. */ static const char* const kUserNames[] = { "SteamAPI_SteamUser_v023", "SteamAPI_SteamUser_v022", "SteamAPI_SteamUser_v021" }; static const char* const kMessagesNames[] = { "SteamAPI_SteamNetworkingMessages_SteamAPI_v002" }; static const char* const kMatchmakingNames[] = { "SteamAPI_SteamMatchmaking_v009" }; /* Only used by SetRouteDiagnostics(). Optional everywhere -- absence costs a * diagnostic, not the link. */ static const char* const kUtilsNames[] = { "SteamAPI_SteamNetworkingUtils_SteamAPI_v004", "SteamAPI_SteamNetworkingUtils_SteamAPI_v003" }; static FARPROC BindFirst(HMODULE dll, const char* const* names, size_t count, const char** bound) { for (size_t i = 0; i < count; i++) { FARPROC p = GetProcAddress(dll, names[i]); if (p) { *bound = names[i]; return p; } } *bound = 0; return 0; } static bool g_bound = false; static std::string g_bindErr; /* Same idiom as LoadPcapLibrary(); one macro deep because there are twenty of * these and a hand-spelled cast per entry point is where a signature typo * hides. decltype keeps the cast welded to the declaration above. */ #define BIND_ONE(ptr, name) \ do { ptr = (decltype(ptr))GetProcAddress(dll, (name)); } while (0) #define BIND_ANY(ptr, names, what) \ do { const char* _w = 0; \ ptr = (decltype(ptr))BindFirst(dll, names, sizeof(names)/sizeof(names[0]), &_w); \ if (_w) Log("STEAM bound %s", _w); \ else Log("STEAM no %s accessor in steam_api64.dll", what); } while (0) static bool BindSteamApi(std::string& err) { /* Cache the handle forever, like LoadPcapLibrary: -1 = never tried, * NULL = tried and failed (don't thrash LoadLibrary on every retry). */ static HMODULE dll = (HMODULE)-1; if (dll != (HMODULE)-1) { err = g_bindErr; return g_bound; } dll = LoadLibraryA("steam_api64.dll"); if (!dll) { g_bindErr = "steam_api64.dll not found -- copy the Steamworks redistributable " "(redistributable_bin/win64) next to the executable"; err = g_bindErr; return false; } #ifdef __MINGW32__ // C++ defines function and data pointers as separate types to reflect // Harvard architecture machines (like the Arduino). As such, casting // between them isn't portable and GCC will helpfully warn us about it. // We're only running this code on Windows which explicitly allows this // behaviour, so silence the warning to avoid confusion. #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wcast-function-type" #endif BIND_ONE(SteamInitFlat, "SteamAPI_InitFlat"); BIND_ONE(SteamInitOld, "SteamAPI_Init"); BIND_ONE(SteamShutdown, "SteamAPI_Shutdown"); BIND_ONE(SteamGetPipe, "SteamAPI_GetHSteamPipe"); BIND_ONE(DispatchInit, "SteamAPI_ManualDispatch_Init"); BIND_ONE(DispatchRunFrame, "SteamAPI_ManualDispatch_RunFrame"); BIND_ONE(DispatchGetNext, "SteamAPI_ManualDispatch_GetNextCallback"); BIND_ONE(DispatchFreeLast, "SteamAPI_ManualDispatch_FreeLastCallback"); BIND_ONE(MsgSendToUser, "SteamAPI_ISteamNetworkingMessages_SendMessageToUser"); BIND_ONE(MsgReceiveOnChannel, "SteamAPI_ISteamNetworkingMessages_ReceiveMessagesOnChannel"); BIND_ONE(MsgAcceptSession, "SteamAPI_ISteamNetworkingMessages_AcceptSessionWithUser"); BIND_ONE(MsgCloseSession, "SteamAPI_ISteamNetworkingMessages_CloseSessionWithUser"); BIND_ONE(MsgRelease, "SteamAPI_SteamNetworkingMessage_t_Release"); BIND_ONE(UserGetSteamID, "SteamAPI_ISteamUser_GetSteamID"); BIND_ONE(LobbyJoin, "SteamAPI_ISteamMatchmaking_JoinLobby"); BIND_ONE(LobbyNumMembers, "SteamAPI_ISteamMatchmaking_GetNumLobbyMembers"); BIND_ONE(LobbyMemberByIndex, "SteamAPI_ISteamMatchmaking_GetLobbyMemberByIndex"); BIND_ANY(GetMessagesIface, kMessagesNames, "ISteamNetworkingMessages"); BIND_ANY(GetUserIface, kUserNames, "ISteamUser"); BIND_ANY(GetMatchmakingIface, kMatchmakingNames, "ISteamMatchmaking"); BIND_ANY(GetUtilsIface, kUtilsNames, "ISteamNetworkingUtils"); BIND_ONE(UtilsSetDebugOutput, "SteamAPI_ISteamNetworkingUtils_SetDebugOutputFunction"); #ifdef __MINGW32__ #pragma GCC diagnostic pop #endif /* Matchmaking is deliberately NOT in this list: no lobby is a degraded * mode (peers= still works), a missing transport export is not. */ struct { const void* p; const char* what; } required[] = { { (const void*)SteamShutdown, "SteamAPI_Shutdown" }, { (const void*)SteamGetPipe, "SteamAPI_GetHSteamPipe" }, { (const void*)DispatchInit, "SteamAPI_ManualDispatch_Init" }, { (const void*)DispatchRunFrame, "SteamAPI_ManualDispatch_RunFrame" }, { (const void*)DispatchGetNext, "SteamAPI_ManualDispatch_GetNextCallback" }, { (const void*)DispatchFreeLast, "SteamAPI_ManualDispatch_FreeLastCallback" }, { (const void*)GetMessagesIface, "SteamAPI_SteamNetworkingMessages_SteamAPI_v002" }, { (const void*)MsgSendToUser, "SendMessageToUser" }, { (const void*)MsgReceiveOnChannel, "ReceiveMessagesOnChannel" }, { (const void*)MsgAcceptSession, "AcceptSessionWithUser" }, { (const void*)MsgCloseSession, "CloseSessionWithUser" }, { (const void*)MsgRelease, "SteamNetworkingMessage_t_Release" }, { (const void*)GetUserIface, "ISteamUser accessor" }, { (const void*)UserGetSteamID, "ISteamUser_GetSteamID" } }; for (size_t i = 0; i < sizeof(required)/sizeof(required[0]); i++) { if (!required[i].p) { g_bindErr = std::string("steam_api64.dll is missing ") + required[i].what + " -- SDK too old for the flat NetworkingMessages API (need 1.46+)"; err = g_bindErr; return false; } } if (!SteamInitFlat && !SteamInitOld) { g_bindErr = "steam_api64.dll exports neither SteamAPI_InitFlat nor SteamAPI_Init"; err = g_bindErr; return false; } g_bound = true; return true; } /* ------------------------------------------------------------------ * * Link state. Single-threaded by contract (see steamlink.h), so no locks * and no atomics anywhere below. * ------------------------------------------------------------------ */ struct Peer { uint64_t steamId; uint8_t mac[6]; bool macKnown; uint32_t ip; /* host order */ char name[16]; bool sessionUp; bool isGateway; /* the session host: default route for unknown unicast */ uint64_t framesTx, framesRx; uint32_t helloSentMs; /* 0 = never sent */ uint32_t helloHeardMs; /* 0 = never answered -- keeps it on the fast ladder */ bool fromLobby; /* lobby-learned: a lobby leave may retire it */ }; enum { HELLO_LEN = 28, /* 'H' ver mac[6] ip[4] name[16] */ HELLO_FAST_MS = 2000, /* until the peer has answered */ HELLO_SLOW_MS = 30000, /* after */ HELLO_REPLY_MIN_MS = 1000, /* floor on answering a peer's HELLO */ LOBBY_POLL_MS = 2000, RX_BATCH = 16 }; static bool g_up = false; static int g_refs = 0; static int32_t g_pipe = 0; static void* g_iMessages = 0; static void* g_iUser = 0; static void* g_iMatchmaking = 0; static uint64_t g_selfId = 0; static uint8_t g_selfMac[6] = { 0, 0, 0, 0, 0, 0 }; static uint32_t g_selfIp = 0; static char g_selfName[16] = { 0 }; static int g_channel = 0; static uint64_t g_lobby = 0; static uint32_t g_lobbyPolledMs = 0; static uint32_t g_epoch = 0; static uint64_t g_tx = 0, g_rx = 0, g_txDropped = 0; /* std::map, not unordered_map: the peer set is <=7 entries and the gateway * builds a BPF string out of Peers(), which is easier to eyeball and diff when * the order is stable. */ static std::map g_peers; /* Refused identities, so a stranger hammering us costs one log line total. */ static std::set g_refused; /* 32-bit like the rest of the fork (vpxlog.cpp): every elapsed test below is * an unsigned 32-bit subtraction, which is exact across the 49.7-day wrap. * GetTickCount64 is deliberately NOT used -- it disappears when a build pins * _WIN32_WINNT below 0x0600, which parts of this tree do. */ static inline uint32_t NowMs() { return (uint32_t)GetTickCount(); } static Peer* Find(uint64_t steamId) { std::map::iterator it = g_peers.find(steamId); return (it == g_peers.end()) ? 0 : &it->second; } static Peer* FindByMac(const uint8_t mac[6]) { for (std::map::iterator it = g_peers.begin(); it != g_peers.end(); ++it) if (it->second.macKnown && memcmp(it->second.mac, mac, 6) == 0) return &it->second; return 0; } /* The session host, i.e. the default route for unicast we can't place. Null in * a gateway-less session (two players and no console), which is why every * caller falls back to flooding rather than dropping. */ static Peer* FindGateway(void) { for (std::map::iterator it = g_peers.begin(); it != g_peers.end(); ++it) if (it->second.isGateway) return &it->second; return 0; } static const char* PeerName(const Peer& p) { return p.name[0] ? p.name : "?"; } /* ------------------------------------------------------------------ * * Wire * ------------------------------------------------------------------ */ static bool SendRecord(Peer& p, const uint8_t* rec, int len) { SteamNetworkingIdentity id; sdkabi::IdentitySetSteamID(id, p.steamId); /* A send to a peer we have no session with is what OPENS the session -- * the peer gets a 1251 and accepts if we are in its allowlist. */ const int r = MsgSendToUser(g_iMessages, &id, rec, (uint32_t)len, SEND_FLAGS, g_channel); if (r != RESULT_OK) { static unsigned long long errn = 0; if (LogNth(++errn)) Log("STEAM send error #%llu EResult=%d to %s (%llu)", (unsigned long long)errn, r, PeerName(p), (unsigned long long)p.steamId); return false; } return true; } static void SendHello(Peer& p, uint32_t now) { uint8_t rec[HELLO_LEN]; memset(rec, 0, sizeof(rec)); rec[0] = REC_HELLO; rec[1] = 1; /* version */ memcpy(rec + 2, g_selfMac, 6); rec[8] = (uint8_t)(g_selfIp >> 24); /* BIG-endian on the wire */ rec[9] = (uint8_t)(g_selfIp >> 16); rec[10] = (uint8_t)(g_selfIp >> 8); rec[11] = (uint8_t)(g_selfIp); memcpy(rec + 12, g_selfName, 15); /* rec is zeroed => NUL-terminated */ p.helloSentMs = now; SendRecord(p, rec, (int)sizeof(rec)); } /* ------------------------------------------------------------------ * * Peer table * ------------------------------------------------------------------ */ static void AddPeerInternal(uint64_t steamId, bool fromLobby) { if (!g_up || !steamId || steamId == g_selfId) return; if (g_peers.find(steamId) != g_peers.end()) return; Peer p; memset(&p, 0, sizeof(p)); p.steamId = steamId; p.fromLobby = fromLobby; g_peers[steamId] = p; g_refused.erase(steamId); /* allowlisted now; let it be logged again if dropped */ Log("STEAM peer %llu added%s", (unsigned long long)steamId, fromLobby ? " (lobby)" : ""); /* HELLO immediately: it opens the session AND carries our MAC binding. */ SendHello(g_peers[steamId], NowMs()); } void AddPeer(uint64_t steamId) { AddPeerInternal(steamId, false); } void RemovePeer(uint64_t steamId) { std::map::iterator it = g_peers.find(steamId); if (it == g_peers.end()) return; const bool hadMac = it->second.macKnown; if (g_iMessages && MsgCloseSession) { SteamNetworkingIdentity id; sdkabi::IdentitySetSteamID(id, steamId); MsgCloseSession(g_iMessages, &id); } Log("STEAM peer %s (%llu) removed", PeerName(it->second), (unsigned long long)steamId); g_peers.erase(it); if (hadMac) g_epoch++; } /* ------------------------------------------------------------------ * * Callbacks (manual dispatch) * ------------------------------------------------------------------ */ /* Lift the leading SteamNetworkingIdentity out of a callback payload. * memcpy, not a cast: the payload is a uint8_t* into steam_api64.dll's own * callback buffer, so it carries no alignment promise (same reasoning as * OnLobbyChatUpdate below). It also must not be handed straight back to the * SDK -- the flat API takes the identity BY VALUE, which on the Win64 ABI is * a pointer to a caller-owned temporary the callee is free to write to, and * that buffer is Steam's, not ours. Copy first, pass the copy. */ static bool IdentityFromCallback(const CallbackMsg_t& cb, SteamNetworkingIdentity& out) { if (!cb.m_pubParam || cb.m_cubParam < (int)sizeof(SteamNetworkingIdentity)) return false; memcpy(&out, cb.m_pubParam, sizeof(out)); return true; } static void OnSessionRequest(const CallbackMsg_t& cb) { SteamNetworkingIdentity ident; if (!IdentityFromCallback(cb, ident)) return; const SteamNetworkingIdentity* id = &ident; const uint64_t who = sdkabi::IdentityGetSteamID(*id); Peer* p = Find(who); if (!p) { /* App 480 is public, so anyone who learns our SteamID can ring the * bell; refusing everything outside the session's peer list IS the * security story for a private group. Nothing else gates inbound. */ if (g_refused.insert(who).second) Log("STEAM session request from %llu REFUSED (not in peer list)", (unsigned long long)who); return; } if (!MsgAcceptSession(g_iMessages, id)) { Log("STEAM AcceptSessionWithUser failed for %llu", (unsigned long long)who); return; } p->sessionUp = true; Log("STEAM session accepted from %s (%llu)", PeerName(*p), (unsigned long long)who); /* A fresh inbound session means that peer just (re)started: HELLO now so it * binds our MAC without waiting out its own ladder. */ SendHello(*p, NowMs()); } static void OnSessionFailed(const CallbackMsg_t& cb) { /* Payload is a SteamNetConnectionInfo_t whose FIRST member is the peer * identity. We read exactly that and nothing past it -- the tail of that * struct has grown between SDK releases. */ SteamNetworkingIdentity ident; if (!IdentityFromCallback(cb, ident)) return; const SteamNetworkingIdentity* id = &ident; const uint64_t who = sdkabi::IdentityGetSteamID(*id); Peer* p = Find(who); if (!p) return; /* Close it explicitly: a failed session stays failed, and every later * SendMessageToUser fails instantly until the session is torn down. This * call is what makes the re-announce below an actual retry. */ if (MsgCloseSession) MsgCloseSession(g_iMessages, id); p->sessionUp = false; p->helloHeardMs = 0; /* back onto the 2s ladder... */ p->helloSentMs = 0; /* ...starting with the next Pump */ Log("STEAM session FAILED with %s (%llu) -- re-announcing", PeerName(*p), (unsigned long long)who); } static void OnLobbyChatUpdate(const CallbackMsg_t& cb) { /* LobbyChatUpdate_t: u64 lobby, u64 userChanged, u64 userMakingChange, * u32 stateChange. memcpy because the callback buffer is only byte-aligned. */ if (!g_lobby || !cb.m_pubParam || cb.m_cubParam < 28) return; uint64_t lobby = 0, user = 0; uint32_t state = 0; memcpy(&lobby, cb.m_pubParam + 0, 8); memcpy(&user, cb.m_pubParam + 8, 8); memcpy(&state, cb.m_pubParam + 24, 4); if (lobby != g_lobby || !user || user == g_selfId) return; if (state & CHAT_MEMBER_ENTERED) { AddPeerInternal(user, true); } else { /* Only lobby-learned peers leave with the lobby: the session file's * peer list is the allowlist and stays put for the whole session. */ Peer* p = Find(user); if (p && p->fromLobby) RemovePeer(user); } } static void ServiceHellos(uint32_t now) { for (std::map::iterator it = g_peers.begin(); it != g_peers.end(); ++it) { Peer& p = it->second; const uint32_t due = p.helloHeardMs ? (uint32_t)HELLO_SLOW_MS : (uint32_t)HELLO_FAST_MS; if (!p.helloSentMs || (uint32_t)(now - p.helloSentMs) >= due) SendHello(p, now); } } static void ServiceLobby(uint32_t now) { if (!g_lobby || !g_iMatchmaking || !LobbyNumMembers || !LobbyMemberByIndex) return; if (g_lobbyPolledMs && (uint32_t)(now - g_lobbyPolledMs) < (uint32_t)LOBBY_POLL_MS) return; g_lobbyPolledMs = now; /* JoinLobby is asynchronous and we intentionally await no call result (see * Pump), so the initial roster arrives by polling; 506 handles the deltas * after that. Polling a 2-8 entry list every 2s costs nothing. */ const int n = LobbyNumMembers(g_iMatchmaking, g_lobby); for (int i = 0; i < n; i++) { const uint64_t id = LobbyMemberByIndex(g_iMatchmaking, g_lobby, i); if (id && id != g_selfId && !Find(id)) AddPeerInternal(id, true); } } /* ------------------------------------------------------------------ * * API * ------------------------------------------------------------------ */ bool Init(const Config& cfg, const uint8_t self_mac[6], uint32_t self_ip, std::string& err) { err.clear(); if (g_up) { /* Refcounted per the header. A second Init only widens the peer set -- * the MAC/IP/name of the first caller win, because they are the guest's * and there is only one guest per process. */ g_refs++; for (size_t i = 0; i < cfg.peers.size(); i++) AddPeerInternal(cfg.peers[i], false); return true; } if (!self_mac) { err = "steam: self MAC is required"; return false; } if (!BindSteamApi(err)) return false; if (SteamInitFlat) { char msg[1024]; msg[0] = 0; const int r = SteamInitFlat(msg); if (r != 0) { err = std::string("SteamAPI_Init failed (") + std::to_string(r) + "): " + (msg[0] ? msg : "is Steam running and logged in? SteamAppId must be set (480)"); return false; } } else if (!SteamInitOld()) { err = "SteamAPI_Init failed -- is Steam running and logged in? " "SteamAppId must be set (480)"; return false; } DispatchInit(); /* we own the pump; see Pump() */ g_pipe = SteamGetPipe(); if (!g_pipe) { err = "SteamAPI_GetHSteamPipe returned 0"; SteamShutdown(); return false; } g_iMessages = GetMessagesIface(); if (!g_iMessages) { err = "ISteamNetworkingMessages unavailable"; SteamShutdown(); return false; } g_iUser = GetUserIface(); g_selfId = g_iUser ? UserGetSteamID(g_iUser) : 0; if (!g_selfId) { err = "Steam user is not logged in (no SteamID)"; SteamShutdown(); return false; } g_iMatchmaking = GetMatchmakingIface ? GetMatchmakingIface() : 0; memcpy(g_selfMac, self_mac, 6); g_selfIp = self_ip; g_channel = cfg.channel; g_lobby = cfg.lobby; memset(g_selfName, 0, sizeof(g_selfName)); if (!cfg.name.empty()) memcpy(g_selfName, cfg.name.c_str(), cfg.name.size() < 15 ? cfg.name.size() : 15); g_lobbyPolledMs = 0; g_tx = g_rx = g_txDropped = 0; g_peers.clear(); g_refused.clear(); g_up = true; g_refs = 1; Log("STEAM link up: self %llu %02x:%02x:%02x:%02x:%02x:%02x %u.%u.%u.%u \"%s\" channel %d", (unsigned long long)g_selfId, g_selfMac[0], g_selfMac[1], g_selfMac[2], g_selfMac[3], g_selfMac[4], g_selfMac[5], (g_selfIp >> 24) & 0xff, (g_selfIp >> 16) & 0xff, (g_selfIp >> 8) & 0xff, g_selfIp & 0xff, g_selfName, g_channel); /* The pod MAC is derived from the slot IP by configure.ps1's formula; a * mismatch means a hand-edited conf and shows up later as a peer nobody can * route to, so say it once at Init instead. */ if (g_selfIp && (g_selfMac[0] != 0x02 || g_selfMac[1] != 0x00 || g_selfMac[2] != (uint8_t)(g_selfIp >> 24) || g_selfMac[3] != (uint8_t)(g_selfIp >> 16) || g_selfMac[4] != (uint8_t)(g_selfIp >> 8) || g_selfMac[5] != (uint8_t)(g_selfIp))) Log("STEAM WARNING: macaddr is not 02:00:+IP octets -- check [ne2000] macaddr against the slot IP"); for (size_t i = 0; i < cfg.peers.size(); i++) AddPeerInternal(cfg.peers[i], false); /* The gateway is a peer like any other -- it just also serves as the * default route. Seed it even if it was left out of cfg.peers, otherwise a * session file listing it only as the gateway would have nowhere to send * console traffic. */ if (cfg.gateway) { AddPeerInternal(cfg.gateway, false); Peer* gw = Find(cfg.gateway); if (gw) { gw->isGateway = true; Log("STEAM gateway (session host) = %llu -- default route for non-peer MACs", (unsigned long long)cfg.gateway); } } else { Log("STEAM no gateway configured -- unicast to an unknown MAC will flood every peer"); } if (g_lobby) { if (g_iMatchmaking && LobbyJoin) { LobbyJoin(g_iMatchmaking, g_lobby); Log("STEAM joining lobby %llu for the roster", (unsigned long long)g_lobby); } else { Log("STEAM lobby %llu ignored -- no ISteamMatchmaking in this steam_api64.dll", (unsigned long long)g_lobby); } } if (g_peers.empty() && !g_lobby) Log("STEAM WARNING: no peers configured -- nothing will be sent"); return true; } void Shutdown() { if (!g_up) return; if (--g_refs > 0) return; bool hadMacs = false; for (std::map::iterator it = g_peers.begin(); it != g_peers.end(); ++it) { hadMacs = hadMacs || it->second.macKnown; if (MsgCloseSession) { SteamNetworkingIdentity id; sdkabi::IdentitySetSteamID(id, it->first); MsgCloseSession(g_iMessages, &id); } } g_peers.clear(); g_refused.clear(); if (hadMacs) g_epoch++; Log("STEAM link down (tx %llu rx %llu dropped %llu)", (unsigned long long)g_tx, (unsigned long long)g_rx, (unsigned long long)g_txDropped); g_up = false; g_iMessages = g_iUser = g_iMatchmaking = 0; g_selfId = 0; SteamShutdown(); /* The DLL handle stays loaded on purpose: Steam does not survive a * LoadLibrary/FreeLibrary cycle, and the pcap backend caches its handle the * same way. */ } void Pump() { if (!g_up) return; /* The documented manual-dispatch loop. Manual dispatch (rather than * SteamAPI_RunCallbacks) is what lets the fork service Steam from the * emulator thread inside NE2000_Poller -- no callback registration, no * second thread, no timer. */ DispatchRunFrame(g_pipe); CallbackMsg_t cb; while (DispatchGetNext(g_pipe, &cb)) { switch (cb.m_iCallback) { case CB_SESSION_REQUEST: OnSessionRequest(cb); break; case CB_SESSION_FAILED: OnSessionFailed(cb); break; case CB_LOBBY_CHAT_UPDATE: OnLobbyChatUpdate(cb); break; default: break; /* incl. SteamAPICallCompleted_t: nothing here awaits * a call result, so there is no GetAPICallResult arm */ } DispatchFreeLast(g_pipe); /* every GetNextCallback that returned true */ } const uint32_t now = NowMs(); ServiceHellos(now); ServiceLobby(now); } void SendFrame(const uint8_t* frame, int len) { if (!g_up) return; if (!frame || len < 14 || len > (int)VWE_MAX_FRAME) { g_txDropped++; if (LogNth(g_txDropped)) Log("STEAM TX DROP #%llu len=%d (not 14..%d)", (unsigned long long)g_txDropped, len, (int)VWE_MAX_FRAME); return; } /* One record = ['F'][frame]. Single-threaded by contract, so one static * staging buffer beats a per-frame allocation on a 1 kHz path. */ static uint8_t rec[1 + VWE_MAX_FRAME]; rec[0] = REC_FRAME; memcpy(rec + 1, frame, (size_t)len); const int reclen = len + 1; int tried = 0, sent = 0; Peer* dest = 0; if ((frame[0] & 1) == 0) { /* unicast */ dest = FindByMac(frame); /* Not a peer MAC: this is almost certainly console-bound (200.0.0.10 * lives on the host LAN behind the gateway, so its NIC MAC can never * appear in the peer table). Route it to the gateway rather than * flooding -- flooding would put the whole console<->pod stream, the * egg transfer included, on every other player's uplink. */ if (!dest) dest = FindGateway(); } if (dest) { tried = 1; if (SendRecord(*dest, rec, reclen)) { sent = 1; dest->framesTx++; } } else { /* Broadcast/multicast (ARP must reach everyone), or unicast with no * gateway configured -- a gateway-less session, or the narrow race * before a peer's HELLO has bound its MAC. */ for (std::map::iterator it = g_peers.begin(); it != g_peers.end(); ++it) { tried++; if (SendRecord(it->second, rec, reclen)) { sent++; it->second.framesTx++; } } } if (sent) { g_tx++; if (LogNth(g_tx)) Log("STEAM TX #%llu len=%d dst=%02x:%02x:%02x src=%02x:%02x:%02x peers=%d", (unsigned long long)g_tx, len, frame[0], frame[1], frame[2], frame[6], frame[7], frame[8], sent); } else { g_txDropped++; if (LogNth(g_txDropped)) Log("STEAM TX DROP #%llu len=%d dst=%02x:%02x:%02x (%s)", (unsigned long long)g_txDropped, len, frame[0], frame[1], frame[2], tried ? "no peer took it" : "no peers"); } } /* Returns 1 if a frame reached the callback. ALWAYS releases the message -- * every early-out below leaks a Steam allocation otherwise, and this runs a * thousand times a second for a whole evening. */ static int HandleMessage(SteamNetworkingMessage_t* m, const std::function& cb) { if (!m) return 0; int delivered = 0; const uint8_t* d = (const uint8_t*)m->m_pData; const int len = m->m_cbSize; const uint64_t who = sdkabi::IdentityGetSteamID(m->m_identityPeer); Peer* p = Find(who); if (!p) { /* Only allowlisted identities get a session, so this is a peer we * dropped mid-flight -- or an SDK identity type we do not speak. */ static unsigned long long strayn = 0; if (LogNth(++strayn)) Log("STEAM RX from unknown identity %llu discarded (#%llu)", (unsigned long long)who, (unsigned long long)strayn); } else if (!d || len < 1) { /* Throttled like every other junk path: these are driven by the PEER's * send rate, and a version-skewed pod re-HELLOs every 2s for the whole * mission. Powers of 2 still log the first one loudly. */ static unsigned long long emptyn = 0; if (LogNth(++emptyn)) Log("STEAM RX empty record from %s (#%llu)", PeerName(*p), (unsigned long long)emptyn); } else if (d[0] == REC_HELLO) { if (len < HELLO_LEN || d[1] != 1) { static unsigned long long badhn = 0; if (LogNth(++badhn)) Log("STEAM RX bad HELLO from %llu (len=%d ver=%d) (#%llu)", (unsigned long long)who, len, len >= 2 ? d[1] : -1, (unsigned long long)badhn); } else { const uint32_t now = NowMs(); p->sessionUp = true; p->helloHeardMs = now; p->ip = ((uint32_t)d[8] << 24) | ((uint32_t)d[9] << 16) | ((uint32_t)d[10] << 8) | (uint32_t)d[11]; memcpy(p->name, d + 12, 16); p->name[15] = 0; if (!p->macKnown || memcmp(p->mac, d + 2, 6) != 0) { memcpy(p->mac, d + 2, 6); p->macKnown = true; g_epoch++; /* the gateway recompiles its BPF off this */ Log("STEAM peer %s (%llu) is %02x:%02x:%02x:%02x:%02x:%02x %u.%u.%u.%u", PeerName(*p), (unsigned long long)who, p->mac[0], p->mac[1], p->mac[2], p->mac[3], p->mac[4], p->mac[5], (p->ip >> 24) & 0xff, (p->ip >> 16) & 0xff, (p->ip >> 8) & 0xff, p->ip & 0xff); } /* Answer a HELLO we were not going to send for another 30s: a peer * that just restarted has an empty table and needs our binding now. * The 1s floor is what keeps two peers from ping-ponging forever. */ if ((uint32_t)(now - p->helloSentMs) >= (uint32_t)HELLO_REPLY_MIN_MS) SendHello(*p, now); } } else if (d[0] == REC_FRAME) { const int flen = len - 1; if (flen < 14) { static unsigned long long runtn = 0; if (LogNth(++runtn)) Log("STEAM RX runt frame from %s len=%d (#%llu)", PeerName(*p), flen, (unsigned long long)runtn); } else { p->sessionUp = true; if (!p->macKnown) { /* Fallback binding for a HELLO that raced past us. HELLO stays * authoritative -- it can correct this, this never overwrites it * (a relayed frame's src MAC is not necessarily the peer's). */ memcpy(p->mac, d + 1 + 6, 6); p->macKnown = true; g_epoch++; Log("STEAM peer %llu src-MAC learned %02x:%02x:%02x:%02x:%02x:%02x (no HELLO yet)", (unsigned long long)who, p->mac[0], p->mac[1], p->mac[2], p->mac[3], p->mac[4], p->mac[5]); } p->framesRx++; g_rx++; if (LogNth(g_rx)) Log("STEAM RX #%llu len=%d dst=%02x:%02x:%02x src=%02x:%02x:%02x from=%s", (unsigned long long)g_rx, flen, d[1], d[2], d[3], d[7], d[8], d[9], PeerName(*p)); cb(d + 1, flen); delivered = 1; } } else { static unsigned long long unkn = 0; if (LogNth(++unkn)) Log("STEAM RX unknown record type 0x%02x from %s (#%llu)", d[0], PeerName(*p), (unsigned long long)unkn); } MsgRelease(m); return delivered; } int PollFrames(const std::function& cb) { if (!g_up) return 0; int delivered = 0; SteamNetworkingMessage_t* msgs[RX_BATCH]; for (;;) { const int n = MsgReceiveOnChannel(g_iMessages, g_channel, msgs, RX_BATCH); if (n <= 0) break; for (int i = 0; i < n; i++) delivered += HandleMessage(msgs[i], cb); if (n < RX_BATCH) break; /* short batch == queue drained */ } return delivered; } uint64_t SelfSteamID() { return g_selfId; } /* Steam calls this from ITS OWN service thread, not ours. Log() must therefore * stay a plain sink write -- do not touch the peer table or any other state * from in here. */ static void __cdecl SteamDebugSink(int level, const char* msg) { Log("STEAM/net[%d] %s", level, msg ? msg : ""); } void SetRouteDiagnostics(int level) { if (!g_up) { Log("STEAM route diagnostics ignored -- link is not up"); return; } void* utils = GetUtilsIface ? GetUtilsIface() : 0; if (!utils || !UtilsSetDebugOutput) { Log("STEAM route diagnostics unavailable -- this steam_api64.dll has no " "ISteamNetworkingUtils::SetDebugOutputFunction"); return; } UtilsSetDebugOutput(utils, level, level > 0 ? SteamDebugSink : 0); Log("STEAM route diagnostics ON at level %d -- watch for the relay/direct path", level); } uint32_t PeerEpoch() { return g_epoch; } size_t Peers(std::vector& out) { out.clear(); out.reserve(g_peers.size()); for (std::map::const_iterator it = g_peers.begin(); it != g_peers.end(); ++it) { const Peer& p = it->second; PeerInfo pi; memset(&pi, 0, sizeof(pi)); pi.steamId = p.steamId; memcpy(pi.mac, p.mac, 6); pi.macKnown = p.macKnown; pi.ip = p.ip; memcpy(pi.name, p.name, sizeof(pi.name)); pi.name[sizeof(pi.name) - 1] = 0; pi.sessionUp = p.sessionUp; pi.isGateway = p.isGateway; pi.framesTx = p.framesTx; pi.framesRx = p.framesRx; out.push_back(pi); } return out.size(); } void Counters(uint64_t& tx, uint64_t& rx, uint64_t& txDropped) { tx = g_tx; rx = g_rx; txDropped = g_txDropped; } } // namespace vwesteam #else /* !WIN32 -- the whole VWE deployment is Windows; this keeps the file * harmless in a cross-platform build instead of breaking it. */ namespace vwesteam { bool Init(const Config&, const uint8_t*, uint32_t, std::string& err) { err = "steam transport is Windows-only in this build"; Log("%s", err.c_str()); return false; } void Shutdown() {} void Pump() {} void SendFrame(const uint8_t*, int) {} int PollFrames(const std::function&) { return 0; } uint64_t SelfSteamID() { return 0; } void SetRouteDiagnostics(int) {} uint32_t PeerEpoch() { return 0; } size_t Peers(std::vector& out) { out.clear(); return 0; } void AddPeer(uint64_t) {} void RemovePeer(uint64_t) {} void Counters(uint64_t& tx, uint64_t& rx, uint64_t& txDropped) { tx = rx = txDropped = 0; } } // namespace vwesteam #endif /* WIN32 */