Two size errors (animationPrefix char[12] vs 4 bytes; a FICTITIOUS 64-byte skeletonName block -- the record has no skl name) skewed every member after word 0x10 by +2/+15 words and pushed the fieldXX block past the record end. Full writer map recovered (FUN_004a2da8 + Mover FUN_004238bc + Reticle FUN_00435ac8 chains) + runtime reader map (ctor pass @0x4a24d1-0x4a2968, capstone-verified). Struct rewritten as the exact overlay (static_assert- locked); all Pass-2 consumers renamed to real fields; retired: ctor + replicant + master-gait floor-25 hacks, the throttle guard, the fictitious classID stamp. Verified: accel=30 superStop=10 throttleAdj=1 (was ~1/OOB/0), deadbands byte-identical via named fields, full walk->run lifecycle clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
32 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), and MP is now substantially reconstructed + 2-node verified — replication,
cross-pod combat, beam visuals, replicant gait, respawn (tasks #46-#52). Remaining: pod-LAN config,
fidelity nuances, and the Mech-level update records. Full detail: docs/PROGRESS_LOG.md §7, §8;
the problem writeup: docs/HARD_PROBLEMS.md (P6).
The stack (already reconstructed)
- WinTesla replaced
NETNUBwith a ~3.5k-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. MP was integrated on that stack and verified end-to-end 2-node. 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)
- ⚠
-netnode render glitch — LIKELY ALREADY FIXED / WATCHING (task #53): a 2-node MP session once showed ONE window come up visually wrong — shadow clipping, camera stuck inside/blocked, targeting/firing dead — while the other was fine. Two earlier framings were WRONG and are corrected: (i) the "two-D3D9-apps-on-one-GPU / device contention" theory was retracted — a solo instance, AND two solo instances, are all clean, so it is not device contention; (ii) "glitched FROM LOAD, before any death" was ALSO wrong — the trigger is a RESPAWN (user correction, 2026-07-10). So it is a post-respawn render-rebuild fault on the MP path, not a load-time one. Does NOT affect the pod (one instance per machine).- What the respawn rebuild does (reproduced 2026-07-10): on respawn the player's mech is rebuilt
via
MakeEntityRenderables, which runs a TWO-STEP view sequence — inside skeleton +COCKPIT eyepoint, then outside skeleton +external chase([view] …logs, btl4vid.cpp:2158) — and the external chase camera is NOT re-installed (only ONE[BTrender] external debug chase camera installed, btl4vid.cpp:647, for the whole session despite multiple respawns). Plausible failure: if that two-step completes only to the COCKPIT step (raced/interrupted on the-netladder), the node is left stuck INSIDE — body hidden by the inside skeleton, shadow/reticle flagging with it = the reported bundle. Camera + reticle + view +tshdshadow are all gated onbuildDebugChaseCamera = (view_type == insideEntity)(btl4vid.cpp:297). - Status: could NOT reproduce (2026-07-10) → likely already fixed. 9 automated 2-node loads PLUS a
forced death/respawn all came up CLEAN (the respawn completed to external chase; clean backbuffer via
the new
BT_SHOTdump). Since debug uninit is a deterministic0xCDCDCDCD(would glitch every run), the intermittency implies a race, and the task #52 respawn fixes (theLoadMissionre-entry killer bug, viewpoint relink, death-latch) plausibly ALREADY closed it. Left as WATCH — re-open and capture withBT_SHOTif it recurs. Tools kept:BT_SHOT=<path>(per-instance backbuffer PNG, off by default, L4VIDEO.cpp) +scratchpad/hunt53.sh(N-run divergence hunter).
- What the respawn rebuild does (reproduced 2026-07-10): on respawn the player's mech is rebuilt
via
- 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:584) → 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:591) 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]
Mech-level update records (task #1, 2026-07-11) — DONE [T1 transcription / T2 live]
The 9-type Mech record channel is LIVE end-to-end: writer @004a0c2c transcribed
instruction-for-instruction from reference/decomp/mech_writeupdate_004a0c2c.disasm.txt, reader
@004a1232 rewritten from part_012.c:9576-9692 with every Wword absorber promoted to the NAMED
engine/port member. Wire sizes verified live (0x14/0x20/0x2c/0x78). Key mechanics:
- Record type == the
updateModelBIT INDEX (Simulation::WriteSimulationUpdatewalks the mask, SIMULATE.cpp:302-328; binary twin FUN_0041bd98). Request =Mech::ForceUpdate(1<<N)(@0x4a4c54:updateModel |= mask,&= 0xfe03filter when disabled). THREE prior mislabels of the same binary fns are retired:SetInstanceFlagsandRequestActionFlags(both @0x4a4c54 — +0x18 isSimulation::updateModel, NOT entity flags) andIsNetworkCopy(@0x49fb54 =IsDisabled, simulationState 2||9). movementMode==Simulation::simulationState.currentState(binary mech+0x40 = StateIndicator@0x2c +0x14; oldState @+0x3c). The port's parallelint movementModemember is GONE —MovementMode()/SetMovementMode()accessors ride the engine StateIndicator, so the state (death 9 / limbo 2 / airborne 3,4) replicates in EVERY record header (+0xC) and the replicant tracks the master automatically.- Every record tail = the mapper's
speedDemand(subsystemArray[0]+0x128) mirrored intobodyTargetSpeed@0x6b4on both sides — the authentic replicant-gait feed (it was misread as a "sequence number"; the type-2 record exists solely to ship it on change). Task #50's velocity-derived gait stays as the leg-channel driver meanwhile. - Types: 0 pose (Mover record + tail; orientation SAVED/RESTORED around the base call — it rides ONLY type 4), 1 damage zones (engine), 2 speedDemand, 3 leg-state+stability (write-through body dispatch both sides), 4 orientation+angular-velocity resync (euler-encoded, quat normalized), 5 knockdown (SetBodyAnimation 0x20 both sides), 6 death, 7 impact (fields only), 8 airborne/reverse selector. 5/6/7 share the 0x2c heat/throttle/fall-basis payload.
- Senders wired byte-exact (the perf-loop + event map recovered from 27 binary call sites):
gait transitions
Force(8)(mech2 — already transcribed under the RequestActionFlags mislabel, masks matched the binary exactly), knockdownForce(1|0x20)(mech4), deathForce(1|0x40)BEFORESetMovementMode(9)(the disabled filter would mask it after),ResetForce(0x1f)+ the binary zero-set (state 0, cycle/target speeds, poseSyncLatch), perf-loop deadbands: position → type 0, orientation deviation → type 4, speedDemand change → type 2, heat-level change → type 7 (snapshot @0x780). [T3 deadband thresholds — the model record'sUpdatePositionDiffrence/UpdateTurnVelocityDiffrence/UpdateTurnDegreeDiffrencefields (+0x26..0x28) are the authentic constants, not yet streamed to the runtime members +0x768/+0x76c/+0x770.] - VERIFIED 2-node: types 2/3/4 flow while driving; kill → B emits ONE type-6 (simState=9 in
the header) → A's replicant flips to MovementMode 9 and its own
UpdateDeathStateruns the wreck-sink loop on the OBSERVER's screen (smoke re-arm logged on A; NO double death transition, NO replicant VehicleDead) — the death SINK/burial gap (old item 3) is CLOSED. Respawn:Force(0x1f)burst snaps the replicant to the drop zone + un-wrecks it. Walking replicant un-regressed (run states 10→12, legCycle tracking, with type-3 records flowing). Solo clean. - Telemetry:
[mrec-tx]/[mrec-rx]per non-pose record (BT_REPL_LOG). Note the type-1 rx line prints a bogus simState (the damage record's own layout at +0xC) — cosmetic. - New by-name members:
poseSyncLatch@0x77c(dead-reckon re-base latch, consumed by FUN_004ab1c8),fallDirection@0x4a8+fallScalar@0x4b4[T4 names],heatLevelSnapshot@0x780. A TU-local stub type forced theBTMapperSpeedDemandRaw(void*)bridge (mech.cpp carries a localstruct MechControlsMapperrecon stub — typed cross-TU signatures mangle differently).
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 — DONE (task #46). The world-pick used to test only
gEnemyMech(the solo dummy, btplayer.cpp:791); now generalized to the live-mech registry candidate walk (BTGetTargetCandidates, all mechs != shooter) — see "Cross-pod combat step 1+2" above. - ✅ Cross-pod damage — DONE (task #47). TakeDamage dispatched at the REPLICANT reroutes to the owning master (verified end-to-end: full cross-pod kill; the master takes real zone damage + runs its own death transition; task #42 placed the death dispatch victim-side — MP-correct by construction). See "Cross-pod combat step 1+2" above.
- 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 defaultdeathCount = -1= the death notification) toGetPlayerLink()→BTPlayer:: VehicleDeadMessageHandler(@004c05c4 structure): MissionEnding(state 4) skips all; -1 → dedup on deathPending, ++deathCount, debitscenarioRolelife, severplayerVehicle(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.scpare internal-AUDIO scripts, NOT per-mech intro scripts; andadrop(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) loadstsphere.bgfby FILENAME viad3d_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 hastsphere.bgf/tsphere. - Engine class:
POVTranslocateRenderable(L4VIDRND.cpp:1749 / .h:638) — aVideoRenderable(Watcher) that loadstsphere.bgfand runs a state machine keyed on aStateIndicator"effect trigger" (the player'sSimulationStatedial) vs a control state:IdleState→ (trigger hits control state)InitialCollapseState(sphere scalesCOLLAPSE_START_SCALE 100→1 overCOLLAPSE_TIME 1.3s, thenSetIsDead(true)) →WaitForReincarnateState(jitters via cos/sinTRANSLATE_LIMIT 2.0) → (trigger leaves control state = respawn)ExpandRevealState(scales 1→EXPAND_END_SCALE 150overEXPAND_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.cppMakeEntityRenderablesfor aBTPlayerClassIDREPLICANT createsnew BTTranslocationRenderable(entity, Watcher, GetMainView(), sim_state=GetAttributePointer(SimulationState), drop_zone=GetAttributePointer("DropZoneLocation"), 1)(FUN_00458d2c, alloc 0x40, control state 1); our own POV instead getsBTPOVStartEndRenderable(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):
BTTranslocationRenderablewas a no-op stub inbtstubs.cpp; now reconstructed inbtl4vid.cpp(state machine +d3d_OBJECT::LoadObject ("tsphere.bgf")), drawn direct from the render loop byBTDrawTranslocationSpheres(hooked in L4VIDEO.cpp besideBTDrawBeams, PASS_ALPHABLEND) — the same direct-draw accommodation the beams/ reticle use since our VideoComponent tree is partial. Trigger: the LOCAL player's ownSimulationStatedial, pulsedDropZoneAcquired(1)→VehicleTranslocated(2)at respawn (btplayer.cpp create branch + a 1.4 s flip timer inPlayerSimulation), with the respawn origin written into the player'sDropZoneLocationattribute (the sphere's draw position). Wiring (btl4vid.cppMakeEntityRenderables, 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 viaBT_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 gateBT_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 (decompFUN_00453dc4Z-spin, which the WinTesla port had stubbed), coloured by thebintAcloud 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 inL4D3D::SetTextureScrolling(offset =scrollDelta × absolute_time→ unbounded → UV precision loss → radial "spokes"), fixed withfmodf; 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 —IsMechDestroyedtestedgraphicAlarm>=9alone; a later leg hit on the wreck rewrites the alarm to 4/3, un-latching → the death transition RE-RAN (double kill, doubleVehicleDead(-1)). Fixed to latch onmovementMode 2||9(see combat-damage.md "Death-latch correction"). (ii) score into severed vehicle —BTPlayer::ScoreMessageHandler/ScoreInflictedMessageHandlerderefplayerVehicleunguarded; 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) callsvideoRenderer->LoadMissionon EVERY viewpoint-make, so the respawn re-runsDPLReadEnvironment→DPLReadINIPage; its light blockFails ("All lights must be defined on a single INI page") becausesceneLightCountwas never reset between reads (ctor-only init). Fixed in L4VIDEO.cppDPLReadEnvironment: tear down the prior mission'ssceneLight[]+sceneLightCount=0before 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 cdbbp ucrtbased!abort— aFail/Checkis an abort() MessageBox, not an AV, sosxe avmisses it.
- Asset:
- ✅ 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'supdateModel/ForceUpdate(). The REPLICATION rides subsystem update records inside the mech's update message: the roster walk passes the entity's stream to every subsystem'sPerformAndWatch;Emitter::WriteUpdateRecord(@004ba65c, corrected: record LENGTH not recordID; rec+0x30 = the TARGET's EntityID not a colour) serializes firingActive/beamEndpoint/beamFlag/targetID; the engine'sEntity::UpdateMessageHandlerroutes records bysubsystemIDtoEmitter::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/ContinueDischargecallForceUpdate()per keepalive tick + once at beam end. The render walk was extracted toMech::DrawWeaponBeams(per-mech port cache) and runs for REPLICANTS. ⚠ two silent-failure traps fixed: the overrides originally used the class's own shadowingUpdateRecordtypedef as the param type → never overrode the engine virtual (base ran, nothing serialized); andrecordLengthmust 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. ✅ The MECH-level records LANDED (task #1, 2026-07-11) — see the section below; this REMAINING note is closed. - ✅ 2-window driving — RESOLVED (tasks #48/#51). Keyboard MP turning was restored at the root
(the
pilotArray[1]shrunk-span overrun fix, task #51);BT_KEY_NOFOCUSexists for automated harnesses. DEATHS scoring lights via the existing BTPostKillScore MP branch. Respawn is reconstructed (task #52 — see item 3b).
Key Relationships
- Base: wintesla-port (L4NET). Depends on: locomotion (update writer), combat-damage
(entity lifecycle). Detail:
docs/HARD_PROBLEMS.md(P6).