From 54b6663b4dcdd64d8e2cf31825a597e6f26e8b57 Mon Sep 17 00:00:00 2001 From: arcattack Date: Thu, 9 Jul 2026 12:52:26 -0500 Subject: [PATCH] MP task #47 SOLVED: cross-pod damage + kill (validate the master mech) Full cross-pod KILL now works: instance A (host 2) shoots B's mech (host 3) dead over the network; B runs the death transition on its own screen. Verified on a 2-node one-box run (BT_MP_FORCE_DMG on A): B's mech takes ~36 remote hits of 12 and is DESTROYED, with wreck swap + explosion. ROOT CAUSE (one bug): B's own MASTER mech was INVALID. Entity::Receive(Event*) does `if(!IsValid()) event->Defer()` -- so a cross-pod-delivered TakeDamage that arrived, resolved to B's real mech, and Posted, was DEFERRED forever; the handler never ran. The reconstructed Mech::Make force-set ValidFlag only for replicants (the P6 bring-up hack for the partial MakeReady handshake); a locally-created master never got validated. FIX (one line): Mech::Make now sets ValidFlag for the master too -- the reconstructed ctor builds the mech synchronously, so it's safe. Nothing else changed: targeting, the virtual victim->Dispatch, the replicant reroute, the wire, receive, and id resolution were ALL already correct. Two prior root causes were BOTH mis-diagnoses from the WRONG messageID: Entity::TakeDamageMessageID is 18, not 21. Every earlier probe filtered msgID==21, never matched a real TakeDamage, and mis-correlated the stray 21 hits' 3:19 / classID-48 against the real 3:22 hit -- inventing (a) a "cross-TU Entity layout divergence" (disproven: offsetof identical in both TUs, sizeof 444, entityID@0x17C) and (b) a "localID corrupted on the wire". Both FALSE; the id is correct end-to-end (3:22 = B's master). - game/reconstructed/mech.cpp: validate master + replicant at Make. - game/reconstructed/mech4.cpp: BT_MP_FORCE_DMG cleaned to a concise gated 2-node cross-pod reproducer (real virtual Dispatch path). - context/multiplayer.md, reconstruction-gotchas.md: corrected root cause + the validity-defers-cross-pod gotcha. Engine diagnostics reverted to clean. Solo un-regressed (the solo enemy was already force-valid; the change only ADDS ValidFlag). btl4.exe builds; full mission loop clean. Co-Authored-By: Claude Fable 5 --- context/multiplayer.md | 84 ++++++++++++++++--------------- context/reconstruction-gotchas.md | 14 ++++-- game/reconstructed/mech.cpp | 19 ++++--- game/reconstructed/mech4.cpp | 24 ++++----- 4 files changed, 75 insertions(+), 66 deletions(-) diff --git a/context/multiplayer.md b/context/multiplayer.md index ae19b24..7e645d3 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -62,51 +62,53 @@ the solo `gEnemyMech` to the picked `hotTarget` (missile impact + projectile val 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. +**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] -⚠ **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.) +**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. -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). +**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). ## 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] +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) so a HUMAN (not just the FORCE_DMG hook) can aim + +fire cross-pod; 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 diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index 129dbf9..d0b10a8 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -126,10 +126,16 @@ real PoweredSubsystem). Keep the alloc SIZE + special-cache when swapping a fact (`Receiver::MessageHandlerSet(Entity::GetMessageHandlers())`). An empty default-ctor set has no parent chain → `Receiver::Receive` finds no handler → every inherited message (TakeDamage!) is **silently dropped**. [T2] -- `Entity::Dispatch` delivers synchronously only for a VALID master; an invalid entity `Post`s a - deferred event that never fires. A manually-spawned entity (no CheckLoad handshake) must call - `SetValidFlag()` itself — else EVERY message defers forever (the replicant / spawned-dummy / - MessageBoard-source class of bug). [T2] +- **Entity validity gates message delivery on BOTH paths, and an unvalidated entity drops everything.** + `Entity::Dispatch` delivers synchronously only for a VALID master (invalid → `Post(EntityInvalidEventPriority)`, + which does re-fire); but a message that arrives as an EVENT — `Entity::Receive(Event*)`, ENTITY.cpp:165, + e.g. any Posted or **cross-pod-delivered** message — does `if(!IsValid()) event->Defer()`, and the + deferred queue never re-fires until the entity becomes valid. A manually-spawned OR network-created entity + (the port's MakeReady/CheckLoad handshake is a partial impl) must call `SetValidFlag()` itself — else EVERY + message defers forever. Force-validate at `Make` (the reconstructed ctor builds the entity synchronously). + Hit by: the spawned dummy, replicants, AND — task #47 — a peer's own **MASTER** mech: cross-pod TakeDamage + reached B, resolved to B's real mech, then `Entity::Receive` saw `valid=0` and deferred it forever → 0 + damage. Fix = `Mech::Make` sets `ValidFlag` for the master too (mech.cpp), not just replicants. [T2] ## 10. Container-Execute must override (gauges) diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 1eb2dfd..bc724b8 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -689,14 +689,17 @@ Mech* return 0; } new (mech) Mech(creation_message, DefaultData); // FUN_004a1674 - // P6 bring-up (multiplayer): a REPLICANT never gets the master's MakeReady/ - // CheckLoad validity handshake (the engine's interest layer is a partial - // implementation), and an INVALID entity defers every message as an event - // (ENTITY.cpp Receive/Dispatch) -- so its network UPDATES never apply AND the - // deferred-event churn keeps the peer's CreatingMission queue from ever going - // quiet. Validate replicants at creation (same fix the spawned test dummy - // needed); the authentic MakeReady flow is future work. - if (mech->GetInstance() == Entity::ReplicantInstance) + // P6 bring-up (multiplayer): the port's MakeReady/CheckLoad validity handshake + // is a partial implementation, so a mech is never authentically validated. An + // INVALID entity DEFERS every dispatched message forever (ENTITY.cpp + // Entity::Receive -> event->Defer), which silently drops cross-pod TakeDamage + // at the owning MASTER (task #47: B receives + resolves the hit to its own mech, + // classID 3001, but Entity::Receive sees valid=0 and defers it). It also stalls + // a REPLICANT's network UPDATES and churns the peer's CreatingMission queue. The + // reconstructed ctor builds the whole mech synchronously, so validating here is + // safe for BOTH instances (a master must be valid to take cross-pod damage; a + // replicant to apply updates). The authentic MakeReady flow is future work. + if (mech != 0) { mech->SetValidFlag(); } diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 002cea1..c6f07d3 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -2409,16 +2409,13 @@ void << " zones=" << m->damageZoneCount << "\n" << std::flush; } - // CROSS-POD DAMAGE PROOF HOOK (BT_MP_FORCE_DMG, task #46/#47): once a - // second, dispatch a real unaimed TakeDamage at the first live - // REPLICANT candidate through the SAME engine path a beam hit uses - // (Entity::Dispatch -> replicant reroute -> SendMessage(ownerID)). - // This isolates the cross-pod delivery from the interactive aim/drive - // plumbing so a 2-node test can watch the wire without having to line - // up a boresight. It does NOT stamp/patch the id -- Entity::Dispatch - // reads this->entityID at offset 0x17C, which the [mp-layout] probe - // proved is IDENTICAL in the reconstructed and engine TUs (both 380, - // sizeof(Entity)=444), so no cross-TU translation is needed or done. + // CROSS-POD DAMAGE TEST HOOK (BT_MP_FORCE_DMG, task #47): once a second, + // dispatch an unaimed TakeDamage at the first live REPLICANT through the + // SAME virtual Entity::Dispatch path a beam hit uses -- Entity::Dispatch + // reroutes a replicant's message to the owning master over the wire, the + // master's now-valid mech resolves + applies it (cylinder lookup). Lets a + // 2-node test exercise cross-pod delivery without lining up a boresight. + // Off by default; the interactive beam path needs no such hook. if (getenv("BT_MP_FORCE_DMG")) { static float s_fd = 0.0f; s_fd += dt; @@ -2440,9 +2437,10 @@ void Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), GetEntityID(), -1, dmg); - DEBUG_STREAM << "[mp-force] " << (void*)m - << " Dispatch unaimed TakeDamage id=" << m->GetEntityID() - << " ownerID=" << (int)m->GetOwnerID() << "\n" << std::flush; + if (getenv("BT_MP_LOG")) + DEBUG_STREAM << "[mp-force] " << (void*)m + << " cross-pod TakeDamage id=" << m->GetEntityID() + << " -> ownerID=" << (int)m->GetOwnerID() << "\n" << std::flush; m->Dispatch(&td); break; }