The BT_GOTO beeline never actually closed on a target -- three separate faults: 1. It was gated behind `if(gBTDrive.forced)`, and `forced` was set ONLY by BT_AUTODRIVE -- a chicken-and-egg where the block that would drive was gated on the drive already being on. So BT_GOTO alone just turned in place (the "stall"). Fix: BT_GOTO enables forced mode itself AND supplies its own forward throttle (a beeline must drive, not only steer), cutting the throttle inside a stop radius (weapon range for "enemy", so it holds + shoots). 2. Steering used the gDriveHeading SCALAR MIRROR (seeded to 0), but MP pilots spawn facing a non-zero heading -> it steered the WRONG way. Fix: 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 mode (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 one-box: A with BT_GOTO="enemy" ALONE (no autodrive) drives dist 1673->231m, holds at weapon 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 still drives; goto="enemy" resolves the enemy, mechPicks=59, no crash). BT_GOTO_LOG traces the beeline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
13 KiB
id, title, status, source_sections, related_topics, key_terms, open_questions
| id | title | status | source_sections | related_topics | key_terms | open_questions | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| multiplayer | Multiplayer — replication, netcode, the console | provisional | PROGRESS_LOG.md §7 (Phase 7), §8 (P6); docs/HARD_PROBLEMS.md (P6) |
|
|
|
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
NETNUBwith 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 rumoredL4NET.CPP:1853send-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 <port> = 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 aconst 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=<file> + BT_AFFINITY=<mask> (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):
- A:
victim->Dispatch(&td)(VIRTUAL — the real beam path) →Entity::Dispatchreadsinst=Replicant, stampsentityID=3:22(B's master id),if(ReplicantInstance) SendMessage(ownerID=3, EntityManagerClientID). - Wire:
NetworkManager::Send(L4NET.CPP:1062) →send()to host 3 (online). - B:
ReceiveNetworkPacket(NTTMGR.cpp:107) →GetEntityPointer(3:22)=entityIndexSocket.Find(HOSTMGR.h:270) → resolves to B's master mech (classID 3001=0xBB9) →Post(EntityManagerEventPriority). - B: event drains →
Entity::Receive(nowvalid=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):
- 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. - 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).
- 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.
- 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. - 2-window driving: input gates on window focus (alternate windows;
BT_KEY_NOFOCUSfor 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).