VPX_PF_WATCH (fork, cpu/paging.cpp): on a guest page fault at the watched linear address, dump guest registers, the last 64 serial RX deliveries with guest cs:eip at each, the code bytes at the faulting EIP and the stack top. serialnamedpipe's doReceive feeds the ring. Armed in podrun.sh and launch_pod.ps1. The first catch decoded the residual fault completely: CS:EIP 00FF:000066D4 in the DPMI host, EBX = F000CA60 -- an IVT entry read as a dword, segment F000 offset CA60, a BIOS default interrupt handler -- and the faulting access is [EBX+0x3004], whose 0x80000000 segment-base wrap gives exactly cr2 7000FA64. The host probes a word 0x3004 bytes past a real-mode vector value treated as a flat pointer: harmless for its own low-memory handlers, a fault for BIOS F000:xxxx defaults. The serial ring shows a steady 1-byte/1-3ms vRIO stream with nothing special at the fault -- the stream determines which vectors get walked, not the crash itself. The 0x3004 appears nowhere in DPMI32VM.OVL or 32RTM.EXE as an immediate, so the probe now also dumps code bytes at EIP; faulthunt.sh loops runs until the next catch. Shipped baseline streak: 4/4 clean -- consistent with exposure, not yet discriminating. The inside view now loads the COCKPIT skeleton: the fleet-wide X-variant naming convention (MAD->MAX etc., all 64 skeletons present) selects the same 25-joint chain with a single object -- max_cop.bgf, the MAX_COP canopy shell with the PUNCH-texel windows from the capture forensics. The donor names the same mechanism from the decomp side (inside = SkeletonType_A with '_cop' selection). Fallback to the body skeleton when no X file exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
217 lines
9.8 KiB
C++
217 lines
9.8 KiB
C++
/*
|
|
* 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 <cstdint>
|
|
#include <cstddef>
|
|
#include <string>
|
|
#include <vector>
|
|
#include <functional>
|
|
|
|
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<uint64_t> 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<void(const uint8_t*, int)>& 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<PeerInfo>& 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
|