User report from the first live 2-window session: throttle worked, A/D turn
did nothing. [mppr] showed the input REACHING the mapper (stickX=-1) but
turnDemand=0 and controlMode = pointer garbage (73583232 / 217478632 --
different per instance), from frame 1, MP only (solo mode=0).
Hunted with a cdb hardware write-breakpoint (ba w4 armed at ctor time on
&controlMode, compiled offset +0x128 from the [mode-ctor] probe). Caught the
writers red-handed: MechControlsMapper::BuildPilotArray / FillPilotArray.
ROOT CAUSE -- a shrunk-span array: the binary reserves a FIXED 10-slot pilot
block @0x15C..0x183 ((pilotArrayBuilt@0x184 - 0x15C)/4 = 10); the recon
declared `Pilot *pilotArray[1]` ("variable length"), so every write past
slot 0 stomps the members declared after it. In MP, FillPilotArray wrote the
PEER's Player pointer into slot 1 = over controlMode -> the turn shaping
dispatched on pointer garbage -> turnDemand 0. MASKED in solo: the zero-loop
overrun wrote 0 there, and 0 == BasicMode. This was ALSO the real cause of
task #48's "turnDemand zeroes out in -net" observation (worked around then
with the direct goto-turn; attribution corrected in the KB).
Fix: pilotArray sized to the binary's own 10-slot span; ctor zeroes all
slots; BuildPilotArray clamps pilotCount to capacity (local + 9 remotes; an
8-pod game fits). Diagnostics kept (BT_MODE_LOG ctor probe; [mppr] line now
prints mapper/&mode).
VERIFIED: MP session holds mode=0 throughout, turnDemand flows nonzero, the
mech turns (heading -1.33) and closes on the peer; solo kill chain intact.
KB: new "shrunk-span array" gotcha (reconstruction-gotchas 5) with the cdb
ba-w4 recipe; multiplayer.md task-#48 note corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 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 read 0 in -net — 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. CAUSE FOUND LATER (task #51): turnDemand zeroed because controlMode was pointer
garbage — the pilotArray[1] shrunk-span overrun (see reconstruction-gotchas §5), MP-only because solo's
overrun wrote 0==BasicMode. Fixed at the root (10-slot array); the direct goto-turn stays (harmless, and it
skips the 1-frame mapper latency). KEYBOARD turning in MP was restored by the root fix.
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.
Replicant gait animation (task #50, 2026-07-09) — DONE [T2]
A peer's replicant used to slide as a frozen statue (DeadReckon moved it; nothing animated it). Now the
replicant block (mech4.cpp, after DeadReckon) derives a SIGNED speed demand from the replicated
velocity — the master publishes worldLinearVelocity in every update record (Mover::WriteUpdateRecord
MOVER.cpp:759), the replicant stores it in updateVelocity.linearMotion (:712); sign = the forward dot
against the mech's -Z facing — writes it to controlsMapper->speedDemand (the LEG channel's live source)
and advances the LEG state machine for its JOINT writes only. Travel stays DeadReckon (the returned
distance is discarded) so position always follows the master; the clip cadence matches the replicated
speed. ⚠ REPLICANT GAIT-SCALAR PRIMING (same class as the master's per-frame primes, which live in the
PLAYER-only drive block): globalTimeScale/idleStrideScale read 0 on a replicant (clip frozen at
advance-time dt*0, legState engaged but legCycle stuck 0), and reverseSpeedMax2@0x7a0 (the run-cycle
rise-clamp; LoadLocomotionClips does NOT set it) read debug-heap garbage → legCycleSpeed clamped to
-4.3e8 entering run state 13. Both primed in the replicant block; legCycleSpeed itself sane-banded once.
VERIFIED 2-node: the observer's replicant runs the full lifecycle — stand→walk(5,7)→run(10,12) with
legCycle 7.5→34 tracking the master's speed, wind-down (8) and back to Standing(0) when the master stops.
Solo un-regressed. BT_REPL_LOG traces pos/vel/legState/legCycle; recipe scratchpad/mp_gait_test.sh.
NOTE: the update-record velocity is currently the master's ACTUAL travel velocity (worldLinearVelocity
from the leg channel); the binary publishes the BODY channel's smoothed projection (projectedVelocity,
disasm +0x2a0). Swapping the source is a fidelity refinement DEFERRED — it risks a verified working feed
for a smoothing nuance. [T3 on that nuance]
Remaining (P6 phase 4 / Phase 7)
The pod-LAN config (real IPs, bare-IP pilot entries); update-record velocity sourced from the body-channel projection (above). 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).