--- id: multiplayer title: "Multiplayer — replication, netcode, the console" status: provisional source_sections: "PROGRESS_LOG.md §7 (Phase 7), §8 (P6); docs/HARD_PROBLEMS.md (P6)" related_topics: [wintesla-port, combat-damage, locomotion, decomp-reference] key_terms: [master, replicant, EGG, dead-reckon] open_questions: - "Cross-pod combat (damage to a replicant's master); replicant gait; pod-LAN config" --- # Multiplayer Pod-to-pod networked play. The hard infrastructure exists (WinTesla replaced DOS `NETNUB` with a WinSock2 TCP stack); the work is integrating + smoke-testing it for BT. Full detail: `docs/PROGRESS_LOG.md §7, §8`; the problem writeup: `docs/HARD_PROBLEMS.md` (P6). ## The stack (already reconstructed) - WinTesla replaced `NETNUB` with a **3446-line WinSock2 TCP** reimplementation (`MUNGA_L4/L4NET.CPP`, `SOCK_STREAM`/ReliableMode, zero SOCK_DGRAM); the master/replicant distributed-sim core (InterestManager/Entity/HostManager) is complete. So MP = integrate + smoke-test that stack (gated on P3 locomotion + P5 entity lifecycle + subsystem WAVEs). The rumored `L4NET.CPP:1853` send-size bug is DEAD CODE (`#if MESSAGE_BUFFERING`==0). [T2] ## Master / replicant model - **[[master]]** = a locally-simulated entity (your mech + dummies); drives itself + EMITS update records. - **[[replicant]]** = a peer's mech, a local proxy; DeadReckons toward the master's replicated position. MUST `SetValidFlag()` at creation or every message defers forever ([[reconstruction-gotchas]] §9). ## How a session forms `-net ` = networked mode (the pod listens on for a CONSOLE, boots ConsoleOnly; game mesh = +1). Solo (no `-net`) never touches WinSock. The mission egg's `[pilots]` section IS the roster (`pilot=ip[:port]` + a per-address page); each pod self-identifies by local-IP+game-port, connects to earlier pilots, listens for later ones — a deterministic full mesh; the mission loads when all connect. The **CONSOLE** (operator station — ABSENT from every archive) delivers the egg as chunked `ReceiveEggFileMessage` packets + the LAUNCH `RunMissionMessage`; **`tools/btconsole.py` is our console emulator** (⚠ `NotationFile::ReadText` expects NUL-SEPARATED lines). [T2] ## Verified milestones (one box, two instances) - **P6 smoke test:** two instances share a world — console egg → TCP mesh → synchronized start → bidirectional entity replication (each renders its own mech + the peer's REPLICANT), 0 crashes. - **Movement replication:** A's mech WALKS on B's screen (the replicant tracks the master to ~1.5u via dead-reckoning). The authentic mission-start ladder runs (CreatingMission→…→RunningMission via the console LAUNCH). SIX bugs fixed to get here (dead-reckoner install, replicant-motion DeadReckon, master emission threshold, emission gated on RunningMission, the console-must-LAUNCH fact, replicant validity). [T2] - **Wire-format bug class found+fixed:** MakeMessages replicate RAW over TCP, so string payload must be INLINE (`char[N]` at the binary offsets), not a `const char*` pointer (garbage cross-pod). Check EVERY MakeMessage for pointer payloads. [T2] ## Debug tooling (`BT_NET_TRACE`, permanent) `[net-tx]/[net-rx]` (L4NET), `[net-upd]` (update lookup), `[upd-repl]`, `[ent-exec]` (state ladder). Per-instance: `BT_LOG=` + `BT_AFFINITY=` (CPU pin). Update stream ≈ 60 Hz × 144-byte records per moving master. [T2] ## Cross-pod combat step 1+2 (task #46, 2026-07-09) [T2 findings] **Step 1 — target ANY peer mech: DONE + verified.** A live-mech registry (`BTRegisterMech`/ `BTDeregisterMech` from the Mech ctor/dtor, btplayer.cpp) collects every Mech — player, dummy, AND peer replicants. The mech4 boresight world-pick now walks `BTGetTargetCandidates` (all mechs ≠ shooter) and picks the CLOSEST the ray strikes, retargeting the whole fire/damage block from the solo `gEnemyMech` to the picked `hotTarget` (missile impact + projectile validation generalised via `BTIsRegisteredMech`). Verified one-box: instance A correctly enumerates B's mech as a live `ReplicantInstance` (inst=4), 20 zones, `ownerID=3`, alive. Solo un-regressed (pick→damage→kill chain clean). **Step 2 — cross-pod damage: SOLVED + verified end-to-end (task #47, 2026-07-09).** A one-line fix (`Mech::Make`) yields a full cross-pod KILL: instance A (host 2) shoots B's mech (host 3) dead over the network, B runs the death transition (wreck swap + explosion) on its own screen. [T2] **THE BUG (root cause): B's own MASTER mech was INVALID, so every dispatched network message to it was DEFERRED forever.** `Entity::Receive(Event*)` (ENTITY.cpp:165) does `if(!IsValid()) event->Defer(); return;`. The reconstructed `Mech::Make` (mech.cpp) force-set `ValidFlag` ONLY for replicants (the P6 bring-up hack for the partial MakeReady/CheckLoad handshake); a locally-created **master** never got validated. So A→B TakeDamage arrived, resolved to B's real master mech, Posted, drained to `Entity::Receive` — which saw `valid=0` and Deferred it. Handler never ran → 0 damage. **THE FIX:** `Mech::Make` now sets `ValidFlag` for the master too (`if (mech != 0) mech->SetValidFlag();` — the reconstructed ctor builds the whole mech synchronously, so it's safe). NOTHING ELSE changed: the targeting, the virtual `victim->Dispatch`, the reroute, the wire, the receive, and the id resolution were all already correct. Solo un-regressed (the solo enemy was already force-valid via btplayer:839; the change only ADDS `ValidFlag`). **Verified full path** (2-node one-box, `BT_MP_FORCE_DMG` on A firing at B's replicant once/sec): 1. A: `victim->Dispatch(&td)` (VIRTUAL — the real beam path) → `Entity::Dispatch` reads `inst=Replicant`, stamps `entityID=3:22` (B's master id), `if(ReplicantInstance) SendMessage(ownerID=3, EntityManagerClientID)`. 2. Wire: `NetworkManager::Send` (L4NET.CPP:1062) → `send()` to host 3 (online). 3. B: `ReceiveNetworkPacket` (NTTMGR.cpp:107) → `GetEntityPointer(3:22)` = `entityIndexSocket.Find` (HOSTMGR.h:270) → resolves to B's **master mech (classID 3001=0xBB9)** → `Post(EntityManagerEventPriority)`. 4. B: event drains → `Entity::Receive` (now `valid=1`) → `Mech::TakeDamageMessageHandler` (mech.cpp:548) → invalidZone (−1) cylinder lookup → zone damage. ~36 hits × 12 → B's mech DESTROYED + death transition. **Two prior mis-diagnoses corrected — both were the WRONG messageID.** `Entity::TakeDamageMessageID` is **18**, NOT 21 (confirmed at runtime: `td.msgID=18`, so `Simulation::NextMessageID`=3, not 6). Every earlier probe filtered `msgID==21`, so it NEVER matched a real TakeDamage — the stray `msgID=21` hits it caught were an unrelated message, and correlating their `3:19` / `classID 48` against the real `3:22` TakeDamage produced two phantom root causes: (a) the "cross-TU Entity layout divergence" (disproven separately — an `offsetof` probe showed `Entity` is IDENTICAL in both TUs: `sizeof=444`, `entityID@380 (0x17C)`, `ownerID@388`, `simulationFlags@32`; distinct from the REAL P5 raw-offset stomps at `this+0x2d4..0x2f0` in `docs/HARD_PROBLEMS.md`), and (b) the "localID drops by hostID on the wire" claim. Both are FALSE. The id is correct end-to-end (`3:22` = B's master); nothing on the wire is corrupted. Lesson: when a message probe seems to see a wrong id, first confirm the numeric messageID. Retained gated instrument: `[mp-force]` (BT_MP_FORCE_DMG + BT_MP_LOG) — the 2-node cross-pod reproducer, off in solo. `[mp-hdlr]` (BT_MP_NET, mech.cpp:554) logs each applied cross-pod hit. 2-node launch recipe + `scratchpad/mp_test.sh`: A `-egg MP.EGG -net 1501`, B `-net 1601`, then `python tools/btconsole.py MP.EGG 127.0.0.1:1501 127.0.0.1:1601` (MP.EGG `[pilots]` = game ports 1502/1602). ## Interactive -net aim + drive (task #48, 2026-07-09) [T2] Re-measured on a 2-node one-box run. **The prior "aim ray dead / drive dead in -net" claim was STALE — both work:** A's aim ray is live (`noRay=0`) and A drives (`BT_AUTODRIVE`/`BT_GOTO` move it). **FIXED — torso-pitch aim ray (gyro-stabilized boresight).** The aim/pick ray was the active RENDER EYE's view direction, which (a) inherits the mech BODY pitch — on a slope the body tilts ~8° to conform to terrain and the boresight pointed that far DOWN, hitting terrain ~50-100 m short of the enemy (`groundPicks`, never `mechPicks`); and (b) in CHASE view (the DEFAULT) is the elevated behind-the-shoulder camera, so the ray started over the mech's shoulder. Fix (L4VIDRND `DPLEyeRenderable::Execute`, the `BTSetAimCamera` publish): LEVEL the boresight (drop the view-dir pitch, rebuild an upright basis with world +Y) AND anchor its ORIGIN at the mech's torso (`myEntity->localToWorld` translation + ~5) instead of the render eye. The guns now fire along the mech's horizontal heading from the torso, independent of view mode and terrain tilt. The reticle X still carries the torso twist (`BTTwistToReticleX`); reticle Y any elevation. VERIFIED end-to-end: solo, enemy 120 m ahead, **both chase AND cockpit** view → `[target] MECH under boresight ... mechPicks=59 groundPicks=0` → beam → `*** DESTROYED`. Also proved `PickRayHit` resolves a REPLICANT when aimed at it (`hit=1` at 757 m in a 2-node run); the replicant `localToWorld` is correct (DeadReckon + `localToWorld= localOrigin`, mech4.cpp:1191). [T2] **FIXED — drive-to-range (the automated cross-pod kill now runs end-to-end).** The `BT_GOTO` beeline had three separate faults: (1) it was gated behind `if(gBTDrive.forced)` (mech4.cpp), and `forced` was set ONLY by `BT_AUTODRIVE` — so `BT_GOTO` alone never drove (chicken-and-egg: the block that would drive was gated on the drive being on). Fix: `BT_GOTO` now enables forced mode itself + supplies its own forward throttle (a "beeline" must drive, not just steer), cutting throttle inside a stop radius (weapon range for `enemy`, so it holds and shoots instead of ramming). (2) The steering used the `gDriveHeading` SCALAR MIRROR (seeded to 0), but MP pilots spawn facing a non-zero heading, so the beeline steered the WRONG way. Fix: compute the heading error from the mech's ACTUAL forward (`localToWorld`'s -Z axis), signed angle to the target. (3) The goto turn was routed through `controlsMapper->turnDemand`, which zeroes out in -net (the mapper key-bridge only shapes the local viewpoint mech there) — the heading froze and A drove straight past B. Fix: apply the goto turn to the orientation integration DIRECTLY (`if(gBTGotoActive) turn=gBTGotoTurn`), after the mapper read. VERIFIED 2-node: A `BT_GOTO="enemy"` alone (no autodrive) drives `dist 1673→231 m`, holds at range, `mechPicks=59`, autofire → **B hdlr=192, DESTROYED** — a fully automated human-style cross-pod kill via the real beam path (not the FORCE_DMG hook). Solo un-regressed (plain autodrive + goto both work). `scratchpad/mp_kill_test.sh`; `BT_GOTO_LOG` traces the beeline. ## Remaining (P6 phase 4 / Phase 7) Replicant GAIT animation (derive from replicated velocity); the pod-LAN config (real IPs, bare-IP pilot entries). See [[open-questions]]. [T3] ## MP-front scout (task #45, 2026-07-09) — the P6 chain SURVIVES the combat/HUD rework [T2] Re-ran the one-box smoke test on the current build (world-pick targeting, weapon groups, death transition, HUD all landed since P6): console egg → mesh → RunningMission on BOTH instances, 174+ ×144-byte update records EACH side (msgID=6), 2 mech trees per instance, 0 crashes over ~6 min. **Gap map to a first playable LAN fight** (in wiring order): 1. **Enemy selection**: the world-pick tests only `gEnemyMech` (the solo dummy, btplayer.cpp:791). MP: the pick must test every OTHER mech (the peer replicants) — generalize to a candidate walk (mech entities != viewpoint). Small. 2. **Cross-pod damage**: dispatch TakeDamage at the REPLICANT → verify the engine reroute carries it to the owning master (the KB claims Dispatch reroutes; not yet exercised with our STEP-6 handler). The master takes real zone damage + runs its own death transition (task #42 placed the death dispatch victim-side — MP-correct by construction). 3. **Victim visuals on the shooter's screen**: the wreck swap gates on zone/graphic state the REPLICANT copy may never see (zone damage may not replicate — only pose updates confirmed). Check DamageZone/subsystem state in the update stream; else replicate the death as an event. 4. **Cross-pod beam visuals**: replicant emitters don't run the local trigger sim, so the peer's beams never draw today. ⭐ The authentic path exists: ServiceDischarge/ContinueDischarge SEND **beam-keepalive messages** (`FUN_0041c350`, templates @0x511e6c/@0x511e78, emitter.cpp already stubs them) — decode + route those to drive replicant beam draws. 5. **2-window driving**: input gates on window focus (alternate windows; `BT_KEY_NOFOCUS` for harness). DEATHS scoring should light via the existing BTPostKillScore MP branch. Respawn = the P5 teardown debt (deferred until needed). ## Key Relationships - Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]] (entity lifecycle). Detail: `docs/HARD_PROBLEMS.md` (P6).