300 lines
25 KiB
Markdown
300 lines
25 KiB
Markdown
---
|
||
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 <port>` = networked mode (the pod listens on <port> for a CONSOLE, boots ConsoleOnly; game mesh
|
||
= <port>+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)
|
||
- ⚠ **ONE-BOX 2-INSTANCE RENDER CAVEAT (observed live 2026-07-09):** with two `btl4.exe` sharing a
|
||
single GPU, ONE window comes up visually GLITCHED FROM LOAD — shadow clipping, camera stuck
|
||
inside/blocked, degraded input (targeting/firing feels dead). A **solo** instance (`-egg TEST.EGG
|
||
BT_SPAWN_ENEMY=1`) renders + targets + fires perfectly (log: `MECH under boresight … IN WEAPON
|
||
RANGE mechPicks=59`). So this is a **two-D3D9-apps-on-one-GPU** artifact (the 2nd device to init
|
||
gets degraded state), NOT a game-logic/warp/respawn bug — and it does NOT affect the pod (each pod
|
||
is its own machine). Consequence for dev: trust the SOLO window for visual/gameplay checks; use the
|
||
2-node box only for REPLICATION/netcode logic (which the logs confirm independent of the glitch).
|
||
If single-box 2-window visual testing is ever needed, the fix is in per-instance D3D device/resource
|
||
init (unproven; likely a shared global or device-state clobber), not the game code.
|
||
- **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=<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):
|
||
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).
|
||
|
||
## 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):
|
||
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** — PARTIALLY resolved (user-observed 2026-07-09): the
|
||
wreck SWAP does appear on the killer's screen, but the death SINK/burial does not (the collapse+
|
||
burial runs only in the owning pod's death transition). Fix path: transcribe the recovered
|
||
Mech-level WriteUpdateRecord death/knockdown record types (5/6 — disasm preserved, task #51).
|
||
3b. **Respawn cycle** (task #52) — the AUTHENTIC cycle is now reconstructed [T1 @004c05c4 +
|
||
engine PLAYER.cpp]: mech death transition (mech4.cpp) dispatches `VehicleDead` (ctor default
|
||
`deathCount = -1` = the death notification) to `GetPlayerLink()` → `BTPlayer::
|
||
VehicleDeadMessageHandler` (@004c05c4 structure): MissionEnding(state 4) skips all; -1 →
|
||
dedup on deathPending, ++deathCount, debit `scenarioRole` life, sever `playerVehicle`
|
||
(the WRECK entity stays), re-post the same message at **now + 5.0 s** (5.0f @004c0830)
|
||
stamped with deathCount; out of lives → the binary posts msgID 0x18 at +10 s (mission
|
||
review; deferred). deathCount ≥ 0 / -2 with a vehicle in hand = acquired (clears
|
||
deathPending); without one → engine base @0042db80 (PLAYER.cpp:214: closest non-"win"
|
||
DropZone → AssignDropZone → 2s re-post probe) → DropZoneReply → CreatePlayerVehicle (a NEW
|
||
mech; `deathCount = 0`) + viewpoint relink (Verify(viewpointEntity==NULL) is compiled out at
|
||
DEBUG_LEVEL 0 — release overwrites, same as the 1995 build). @0049fe0c = roster-wide
|
||
slot-11 DeathShutdown broadcast (the death transition already runs that pass).
|
||
CORRECTIONS to the earlier scouting: BTL4.RES **type 12 = InternalAudioStream** (the engine's
|
||
own resource-type table) — `*int.scp` are internal-AUDIO scripts, NOT per-mech intro scripts;
|
||
and `adrop` (id 1994) is a **type-14 MakeMessageStream** = the map's four DropZone make
|
||
records (0xE0 each: classID 3, origin, facing, name "two"/"three"/…), i.e. the respawn
|
||
infrastructure. **⚠ CORRECTION (2026-07-09): the "blue whirlwind" IS in the game — it is the
|
||
TRANSLOCATION SPHERE, and our port STUBS it.** (My earlier "not a shipped asset" verdict was
|
||
WRONG; it was wrong because I searched only RES resource NAMES + the keyword "whirlwind" — but the
|
||
effect (a) loads `tsphere.bgf` by FILENAME via `d3d_OBJECT::LoadObject`, NOT through the RES table,
|
||
and (b) is called "translocate," not "whirlwind.") The mechanism, fully recovered:
|
||
- **Asset:** `content/VIDEO/GEO/TSPHERE.BGF` (2085-byte low-poly sphere; also in the pod install
|
||
REL410/BT + RP + VWETEST/CYCLE). The exe string table has `tsphere.bgf` / `tsphere`.
|
||
- **Engine class:** `POVTranslocateRenderable` (L4VIDRND.cpp:1749 / .h:638) — a `VideoRenderable`
|
||
(Watcher) that loads `tsphere.bgf` and runs a state machine keyed on a `StateIndicator` "effect
|
||
trigger" (the player's `SimulationState` dial) vs a control state: `IdleState` → (trigger hits
|
||
control state) `InitialCollapseState` (sphere scales `COLLAPSE_START_SCALE 100`→1 over
|
||
`COLLAPSE_TIME 1.3s`, then `SetIsDead(true)`) → `WaitForReincarnateState` (jitters via cos/sin
|
||
`TRANSLATE_LIMIT 2.0`) → (trigger leaves control state = respawn) `ExpandRevealState` (scales
|
||
1→`EXPAND_END_SCALE 150` over `EXPAND_TIME 1.0s`, `SetIsDead(false)`) → IdleState. Rotates
|
||
throughout (`ROTATE_RATE`). I.e. the sphere COLLAPSES onto the mech at death and EXPANDS to
|
||
reveal the reborn mech at respawn — exactly the "blue warp that comes to get you & respawns you."
|
||
- **Wiring (already present in our port!):** `btl4vid.cpp` `MakeEntityRenderables` for a
|
||
`BTPlayerClassID` REPLICANT creates `new BTTranslocationRenderable(entity, Watcher, GetMainView(),
|
||
sim_state=GetAttributePointer(SimulationState), drop_zone=GetAttributePointer("DropZoneLocation"),
|
||
1)` (FUN_00458d2c, alloc 0x40, control state 1); our own POV instead gets `BTPOVStartEndRenderable`
|
||
(the mission fade in/out). So peers watch each other's translocation sphere; your own death is a
|
||
POV fade.
|
||
- **✅ DONE + LOG-VERIFIED (2026-07-09, task #52):** `BTTranslocationRenderable` was a no-op stub
|
||
in `btstubs.cpp`; now reconstructed in `btl4vid.cpp` (state machine + `d3d_OBJECT::LoadObject
|
||
("tsphere.bgf")`), drawn direct from the render loop by `BTDrawTranslocationSpheres` (hooked in
|
||
L4VIDEO.cpp beside `BTDrawBeams`, PASS_ALPHABLEND) — the same direct-draw accommodation the beams/
|
||
reticle use since our VideoComponent tree is partial. Trigger: the LOCAL player's own
|
||
`SimulationState` dial, pulsed `DropZoneAcquired(1)`→`VehicleTranslocated(2)` at respawn (btplayer.cpp
|
||
create branch + a 1.4 s flip timer in `PlayerSimulation`), with the respawn origin written into the
|
||
player's `DropZoneLocation` attribute (the sphere's draw position). Wiring (btl4vid.cpp
|
||
`MakeEntityRenderables`, BTPlayerClassID) originally built the sphere only for REPLICANT players
|
||
(third-party view) + a POV fade for self; extended to build it for the LOCAL player too, since peer
|
||
player-attribute replication (SimulationState/DropZoneLocation) isn't wired (on a replicant both read
|
||
0xCD… uninitialised — confirmed via `BT_TLOC_LOG`). Verified 2-node: on B's respawn the sphere
|
||
COLLAPSES (scale ~100→5) then EXPANDS (→150) at the valid drop-zone origin, deduped to ≤2 active,
|
||
respawn cycle un-regressed, no render errors. Env gate `BT_TLOC_LOG`. REMAINING (follow-up): the
|
||
visual appearance (colour/translucency/size on screen) is unverified by logs — needs a live look;
|
||
and the AUTHENTIC peer path (see OTHERS' spheres) needs player SimulationState/DropZoneLocation
|
||
replication — until then the local-player sphere stands in.
|
||
NOTE the respawn-cycle SIM path is separate + done: `DropZoneReply` (FUN_004bffd0) → `Mech::Reset`
|
||
(@0049fb74) + the level-2 "translocated" cockpit alarm; `CreatePlayerVehicle` (FUN_004bfcac,
|
||
disassembled — make+link only, matches RP analog). The sphere is a pure RENDER-side watcher on the
|
||
SimulationState dial, independent of that sim path.
|
||
Mech::Reset (@0049fb74) full subsystem-reset sweep is still a bring-up TODO.
|
||
**Three respawn bugs found + fixed on the way (all [T2], 2-node force-damage verified):**
|
||
(i) **death-latch un-latch** — `IsMechDestroyed` tested `graphicAlarm>=9` alone; a later leg hit
|
||
on the wreck rewrites the alarm to 4/3, un-latching → the death transition RE-RAN (double kill,
|
||
double `VehicleDead(-1)`). Fixed to latch on `movementMode 2||9` (see combat-damage.md
|
||
"Death-latch correction"). (ii) **score into severed vehicle** — `BTPlayer::ScoreMessageHandler`
|
||
/ `ScoreInflictedMessageHandler` deref `playerVehicle` unguarded; during the 5s dead window it is
|
||
0 → guard added (apply the award, skip the vehicle response). (iii) **renderer LoadMission
|
||
re-entry** — the KILLER bug: `MakeAndLinkViewpointEntity` (APP.cpp:1265) calls
|
||
`videoRenderer->LoadMission` on EVERY viewpoint-make, so the respawn re-runs
|
||
`DPLReadEnvironment`→`DPLReadINIPage`; its light block `Fail`s ("All lights must be defined on a
|
||
single INI page") because `sceneLightCount` was never reset between reads (ctor-only init). Fixed
|
||
in L4VIDEO.cpp `DPLReadEnvironment`: tear down the prior mission's `sceneLight[]` +
|
||
`sceneLightCount=0` before re-reading (the per-page invariant is intra-read only). This is a
|
||
PORT-layer re-entrancy gap in the 2007 renderer, not BT logic. Caught via cdb `bp ucrtbased!abort`
|
||
— a `Fail`/`Check` is an abort() MessageBox, not an AV, so `sxe av` misses it.
|
||
4. ✅ **Cross-pod beam visuals — DONE (task #51, 2026-07-09).** The keepalive theory needed one
|
||
correction: `FUN_0041c350` = (a) queue the LOCAL deferred beam-effect callback (@0x4bac0c) on the
|
||
app+0x34 manager (our per-weapon render walk plays that role) AND (b) set the subsystem DIRTY bit —
|
||
which maps to the 2007 engine's `updateModel`/`ForceUpdate()`. The REPLICATION rides **subsystem
|
||
update records inside the mech's update message**: the roster walk passes the entity's stream to
|
||
every subsystem's `PerformAndWatch`; `Emitter::WriteUpdateRecord` (@004ba65c, corrected: record
|
||
LENGTH not recordID; rec+0x30 = the TARGET's **EntityID** not a colour) serializes
|
||
firingActive/beamEndpoint/beamFlag/targetID; the engine's `Entity::UpdateMessageHandler` routes
|
||
records by `subsystemID` to `Emitter::ReadUpdateRecord` (@004ba568 — was MIStranscribed as
|
||
TakeDamage; the whole weapon-family slot map was swapped: slot 6=Read, 7=Write, 9=TakeDamage — see
|
||
mechweap.hpp). `ServiceDischarge`/`ContinueDischarge` call `ForceUpdate()` per keepalive tick +
|
||
once at beam end. The render walk was extracted to `Mech::DrawWeaponBeams` (per-mech port cache)
|
||
and runs for REPLICANTS. ⚠ two silent-failure traps fixed: the overrides originally used the
|
||
class's own shadowing `UpdateRecord` typedef as the param type → never overrode the engine virtual
|
||
(base ran, nothing serialized); and `recordLength` must be each level's sizeof or the stream
|
||
framing misparses. VERIFIED 2-node: A fires 57 volleys → 225 records → B applies 225 → draws 414
|
||
replicant beams (PPC blue / laser red from A's replicant's gun ports). Solo un-regressed.
|
||
REMAINING (deferred): the MECH-level writer @0x4a0c2c (recovered + preserved:
|
||
`reference/decomp/mech_writeupdate_004a0c2c.disasm.txt`; 9 record types decoded — 0/1/4 pose
|
||
variants, 2 alarm, 3 leg-state+heat with body-channel write-through re-sync, 5 knockdown
|
||
(SetBodyAnimation(0x20) write-through), 6 death, 7 impact, 8 movementMode) — transcribing it
|
||
would replicate remote KNOCKDOWN/death/heat states; beams did not need it.
|
||
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).
|