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 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-09 12:52:26 -05:00
co-authored by Claude Fable 5
parent 04a2049ce2
commit 54b6663b4d
4 changed files with 75 additions and 66 deletions
+43 -41
View File
@@ -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] <ptr> 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
+10 -4
View File
@@ -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)
+11 -8
View File
@@ -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();
}
+11 -13
View File
@@ -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;
}