--- 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: the delivery step FAILS; the DISPATCH + POST + PROCESS chain works.** Traced end-to-end (`BT_MP_NET`/`BT_MP_FORCE_DMG`). VERIFIED parts: 1. `Entity::Dispatch` (ENTITY.cpp:244) reroutes a replicant's msg — `if(ReplicantInstance) application->SendMessage(ownerID, EntityManagerClientID, msg)` → `NetworkManager::Send` (L4NET.CPP:1062) → `send()`. A dispatches a TakeDamage (msgID 21) at B's replicant (`ownerID=3`). 2. On B: `EntityManager::ReceiveNetworkPacket` (NTTMGR.cpp:107) → `GetEntityPointer(entityID)` = `entityIndexSocket.Find(id)` (HOSTMGR.h:270, a hash keyed by EntityID `operator==`) → `Post(EntityManagerEventPriority, entity, msg)` → event drains → `Event::Process` → `entity->Receive(msg)` → `activeMessageHandlers->Find(21)` → handler. The message ARRIVES, is Posted, Processed, and a handler is Found+called — but on the WRONG entity, so B's mech takes 0 dmg. ⚠ **ROOT CAUSE — CORRECTED 2026-07-09 (task #47). The earlier "cross-TU Entity layout divergence" explanation is DISPROVEN.** A one-shot `offsetof` probe compiled into BOTH translation units (`[mp-layout]`, since removed) proved the `Entity` layout is IDENTICAL in the reconstructed (`game/reconstructed`) and engine (`engine/MUNGA`) TUs: **`sizeof(Entity)=444`, `offsetof(entityID)=380 (0x17C)`, `offsetof(ownerID)=388`, `offsetof(simulationFlags)=32`** — and `offsetof(Mech,entityID)=380` too (Entity subobject at 0). So `Entity::Dispatch` reads `this->entityID` at the SAME offset mech4's `GetEntityID()` does; there is NO per-TU read difference. (This is distinct from the REAL P5 "base-region layout divergence" in `docs/HARD_PROBLEMS.md`, which is about raw-offset STOMPS at `this+0x2d4..0x2f0` — offsets far above `entityID` — and does not touch the `entityID` member. The prior session wrongly filed #47 under that P5 issue.) Consequence: the previously-logged `3:22` (mech4 candidate) vs `3:19` (engine Dispatch) readings CANNOT be the same object read two ways (identical offset ⇒ identical value) — they were different objects/messages logged on different lines and mis-correlated. So the "localID drops by hostID on the wire" claim is **unconfirmed** and must be re-measured. Two live hypotheses remain, to be distinguished by a 2-node run with PER-MESSAGE-correlated logging (a sequence id + the entity pointer on both ends): - **H2 wire (de)serialization:** the dispatched-message packet path encodes/decodes EntityID host-relative and one direction is mismatched (the auto-replicated UPDATE path resolves fine — diff the two packet encoders). `EntityID` has `operator±`/`operator-` (ENTITYID.cpp) that a host-relative encoder would use. - **H3 id-assignment mismatch:** A's replicant carries an EntityID that never equalled B's master's registered key, so `entityIndexSocket.Find` hits a different (classID-48) entity; UPDATEs still land because replication routes them via the subscription, not a raw `Find`. Check the replicant's id at creation (the Make/Subscribe from B) against B's master's registered id. **Next (needs the 2-node rig):** run `BT_MP_FORCE_DMG` (now dispatches via the real `m->Dispatch(&td)` path, logging `[mp-force] id=… ownerID=…`) on A while capturing, on B, the `Find` key + the master's own id + classID, correlated per message. That resolves H2 vs H3 directly. `[mp-force]` is the retained gated instrument (off in solo — verified). ## Remaining (P6 phase 4 / Phase 7) Cross-pod damage WIRE DELIVERY (the step-2 gap above — the next MP task); interactive 2-window driving + the `-net`-mode aim projection / drive (A's aim ray was dead — `noRay`, and `BT_AUTODRIVE`/`BT_GOTO` didn't move A in `-net` mode); 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).