BTTranslocationRenderable was a no-op stub; now reconstructed over the engine's
POVTranslocateRenderable: loads tsphere.bgf and runs a collapse-on-arrival /
expand-on-reveal sphere (scale 100->1 over 1.3s, then 1->150 over 1.0s, rotating)
keyed on the player's SimulationState dial. Drawn direct from the render loop by
BTDrawTranslocationSpheres (beside BTDrawBeams, PASS_ALPHABLEND) -- the same
accommodation the beams/reticle use.
The asset loads by FILENAME (tsphere.bgf), not through the RES table -- which is
why every resource-name search missed the effect for three rounds.
Trigger wiring: btl4vid.cpp MakeEntityRenderables builds the sphere for the LOCAL
player (the authentic wiring builds it only for replicants + a POV fade for self,
but peer player-attribute replication isn't wired -- on a replicant SimulationState/
DropZoneLocation read uninitialised). btplayer.cpp pulses SimulationState
DropZoneAcquired->VehicleTranslocated at respawn (a 1.4s flip timer stands in for
the engine's +1s drop-zone re-post) and writes the respawn origin into the
DropZoneLocation attribute.
Verified 2-node (BT_TLOC_LOG): on respawn the sphere collapses (~100->5) then
expands (->150) at the valid drop-zone origin, deduped to <=2 active, respawn
cycle un-regressed, no render errors. Visual appearance (colour/size) still needs
a live look; the authentic see-others'-spheres path needs player replication.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
My earlier 'not a shipped asset' verdict was wrong. The effect is POVTranslocateRenderable
(engine) / BTTranslocationRenderable (our port), which loads tsphere.bgf BY FILENAME (not via
the RES table, so every resource-name search missed it) and runs a collapse-on-death /
expand-on-respawn sphere keyed on the SimulationState dial. Wiring already exists in
btl4vid.cpp; the renderable is a no-op stub in btstubs.cpp. That's why it never shows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Searched the full pod install (nick-games/tesla4pod_extracted REL410/BT;
BTL4.RES byte-identical to ours) and the whole nick-games archive: no
whirl/warp/tunnel/vortex asset by filename or RES resource name, no such
psfx in BTDPL.INI. The decomp drop path (DropZoneReply FUN_004bffd0) fires
no effect — only Mech::Reset + the level-2 "translocated" cockpit alarm.
BTPLAYER source is absent from all archives (binary-only). RP411 analog
uses InitialStasisFlag (sim gate, no visual). Verdict: the pod build's
respawn is mech-redrop + translocated alarm; the "blue whirlwind" is not
a shipped REL410 asset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Death transition (mech4) now dispatches VehicleDead(-1) to the owning
player; BTPlayer::VehicleDeadMessageHandler restructured to the authentic
@004c05c4 three-way branch: -1 = death bookkeeping + sever playerVehicle
(wreck stays) + 5s re-post; >=0 = engine drop-zone hunt -> DropZoneReply
-> CreatePlayerVehicle (new mech); -2 = the acquire probe. Guarded on a
DropZones group so a zone-less mission stays dead instead of aborting.
Three latent bugs the respawn path exposed, all fixed:
- IsMechDestroyed latched on 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/score, abort). Now latches on movementMode 2||9.
- Score handlers dereferenced the severed playerVehicle during the dead
window; guarded.
- The console score flush routed a NetworkClient::Message through the
player's Entity::Dispatch, which stamps entityID past the smaller struct
-> /RTC1 stack overflow. Sent via application->SendMessage instead.
- Renderer LoadMission re-entry (per viewpoint-make) re-read the env INI;
its light block Fail'd on stale sceneLightCount. Reset it in
DPLReadEnvironment so the respawn's second read is clean.
Verified 2-node self-drive: B killed repeatedly by A respawns each time
(wreck stays), ticks + reloads + takes fresh damage on the new mech, no
abort/hang across multiple death->respawn cycles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User report from 2-window play: after dying, the pilot could still steer the
wreck around invisibly. Zero throttle/turn/fire and the forced harnesses at
the gBTDrive source when IsMechDestroyed() -- deliberately INPUT-only, so the
gait/animation machinery keeps running and the death collapse plays out.
Also recorded in the KB: the killer's screen shows the wreck SWAP but not the
death SINK (needs the Mech-level death/knockdown update records, disasm
preserved from task #51), and the respawn-cycle scouting (type-12 intro
scripts are binary ref-records; adrop; DropZone + Mech::Reset machinery) for
the recovery-whirlwind reconstruction (task #52).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User report: lasers only visible on the window firing them. The peer's
replicant emitters never learned the master fired.
THE AUTHENTIC PIPE (decomp-verified):
- FUN_0041c350 (the "beam keepalive" ServiceDischarge/ContinueDischarge call)
does TWO things: queue the LOCAL deferred beam-effect callback (@0x4bac0c)
on the app+0x34 manager -- our per-weapon render walk already plays that
role -- and set the subsystem DIRTY bit, which maps to the 2007 engine's
updateModel / ForceUpdate().
- Replication rides SUBSYSTEM UPDATE RECORDS inside the mech's update message:
the roster walk already hands the entity's stream to every subsystem's
PerformAndWatch; Simulation::WriteSimulationUpdate serializes each requested
updateModel bit; Entity::UpdateMessageHandler routes received records by
subsystemID to the subsystem's ReadUpdateRecord. All engine machinery --
the missing pieces were the Emitter's serialize/apply pair + the triggers.
CORRECTIONS to the dormant task-33-era transcriptions (never exercised --
nothing ever set updateModel -- so the latent misreads never surfaced):
- The weapon-family VTABLE SLOT MAP was swapped: slot 6 = ReadUpdateRecord,
7 = WriteUpdateRecord, 9 = TakeDamage (evidence: Mech hierarchy symmetry +
body semantics; @004ba568 resolves an EntityID at rec+0x30 through the
entity index -- record semantics, not Damage). Renamed across MechWeapon /
Emitter / ProjectileWeapon; the real Emitter::TakeDamage @004bafc8 is
undecoded (inherits MechWeapon for now).
- Emitter/MechWeapon Write: `*record = 0x38/0x18` is the record LENGTH, not
recordID; rec+0x30 is the TARGET's EntityID (GetEntityID()), not a colour --
the old `CopyColor(targetEntity+0x184)` was also a databinding trap.
- OVERRIDE-SIGNATURE TRAP: the decls used each class's own shadowing
UpdateRecord typedef as the param type, silently NOT overriding the engine
virtual (the base ran instead; nothing would ever have serialized).
Base-typed params (Simulation__UpdateRecord*), casts inside.
- Emitter::ReadUpdateRecord reconstructed (@004ba568): target EntityID resolve
(drop unknown non-null targets), MechWeapon alarm apply chain, beam fields.
- ServiceDischarge/ContinueDischarge: ForceUpdate() per keepalive tick + one
final record at beam end (turns the peer's beam off).
- Mech::DrawWeaponBeams extracted from the player-only drive block so the walk
runs for REPLICANTS (+ per-mech gun-port cache -- the process-wide statics
would have served the player's segment pointers as the replicant's muzzles).
VERIFIED 2-node: A fires 57 volleys -> 225 emitter records -> B applies all
225 -> B draws 414 beams (PPC blue / laser red, from A's replicant's own gun
ports). Solo un-regressed (150 beams, kill chain, no crash).
Also preserved: the full Mech::WriteUpdateRecord @0x4a0c2c recovery
(reference/decomp/mech_writeupdate_004a0c2c.disasm.txt) with all 9 record
types decoded (pose/alarm/leg-state+heat with the body-channel write-through
re-sync, knockdown, death, impact, movementMode) -- transcription deferred;
it replicates remote knockdown/death/heat and was not needed for beams.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
A peer's replicant mech slid around as a frozen statue: DeadReckon moved
localOrigin but nothing animated the legs. Reconstruct the replicated-motion
display loop: after DeadReckon, derive 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 = forward dot against the -Z facing),
write it to controlsMapper->speedDemand (the LEG channel's live demand
source), and advance the LEG state machine for its JOINT writes only --
travel stays with DeadReckon, so position always follows the master and the
clip cadence matches the replicated speed.
Two replicant-only initialization holes found live (the master's equivalents
are primed per-frame in the PLAYER-only drive block):
- globalTimeScale/idleStrideScale read 0 -> clip advance time dt*0, the leg
machine engaged (state 11) but the clip froze (legCycle stuck 0);
- reverseSpeedMax2 (the run-cycle rise-clamp @0x7a0; LoadLocomotionClips does
NOT set it) read debug-heap garbage -> legCycleSpeed clamped to -4.3e8 on
entering run state 13. Primed both; legCycleSpeed sane-banded once.
VERIFIED 2-node (BT_GOTO driver + BT_REPL_LOG observer): the replicant runs
the full gait lifecycle -- stand -> walk(5,7) -> run(10,12), legCycle 7.5->34
tracking the master's speed, wind-down(8), Standing(0) when the master stops.
Solo un-regressed (goto->aim->kill chain intact, no crash).
projectedVelocity source-swap (the binary publishes the BODY channel's
smoothed projection instead of the actual travel velocity) evaluated and
DEFERRED: it risks a verified working replication feed for a smoothing
nuance; recorded in multiplayer.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The BT_GOTO beeline never actually closed on a target -- three separate faults:
1. It was gated behind `if(gBTDrive.forced)`, and `forced` was set ONLY by
BT_AUTODRIVE -- a chicken-and-egg where the block that would drive was gated
on the drive already being on. So BT_GOTO alone just turned in place (the
"stall"). Fix: BT_GOTO enables forced mode itself AND supplies its own
forward throttle (a beeline must drive, not only steer), cutting the throttle
inside a stop radius (weapon range for "enemy", so it holds + shoots).
2. Steering used the gDriveHeading SCALAR MIRROR (seeded to 0), but MP pilots
spawn facing a non-zero heading -> it steered the WRONG way. Fix: heading
error from the mech's ACTUAL forward (localToWorld's -Z axis), signed angle
to the target.
3. The goto turn was routed through controlsMapper->turnDemand, which zeroes out
in -net mode (the mapper key-bridge only shapes the local viewpoint mech
there) -- the heading froze and A drove straight past B. Fix: apply the goto
turn to the orientation integration DIRECTLY (if gBTGotoActive, turn=
gBTGotoTurn), after the mapper read.
Verified 2-node one-box: A with BT_GOTO="enemy" ALONE (no autodrive) drives
dist 1673->231m, holds at weapon range, mechPicks=59, autofire -> B hdlr=192,
DESTROYED -- a fully automated human-style cross-pod KILL via the real beam
path (not the FORCE_DMG hook). Solo un-regressed (plain autodrive still drives;
goto="enemy" resolves the enemy, mechPicks=59, no crash). BT_GOTO_LOG traces
the beeline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The aim/pick ray was the active RENDER EYE's view direction, which made a
mech unable to aim at a distant enemy:
(a) it inherited the mech BODY pitch -- on a slope the body tilts ~8 deg to
conform to terrain, so the boresight pointed that far DOWN and the pick
ray hit the ground ~50-100m short of the target (groundPicks, never
mechPicks); and
(b) in CHASE view (the default) it was the elevated behind-the-shoulder
camera, so the ray started over the mech's shoulder and flew high.
Fix (L4VIDRND DPLEyeRenderable::Execute, the BTSetAimCamera publish):
- LEVEL the boresight: drop the view-direction pitch and rebuild an upright
basis (world +Y up) -- the guns fire along the mech's gyro-stabilized
HORIZONTAL heading, not the terrain-pitched body. Reticle X still carries
the torso twist (BTTwistToReticleX); reticle Y any elevation.
- ANCHOR the ray origin at the mech's TORSO (myEntity->localToWorld
translation + ~5) instead of the render eye, so it is correct in BOTH the
chase (default) and cockpit views.
Verified end-to-end: solo, enemy 120m 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 757m, 2-node run).
mech4: BT_GOTO="enemy" test aid now chases the nearest peer of ANY instance
(solo dummy OR replicant), not just replicants.
Still open (locomotion, not aim/MP): BT_GOTO/BT_AUTODRIVE stall short of the
enemy, so an AUTOMATED 2-node kill via the real beam isn't demonstrated yet
(the FORCE_DMG hook still is). A human can now aim correctly in the default
view. Solo un-regressed; build clean; KB validator clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-measured interactive -net combat on a 2-node run. The KB's "aim ray dead /
drive dead in -net" was STALE: A's aim ray is live (noRay=0, the active eye
publishes the aim camera) and A drives (BT_AUTODRIVE/BT_GOTO move it). The real
blocker to a human cross-pod kill via the beam path is NOT the netcode:
- Boresight inherits the mech BODY pitch. The aim ray = the active eye's view
dir; on sloped terrain the body pitches to conform and the boresight points
~8 deg down ([EYE] up=(-.03,.99,-.14) == [pick] ldir.y=-0.145 every frame),
so at range the ray hits terrain ~50-100m short of the enemy (mechPicks=0).
- Drive/goto stalls ~700m short before closing to pick/weapon range.
- PickRayHit vs a replicant is thus unconfirmed, but the replicant localToWorld
IS correct (lstart magnitude == true A->B distance; the DeadReckon +
localToWorld=localOrigin block already tracks the replicated position).
Change: add a gated BT_GOTO="enemy" test mode (beeline toward the nearest live
replicant) -- MP spawn coords vary per run so a fixed "x z" can't reliably face
the peer. KB (multiplayer.md) updated with the corrected findings + the real
remaining work (torso-pitch/level-boresight aim ray + drive-to-range).
Solo un-regressed; btl4.exe builds; KB validator clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full cross-pod KILL now works: instance A (host 2) shoots B's mech (host 3)
dead over the network; B runs the death transition on its own screen. Verified
on a 2-node one-box run (BT_MP_FORCE_DMG on A): B's mech takes ~36 remote hits
of 12 and is DESTROYED, with wreck swap + explosion.
ROOT CAUSE (one bug): B's own MASTER mech was INVALID. Entity::Receive(Event*)
does `if(!IsValid()) event->Defer()` -- so a cross-pod-delivered TakeDamage that
arrived, resolved to B's real mech, and Posted, was DEFERRED forever; the handler
never ran. The reconstructed Mech::Make force-set ValidFlag only for replicants
(the P6 bring-up hack for the partial MakeReady handshake); a locally-created
master never got validated.
FIX (one line): Mech::Make now sets ValidFlag for the master too -- the
reconstructed ctor builds the mech synchronously, so it's safe. Nothing else
changed: targeting, the virtual victim->Dispatch, the replicant reroute, the
wire, receive, and id resolution were ALL already correct.
Two prior root causes were BOTH mis-diagnoses from the WRONG messageID:
Entity::TakeDamageMessageID is 18, not 21. Every earlier probe filtered
msgID==21, never matched a real TakeDamage, and mis-correlated the stray 21
hits' 3:19 / classID-48 against the real 3:22 hit -- inventing (a) a "cross-TU
Entity layout divergence" (disproven: offsetof identical in both TUs, sizeof
444, entityID@0x17C) and (b) a "localID corrupted on the wire". Both FALSE; the
id is correct end-to-end (3:22 = B's master).
- game/reconstructed/mech.cpp: validate master + replicant at Make.
- game/reconstructed/mech4.cpp: BT_MP_FORCE_DMG cleaned to a concise gated
2-node cross-pod reproducer (real virtual Dispatch path).
- context/multiplayer.md, reconstruction-gotchas.md: corrected root cause +
the validity-defers-cross-pod gotcha. Engine diagnostics reverted to clean.
Solo un-regressed (the solo enemy was already force-valid; the change only ADDS
ValidFlag). btl4.exe builds; full mission loop clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prior commit (c78662a) filed cross-pod damage failure under a
"cross-TU Entity/EntityID layout divergence" (and the P5 base-region
audit). A compile-time offsetof probe injected into BOTH translation
units disproves that: game/reconstructed AND engine/MUNGA compute an
IDENTICAL Entity layout --
sizeof(Entity)=444 offsetof(entityID)=380 (0x17C)
offsetof(ownerID)=388 offsetof(simulationFlags)=32
offsetof(Mech,entityID)=380 (Entity subobject at 0)
So Entity::Dispatch reads this->entityID at the SAME offset mech4's
GetEntityID() does -- there is no per-TU read difference, and the
logged 3:22 (mech4 candidate) vs 3:19 (engine Dispatch) cannot be one
object read two ways. Those lines were different objects/messages,
mis-correlated. The distinct, REAL P5 "base-region layout divergence"
(HARD_PROBLEMS.md) is about raw-offset stomps at this+0x2d4..0x2f0 --
far above entityID -- and is unrelated to #47.
Changes:
- Revert the committed BT_MP_NET engine diagnostics (ENTITY/EVENT/
NTTMGR/RECEIVER/L4NET) to the clean a9c3e96 baseline -- those are
the very instruments that produced the mis-correlated data.
- mech4 BT_MP_FORCE_DMG hook: dispatch via the real m->Dispatch(&td)
path (no false id stamping); comment records the offsetof finding.
- context/multiplayer.md: layout-divergence RULED OUT; 3:22/3:19 marked
unconfirmed; leading hypotheses reframed as H2 (wire host-relative
(de)serialization) vs H3 (replicant id != master's registered key),
to be distinguished by a 2-node run with per-message correlation.
Solo un-regressed (mech walks + targets, 0 faults). Cross-pod delivery
remains open, but the investigation is redirected off the wrong (large,
structural) layout-audit path onto the EntityID wire/id-assignment path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Traced the dispatched-message delivery end to end with BT_MP_NET /
BT_MP_FORCE_DMG. Everything works except one wire step:
- A's Entity::Dispatch reroutes the replicant's TakeDamage
(application->SendMessage(ownerID=3, EntityManager, msg)); POST-Dispatch
the message carries entityID=3:22 (host:local) -- the replicant's own
ID, matching B's master (GetEntityID()==3:22). VERIFIED sent.
- On B the message ARRIVES, GetEntityPointer finds an entity, Posts it,
the event drains (ProcessEventTask = ProcessOneEvent(0)), Event::Process
runs, Receive finds+calls a handler. VERIFIED the full deliver chain.
- BUG: B receives entityID=3:19, NOT 3:22 -- the localID dropped by
exactly the hostID (3) between A's send and B's receive. So
GetEntityPointer(3:19) returns the WRONG entity (classID 48, not the
mech 0xBB9), whose base handler ignores the unaimed hit -> 0 damage.
Auto-replicated UPDATE records (msgID 18) arrive with the correct 3:22
and find the mech, so the corruption is specific to the dispatched-
message wire path/direction.
Next: the host-relative EntityID (de)serialization on the dispatched-msg
path (RoutePacket / packet EntityID encoding) -- diff vs the update path
which translates correctly. Diagnostics retained (all BT_MP_NET-gated,
off in solo -- verified: solo 22 hits, 0 probe noise).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
STEP 1 -- target any peer mech: DONE + verified. A live-mech registry
(BTRegisterMech/BTDeregisterMech from the Mech ctor/dtor) collects
every mech -- player, dummy, and peer replicants. The boresight
world-pick walks BTGetTargetCandidates (all mechs != shooter, closest
ray hit) and the whole fire/damage block retargets from the solo
gEnemyMech to the picked hotTarget; missile/projectile validation
generalised via BTIsRegisteredMech. Verified one-box: instance A
enumerates B's mech as a live ReplicantInstance (20 zones, ownerID=3).
Solo un-regressed (pick->damage->kill clean, 28 hits + DESTROYED).
STEP 2 -- cross-pod damage: dispatch + engine reroute are CORRECT and
invoked with a valid owner, but the dispatched message doesn't reach
the master. Entity::Dispatch (ENTITY.cpp:244) reroutes a replicant's
msg via application->SendMessage(ownerID,...); BT_MP_FORCE_DMG proves A
dispatches at B's replicant with ownerID=3, yet B takes 0 STEP-6
damage. Corrects the KB's [T3] "Dispatch already reroutes": the reroute
FIRES; the break is downstream in the transport/delivery of a
dispatched entity-message (update records flow fine -- net-rx works).
Next MP task = that wire delivery. Diagnostics BT_MP_LOG /
BT_MP_FORCE_DMG retained.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One-box smoke test re-run on the current build: console egg -> mesh ->
RunningMission both instances, 174+ x144-byte update records each side,
2 mech trees per instance, 0 crashes (~6 min). The movement-replication
milestone is intact after all the targeting/weapon/death changes.
Gap map to a first playable LAN fight recorded in
context/multiplayer.md (wiring order): (1) generalize the world-pick
from gEnemyMech to all peer mechs; (2) exercise the Dispatch reroute
for cross-pod TakeDamage; (3) victim state visuals on the shooter's
screen (zone replication or a death event); (4) cross-pod beam visuals
via the AUTHENTIC beam-keepalive messages (FUN_0041c350, templates
@0x511e6c/78 -- already stubbed in emitter.cpp); (5) 2-window driving +
DEATHS scoring; respawn deferred to the P5 teardown debt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>