Files
BT412/context/multiplayer.md
T
arcattackandClaude Opus 4.8 0bfb3d4ab3 Warp: restore the translocation-vortex look + fix texture-scroll precision collapse (task #52)
The death/respawn "blue whirlwind" (tsphere) now matches the original cabinet
photo (capture.png): a smooth spinning lavender vortex with a bright core.

Root cause of the long-standing "radial spokes" artifact was NOT the warp code
but a general engine bug: L4D3D::SetTextureScrolling computed its texture-matrix
offset as scrollDelta * absolute_time, which grows unbounded and collapses UV
float precision -> a smooth scrolled cloud shatters into grainy radial steps.
Wrapped with fmodf(..., 1.0f) (identical under REPEAT tiling, full precision).
This also cleans the scrolling bexp beam grit and any other SCROLL material.

Visual reconstruction (verified against the real 45-vtx TSPHERE.BGF bicone,
offline-rasterized then ported):
  - view ON-AXIS (eye centred on the throat) + spin in place -> concentric rings
    (decomp FUN_00453dc4 does spin-about-local-Z + submit; the port had stubbed it)
  - bintA cloud through a WIDE lavender ramp at full contrast (drawn as SKY);
    no geometry "bands", no log-polar twist, no off-axis tornado (all discarded)
  - tessellate the 12-facet bicone smooth; isotropic + trilinear; ramp baked into
    the texture and drawn SELECTARG1(TEXTURE) to avoid double-tinting

Env knobs (BT_WARP_*) default to the verified values; BT_WARP_SELFTEST/SELFSHOT
are an off-by-default visual-verification harness (backbuffer frame dump).

Docs: new context/translocation-warp.md (geometry/material/visual/lifecycle/env);
reconstruction-gotchas.md gains the accumulated-time precision-collapse bug class;
rendering.md / multiplayer.md / decomp-reference.md / CLAUDE.md cross-linked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:53:50 -05:00

312 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)
-**`-net` NODE RENDER GLITCH — OPEN (observed live 2026-07-09, cause NOT yet found):** in a 2-node
session ONE window comes up visually GLITCHED FROM LOAD (before any death) — shadow clipping, camera
stuck inside/blocked, targeting/firing feels dead — while the OTHER is fine. **Isolated by test, not
theory:** (a) a SOLO instance is perfect; (b) **TWO SOLO instances (no `-net`) are BOTH perfect**
(each logs `MECH under boresight … IN WEAPON RANGE`, no device fallback/error, both hardware HAL
devices). So it is **NOT** a two-D3D9-apps-on-one-GPU / device-contention issue (an earlier note
claimed that — WRONG, retracted) and NOT the warp/respawn (from-load, pre-death). It is **specific
to the MP `-net` path** — the console-driven mission-start ladder / replication doing something to
ONE node's render+input state, asymmetrically (the two nodes differ in launch order, affinity,
net port, BT_MP_NET, BT_MP_FORCE_DMG). CAUSE UNKNOWN — next step: determine WHICH node glitches
(A@1501 vs B@1601) and correlate, then audit the MP viewpoint/camera/HUD setup vs the (clean) solo
path. Does NOT affect the pod (one instance per machine). Dev workflow: trust the SOLO window for
visual/gameplay checks; the 2-node logs are still trustworthy for replication/netcode logic.
- **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`.
- **✅ VISUAL DONE + LIVE-VERIFIED (task #52, follow-up):** the on-screen look now matches the
original cabinet photo (`capture.png`) — a smooth spinning lavender vortex with a bright core. The
full geometry/material/render authority is now **[[translocation-warp]]**; headlines: tsphere is a
12-facet BICONE viewed ON-AXIS + spun in place (decomp `FUN_00453dc4` Z-spin, which the WinTesla
port had stubbed), coloured by the `bintA` cloud through a WIDE lavender ramp, drawn as SKY. The
defect that hid it for the whole effort was NOT the warp code — it was a general
**texture-scroll precision-collapse bug** in `L4D3D::SetTextureScrolling` (offset = `scrollDelta ×
absolute_time` → unbounded → UV precision loss → radial "spokes"), fixed with `fmodf`; that also
cleans the scrolling beam grit. See [[reconstruction-gotchas]] §13. REMAINING: the AUTHENTIC peer
path (see OTHERS' spheres) still needs player SimulationState/DropZoneLocation replication — until
then the local-player sphere stands in; and confirm the COLLAPSE (death-side) warp fires on every
MP death path (the force-damage test logged EXPAND reliably, COLLAPSE less so).
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).