User-reported 'gliding stops': when the master halts, the peer's legs wind down
to standing but the error-absorption kept sliding the body forward to close a
small residual offset -- visible precisely because no leg motion masks it. With
the feet planted a sub-unit correction is imperceptible, so resolve it in one
frame (k=1) when replLegAdv~0 and the offset is small, instead of gliding it in.
Large offsets at rest (a real teleport) still ease.
Measured (stop-emphasis sweep): maxStep 1.77u->0.98u (now sub-unit), peer drift
2.1u->1.0u mean. The remaining <=1u snaps are the leg-vs-body channel-shape
mismatch -- likely at/near the in-spec floor for this test rig (the Python
console relay batches packets, unlike the real dedicated pod network).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chasing the ~2.9u residual snap: instrumented BT_MIRDIV and isolated it to the
PEER side (master send-mirror error is accurate, mean 0.39u < 0.55u threshold;
peer drifted 3-11u from received authority). Root: the master models the peer
with the BODY channel but the peer moves with the LEG channel -- slightly
different travel/frame -- so the master deadband does not fire exactly when the
peer drifts, and the fixed ~1/3s offset decay bled slower than accel/decel drift
accumulated. A hard ground-snap (authentic FUN_004ab1c8:14985) popped instead
(records not perfectly dense on the one-box relay).
FIX: error-proportional absorption -- bleed the offset to updateOrigin at a rate
that scales with the error (k 0.15..0.6/frame): small steady offset absorbed
gently (no foot-pop), large speed-change drift caught in a few frames.
Measured (through-zero sweep): peer drift 11u->2.1u, maxStep 2.9u->1.77u, ratio
1.043->1.036. Remaining <=1.8u snaps are the leg-vs-body channel-shape mismatch;
fully closing needs a leg-channel send-mirror (second instance) -- deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User confirms 'way better'; single-player un-regressed (master walks correctly,
the bodyTargetSpeed change only affects the invisible mj=0 body channel).
Flip the authentic-coupled path ON by default:
- peer position: gait-coupled linear (was velocity dead-reckon); BT_DR_POS=1 reverts
- master send-mirror: gait projection of projectedOrigin; BT_NO_MASTER_GAITMIRROR reverts
The two-source split (position from velocity + animation from commanded speed)
that mismatched during speed changes is retired. Residual: occasional ~2.9u
snap from the leg(peer)-vs-body(mirror) channel mismatch -- second-order,
tracked for follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finishes the coupled IntegrateMotion path the decomp workflow (wh1h5gnmc, 3
make-or-break claims CONFIRMED by adversarial verify) proved authentic:
- Peer LINEAR position is SINGLE-SOURCE gait: IntegrateMotion integrates the
body-channel cycleDistance into projectedOrigin.linear@0x260, copied verbatim
to localOrigin -- NOT velocity dead-reckon (that is angular/heading ONLY).
0x260 'motionDelta' and 'projectedOrigin' are literally the same field; the
'contradiction' was our reconstruction's two misnamed shadows.
- Master and peer run the SAME predictor; the master's SEND-mirror must run it
too (binary FUN_004a9b5c @0x4aab9c) so a gait-driven peer stays anchored. Our
mirror used the constant-velocity deadReckoner + overwrote bodyTargetSpeed
live every frame -> could not model a gait peer (the tug-of-war).
- T4 CONFIRMED: both channels pose the full skeleton (same JointedMover); keep
the peer LEG channel (our body channel is unbound on the peer).
CHANGES (all behind BT_MASTER_GAITMIRROR / BT_ROOT_POS, DEFAULT OFF -- zero
change to shipped behavior until visually confirmed):
- mech4.cpp: send-mirror advances projectedOrigin by the mj=0 body channel
travel (mirrorBodyAdv) rotated by heading + last-sent angular vel, re-seeded
to localOrigin on each send, instead of the deadReckoner; and stops the
per-frame live bodyTargetSpeed overwrite so the mirror slews toward last-sent.
- mech.hpp/mech.cpp: mirrorBodyAdv member.
Measured A/B (autonomous through-zero sweep circle, the speed-change regime):
velocity two-source (current default): ratio 1.0022 BUT user-visible glitch
coupled, NO mirror (dense): ratio 1.86 (tug-of-war)
coupled + gait mirror, dense OFF: ratio 1.14, back-steps 0.1%
coupled + gait mirror, dense ON: ratio 1.043, back-steps 0.1%, maxStep 2.9u
The single-source coupling is proven (backward-stepping 0.1% vs the split's
churn). Residual 2.9u occasional snap = leg(peer)-vs-body(mirror) channel
mismatch, second-order. Awaiting visual confirmation before default promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The user's architectural question -- 'should animation and velocity even be
allowed to be uncoupled?' -- is decomp-CONFIRMED correct: the original peer
(FUN_004ab430 -> FUN_004ab1c8) drove POSITION FROM THE CLIP'S ROOT TRAVEL
between records (feet<->ground locked by construction) with a pose-sync
offset decay absorbing record corrections. Our port dead-reckons position
from velocity while the legs run on commanded speed -- the two only agree at
steady state, mismatching exactly during speed changes (the reported glitch).
This commit lands the peer half of the coupled architecture, env-gated:
- BT_ROOT_POS=1: peer position += clip travel rotated by heading (mirror of
the master world-step @3325, == IntegrateMotion tail @004ab1c8); pose
records absorbed via the authentic offset-decay (motionEventVector
mechanism) instead of snapping.
- Measured A/B (through-zero sweep circle, harshest speed-change regime):
velocity-lerp (default): step ratio 1.0022 (clean)
root-motion + sparse records: 11-17u anchor snaps (master's velocity
mirror no longer models a gait-driven peer -> under-sends)
root-motion + dense + offset-decay: evenness OK but ratio 1.86 --
authority tug-of-war (double-authoring, exactly the D5 risk).
CONCLUSION: the coupled peer requires the MASTER side of the original
architecture too (gait-driven send-gate mirror / channel-B IntegrateMotion
projection) -- a coherent rebuild for a fresh session, not a peer-only
patch. Default therefore stays velocity dead-reckon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decomp workflow w0odszxro settled the speed model: the pod throttle was ANALOG-
CONTINUOUS (RIO Ranger, 800 ADC counts, 0.05 deadband; sole detent = snap-to-1.0
within 0.05 @004d196c). '5 speeds' is FALSE -- the 1-5 keys are MFD mode pages;
the 0..5 stepper is HUD/radar zoom. BONUS: throttleState@0x4a4 is a MISNOMER --
binary census proves it is the fall-contact surface material cache (0..7, init
2=Concrete), written only at knockdown; rename pending.
Empirical rule-outs (autonomous drive-sweep harness, this commit):
- Record density: with the clock guard + incremental heading in, a per-frame
continuous demand sweep measures IDENTICAL to constant throttle on position
evenness ([repljit]), render heading ([rendhdg]), and gait cadence ([gaitev]).
- Type-3 stomps: ~0 fire in sustained sweeps (only at launch) -- not the driver.
- The 0.05-grid keyboard publish experiment is retained env-gated OFF
(BT_GRID_LEVER) -- the ADC was ~continuous, so the grid is NOT authentic and
gains nothing measurable; default publish stays continuous (authentic).
New tooling: BT_DRIVE_SWEEP[0] (forced-drive triangle sweep, optional through-
zero), BT_FORCE_STEP (0.05-grid variant), BT_GAITEV (per-frame leg-clip advance
+ state-flip + demand-change stats), [t3rx] (type-3 stomp trace).
STATUS: user still reports visible speed-change glitches in interactive play;
all harness metrics saturate at baseline -- next step is probes ON the user's
interactive session (their eyes + instruments on the same run).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The discriminating user report: peer motion was smooth under the autonomous
harness but skipped when driven by KEYBOARD -- even pure walking / pure spinning.
The harness pins throttle/turn constant; the keyboard SWEEPS the throttle lever
every frame of a key-hold (mech4 sLever integrator), so mapper->speedDemand
changes every frame, and the speed-deadband gate (authentic exact !=) fires a
type-2 speed record EVERY FRAME for the whole accel/decel. The base reader
Simulation::ReadUpdateRecord stamps lastUpdate=Now() on EVERY record (flagged
'HACK - should be based upon message->timeStamp' in the 1995 source) -- so each
type-2 SHRINKS the peer reckoner's projection span (nextUpdate-lastUpdate)
without refreshing updateOrigin: the position target jumps backward toward the
stale origin, the next pose record yanks it forward -> target oscillation every
frame during any input sweep. Matches the session-long 'worst on accel/decel'.
FIX (mech.cpp Mech::ReadUpdateRecord): non-pose records (2,3,5,6,7,8) preserve
lastUpdate around the base call (keeping the real payload, simulationState).
Pose (0) and resync (4) keep their authentic clock behavior. BT_T2_CLOCK
restores the old stamping for A/B.
REPRO HARNESS (mechmppr.cpp): BT_FORCE_SWEEP=<period> triangle-sweeps the forced
throttle 0.2..0.9 -- a type-2 record per frame, the keyboard-skip repro the
constant-throttle harness could never produce.
Verified A/B, autonomous circle+sweep (the keyboard regime):
old clock: worst frame spike 10.4x avg, path/net ratio 1.069 (backtracking)
guarded: worst 4.6x, ratio 1.0022 (no backtracking)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The user-keyboard regime (steering WHILE walking) fired BOTH dense record streams
at once and exposed the last divergence: our peer heading used the engine
Mover::DeadReckon slerp-toward-projection, whose angular projection reads the
SHARED lastUpdate/nextUpdate timebase. The dense type-0 pose stream resets that
timebase every frame while walking while RESTORING a stale orientation (the
authentic case-0 strip, verified against FUN_004a1232 case 0) -- so the angular
target barely advances from a stale base and the slerp DRAGS the heading back
every frame. Measured: peer yaw advancing at ~40% rate with half the frames
stepping BACKWARD. Pure-spin and pure-walk tests never showed it (single
stream) -- why autonomous looked smooth while keyboard play skipped.
AUTHENTIC FIX (decomp FUN_004ab1c8 -> FUN_004ab188/FUN_00409f58): the original
replicant integrates its heading INCREMENTALLY from the CURRENT pose -- exact
rotation of (replicated yaw rate * dt) composed on each frame -- and re-anchors
on type-4 receipt. It never slerps toward a projected angular target.
- mech4.cpp peer branch: save heading, let DeadReckon own LINEAR only, then
integrate heading incrementally (ReconQuatIntegrate); on angSyncLatch (new
type-4) re-anchor to updateOrigin.
- mech.hpp/mech.cpp: angSyncLatch member (angular analog of poseSyncLatch),
armed by ReadUpdateRecord case 4.
- SCALAR peer-yaw mirror (angMirrorYaw/Rate/Time, re-based in the type-4
writer): replaces the quaternion projectedOrigin mirror for the ANGLE
deadband -- the old one was recomputed each frame by the master's own
reckoner from timing it does not control and false-fired in pi-waves
(measured maxAng~=pi bursts -> periodic resync floods).
- Dense-rot type-4 send REMOVED (was masking the old crude projection; not
authentic; churned the shared horizon). Orientation now rides the sparse
angle/velocity deadband resyncs exactly as the binary's.
Verified live-autonomous:
- pure spin: 59/59 perfectly regular peer yaw steps; master resyncs 0/s with
mirror drift ~5e-7 (records near-silent, authentic sparse model).
- walk+turn circle (the user regime): peer sim yaw monotonic at exactly the
master's rate (0.00556/frame @ 0.327 rad/s), no backward steps, no stalls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Confirmed via BT_TRNRATE + direct disasm of the un-exported perf drive (0x4aa3d3)
that turn-in-place rate == walkingTurnRate (speed=0 collapses the lerp) and the trn
clip cadence is authentically FIXED -- so the reconstruction is faithful and the
residual legs-vs-body slip is authentic (decoupled by design; scaling the clip would
deviate from the decomp). Probe left env-gated for future asset-tuning checks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Answers 'how did the original handle this?' from the decomp (subagent hunt):
the 1995 binary's replicant reckoner (FUN_004ab1c8 -> FUN_00409f58, part_000.c:9359)
integrates heading EXACTLY: build a unit axis-angle rotation quaternion from
angularVelocity*dt ({axis*sin(t/2), cos(t/2)}) and Hamilton-multiply it onto the
heading (FUN_00409d9c) -- exact for any timestep, stays on the unit sphere. It
further carries the full orientation quaternion in the FREQUENT pose record
(FUN_0040a938, 7-float pose), so the dead-reckon gap stays tiny.
Our reconstruction diverged two ways, both fixed:
1. ReconQuatIntegrate (mechrecon.hpp) -- the reconstruction of FUN_00409f58 -- was
STUBBED as , a crude small-angle VECTOR add. Restored to
the real exact axis-angle composition. (A 'no stand-ins' violation: the comment
even wrongly claimed Quaternion::Add == FUN_00409f58.)
2. The engine Mover reckoner (MOVER.cpp AcceleratedDeadReckoner/LinearDeadReckoner)
also did the vector Add on projectedOrigin.angularPosition -> over a long peer
record gap it diverged to ~180deg then snapped (the reported spin HANG/hesitation).
Routed both through a new ExactAngularProject() helper (same exact math).
3. Orientation only rode the sparse type-4 resync; during a PURE spin the linear
dense-send never fires (not translating), so the gap ballooned (~1.6s) and the now-
exact projection sat far ahead -> the slerp jumped. Added an ANGULAR dense-send
(resync every frame while |yawRate|>0.1), mirroring the original's frequent-
orientation model -> gap stays tiny -> smooth.
Verified live-autonomous (BT_AUTODRIVE+BT_FORCE_TURN + BT_RENDHDG render-rate probe):
the ~180deg divergence + multi-radian snaps are GONE (rendered maxStep 0.05-0.10 rad,
no jumps). User confirms: no frame hang/hesitation. MOVER.cpp change is strictly
more correct (exact==crude for the small per-frame master case; only large-gap peer
extrapolation changes), so walking is unaffected.
KNOWN REMAINING (separate, smaller): the turn-STEP leg animation (trn clip, mech2.cpp
advance_normally) runs at a FIXED idleStrideScale cadence that does not scale with the
rotation rate, so the legs lag/skip vs the (now-correct) body rotation. Next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Autonomous headless spin harness (BT_AUTODRIVE+BT_FORCE_TURN) + probes that
finally measure what the eye sees, not the sim heading:
- BT_RENDHDG (L4VIDEO render loop): per-RENDERED-frame peer heading step
evenness -- avgStep/maxStep/max-avg ratio. Confirmed the residual spin
'hesitation' is UNEVEN rendered rotation (max/avg ~2.8x), correlated with the
byAngle resync-flood bursts (maxAng ~= pi), NOT a render-vs-sim rate stall
(render redraws a fresh heading every frame).
- BT_SPIN / BT_ANGSIGN: resync trigger breakdown (byAngle/byVel/byRest) + the
frame-level local/update/projected angular-Y that exposed the Abs() macro bug.
- gBTReplRenderYaw: peer heading published from the sim to the render probe.
- Normalize projectedOrigin.angularPosition after the peer-mirror advance
(adding a scaled ang-vel VECTOR to a quaternion denormalizes it) -- correct,
but NOT sufficient: the pi divergence is REAL, from the crude large-angle
quaternion projection over the sparse angular-record interval during a PURE
spin (linear dense-send doesn't fire when not translating; type-4 is the only
orientation carrier and resets the horizon, so dense type-4 -> half-rate).
Residual ROOT (scoped, not yet fixed): the reckoner (MOVER.cpp:457-466) projects
rotation by ADDING vector*t to the heading quaternion -- a small-angle approx the
original kept valid via dense records; our pure-spin case has 1.6s record gaps so
it diverges to ~180deg -> uneven render. Fix path = proper quaternion integration
in the reckoner, or refresh the angular origin without the type-4 horizon reset.
Both touch the shared engine dead-reckon -> aligning before the change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause found via autonomous headless spin (BT_AUTODRIVE+BT_FORCE_TURN) +
frame-level [angsign] probe: the STYLE.H:118 macro
#define Abs(value) ((value>0) ? value : -value)
has NO parens around value, so Abs(a-b) mis-expands to (a-b>0 ? a-b : -a-b) ==
-(a+b) on the false branch, NOT |a-b|. The angular resync velocity gate passed
an EXPRESSION: Abs(localVelocity.angular.y - updateVelocity.angular.y). For a
steady spin the two are equal, so a-b==0 takes the false branch and yields
-(2*rate): on a REVERSE spin that is +2*rate > velDb, firing a type-4 resync
EVERY frame. The resync flood reset the peer's dead-reckon horizon every frame,
pinning its angular slerp at ~half rate and (as the drift periodically ran to
180deg) freezing the rotation for seconds while the leg turn-clip kept stomping.
Confirmed live-autonomous: velDrift 0 (was +2.618), byVel 0 (was 55/55), peer
rendered-rotation median dyaw/expected 1.00 (was 0.49), no multi-second dt gaps.
Fix (mech4.cpp resync gate, reconstruction side -- the engine macro is original,
the original master-perf avoided it by diffing into a temp):
- velDrift: diff into a temp, explicit (d<0?-d:d) so the macro never sees an
expression.
- angDrift: already reworked to a single-variable wrap-safe yaw delta (also
dodges the macro) -- and it corrects a prior units/wrap bug (was comparing a
raw quaternion-Y component against a radian deadband).
- velocity gate now diffs vs updateVelocity (the value the peer extrapolates
with), same scalar representation as localVelocity.
- BT_SPIN / BT_ANGSIGN / updYaw diagnostic probes (env-gated).
KNOWN RESIDUAL (smaller, follow-up): the ANGLE gate still bursts when the crude
large-angle quaternion projection in the reckoner runs updateOrigin stale between
writes and drifts ~180deg; causes occasional single-frame hitches (peer median is
still 1.0), not the freeze. Separate from this fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decomp-verified via multi-agent investigation (workflow wv1km7lvc, adversarially
checked against reference/decomp/all/part_012.c + engine T0).
ROOT CAUSE (D3 CONFIRMED): the peer's leg channel FUN_004a5028 reads its speed
demand from mapper->speedDemand (**(mech+0x128)+0x128, part_012.c:11947/11975/
12028). The reconstruction FABRICATED that input from the noisy dead-reckoned
velocity magnitude and PINNED it flat with a standSpeed*1.05 floor across the
walk threshold -- the leg stutter/skip + cadence-vs-travel desync, worst on
accel/decel. The authentic input is the replicated commanded speed
bodyTargetSpeed@0x6b4 -- the master's own throttle-commanded speedDemand
(mechmppr.cpp:749), stamped into every record (mech.cpp:2036) and read back on
the peer (mech.cpp:1833/1841/1871). It is the IDENTICAL value the master's leg
channel consumes, so the peer clears the stand->walk gate exactly when the master
does and ramps cadence continuously through accel/decel.
Confirms the 07-13/07-14 regression: bb795e2 (smooth, 07-12) fed raw derived
speed with NO floor; the floor added in the task-#64/#50 spiral is what pinned
the cadence.
CHANGES (mech4.cpp replicant branch):
- Change 1 (D3): replMppr->speedDemand = bodyTargetSpeed (was derived velocity
+ standSpeed*1.05 floor). Old path kept behind BT_REPL_VEL for A/B.
- Turning: authentic peer turning is net-driven (angular slerp in DeadReckon,
MOVER.cpp:521-525, D8). Removed the flickery bare +-0.02 yawRate->turnDemand.
The visible turn-STEP (authentically from the body channel replaying the
type-3 turn state; we run the leg channel per D2) is reproduced by arming the
trn clip from the replicated yaw rate with HYSTERESIS (enter >0.08, hold until
<0.03) so the clip no longer chatters. BT_REPL_NOTURN = decomp-strict, no step.
REFUTED (kept as-is): D1/D5 -- peer POSITION authentically comes from the engine
velocity dead-reckoner (Mover::DeadReckon, T0), NOT the gait travel; the
body-channel switch DEVIATES (double-authors position + T4 leg-bone risk) and was
NOT taken. D4 -- bodyTargetSpeed is non-zero tracking throttle (0 only on coast,
which authentically winds down to stand).
Verified live: walking + accel/decel + in-place pivot all smooth (user-confirmed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Session-in-progress peer-motion work, checkpointed before bisecting the
07-13/07-14 gait regression. Contains: TCP_NODELAY on game sockets (L4NET),
every-frame dense position send while moving + came-to-rest/REST send +
per-frame BT_JIT/BT_ANIM smoothness probes (mech4). bodyTargetSpeed feed
reverted to derived-velocity default. MOVER.cpp spline experiment already
reverted. NOT a fix -- a checkpoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The linear pose send-gate watched only POSITION drift + the 2s heartbeat.
When a mech stops, its localOrigin is pinned and the master's own dead-reckon
projection re-bases to it each frame, so error->0 and NO pose record fires --
leaving the peer holding the last WALKING velocity (updateVelocity). The
replicant then dead-reckons + animates a phantom walk-in-place until the 2s
heartbeat (stretched to ~30s when the master window is OS-throttled to ~8fps
while backgrounded during solo two-window testing).
Add the symmetric linear 'came-to-rest' trigger -- the exact analog of the
angular (live yaw-rate==0 && replicated yaw-rate!=0) resync trigger already
present in the type-4 gate: fire a pose record the frame the live horizontal
speed collapses while the last-SENT speed was still non-zero, so the replicant
receives velocity=0 immediately instead of waiting for the heartbeat.
Diagnosis established via BT_WIRE tx0/rx0 probes: records deliver 1:1
(239 tx =~ 240 rx), velocities match -- NOT a replication stall; the master
simply never transmitted the stop. Also confirmed the dominant choppiness is
OS window-throttling (master 8fps backgrounded vs observer 63fps), a
two-windows-on-one-box test artifact absent on real pod hardware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior floor gated on walkStrideLength*0.5, but walkStrideLength is a
stride METRIC (~22.0), not the walk velocity (~6.1) -- so the condition
sd > 11 was never true for a walking peer and the floor never fired. The
replicant's derived speedDemand (== actual velocity 6.13) sits BELOW
standSpeed (6.83), so the leg SM's stand->walk gate (standSpeed < demand)
never tripped and the peer slid forward in the Standing pose.
Gate on standSpeed instead: floor the demand to standSpeed*1.05 when the
peer is clearly walking (demand in (standSpeed*0.5, standSpeed*1.05)).
Verified live: uvel=6.13 -> spd=7.169 floored, legState enters 5 (walk)
on frame 1 with no Standing-pose slide. standSpeed=6.8277 confirmed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root-caused from a live per-frame [replmov] trace (user's session, where replication
works -- the headless rig won't spawn a replicant): the replicant's leg SM sat in
Standing (legState 0, legFrm pinned at 19) while dead-reckoning forward at spd=6.13,
until the master's speed rose to 6.99, then it snapped to StandToWalk.
Cause: the replicant derives speedDemand from the master's ACTUAL velocity, but a
mech's forward walk velocity == walkStrideLength, which sits BELOW standSpeed (bhk1:
walk 6.13 < stand ~6.8). The leg SM's stand->walk gate is ,
so a steadily-walking peer's derived demand NEVER crosses it -> the peer slides in the
standing pose until the master happens to accelerate past standSpeed. The master never
hits this: it feeds the leg SM its COMMANDED throttle speed (>> standSpeed), not the
actual velocity.
Fix: when the peer is clearly moving forward, floor the derived demand just past
standSpeed so it enters the walk cycle immediately; the walk case clamps legCycleSpeed
back to walkStrideLength, so cadence still matches travel (no foot-slip). Reverse
needs no floor (its gate is commandedSpeed < ZeroSpeed, tripped by any negative).
Diag: BT_REPL_MOV (per-frame replicant pos/vel/speed/legState/legFrm + master twin).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EOF
records; also the 2007 call-counter clock stub + two crash fixes
User-reported: peer mechs turn/move choppy ("missing frames"). Measured per-frame
(BT_REPL_HDG): 13% of frames the replicant's heading STALLED, 11% it JUMPED 2-6x.
THREE layered defects found:
1) THE CLOCK STUB (engine substrate): TIMESTUB.cpp's GetRTC/GetHiRes were 2007
`return time++` call-counters -- the "clock" advanced per CALL, not per ms --
and TIMESTUB won the /FORCE duplicate-symbol race over the REAL QPC clock in
L4TIME.cpp (LNK4006). Every Now()-domain consumer (dead-reckon above all) ran
on call-count pseudo-time. Removed TIMESTUB from the build; L4TIME covers every
symbol. [The real clock alone did NOT cure the chop -- but it was objectively
broken and un-gated the two latent bugs below.]
2) TWO CRASH FIXES the real clock exposed:
- legAnimationState@0x3b0 never ctor-initialized (the task-#56 0xCDCDCDCD
family): the type-3 writer re-dispatches SetBodyAnimation(legAnimationState)
on the WRITER; a record emitted before the leg SM's first tick passed raw
0xCDCDCDCD as a clip index -> AV (cdb-pinned, mech2.cpp:233). Init 0.
- Replicants were SERIALIZING update records: the tail WriteSimulationUpdate ran
for every instance, and the port's replicant leg-SM accommodation (task #50)
calls ForceUpdate -> replicants emitted derived/uninitialized state into the
stream. Master-gated; replicant marks discarded (master-authoritative).
3) THE CHOP ITSELF: the master's resync send-gate compares localOrigin vs
projectedOrigin -- the PEER-ESTIMATE mirror -- but the port master never
maintained projectedOrigin (the bring-up drive replaced Mover::Perform; the
engine only updates the projection inside replicant-only DeadReckon). Stale
mirror -> |local-projected| > deadband EVERY frame -> a type-4 re-base record
EVERY frame -> the replicant hard-copied the master's deadband-quantized
heading each frame (the engine lerp never engaged; nextUpdate always behind
till) -> stall/snap beat = the chop. FIX: advance the mirror each frame by the
last-SENT angular velocity (what the peer is extrapolating) and re-base it in
the type-4 writer. Records now flow only on TRUE drift (~1 per 5 frames in a
steady spin), the replicant extrapolates smoothly between them, and the lerp
horizon finally engages. Measured: STALLS 13%->1%, JUMPS 11%->1% (residue: tiny
sub-degree backward corrections on record arrival -- inherent dead-reckon
overshoot the lerp absorbs).
Diag probes: BT_REPL_HDG (per-frame replicant heading + dead-reckon internals),
BT_REPL_TRN (replicant turn/leg state). scratchpad/clockcrash_bp.txt (cdb).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The console emulator's recv loop only caught socket.timeout -- any reset/abort killed
the thread UNHANDLED, its buffered stdout died with it, and with both threads gone the
process exited silently. The 2-node session then froze replication BOTH ways (each pod
holding a dead ConsoleHost socket; the engine never logged a console disconnect). Now:
OSErrors are caught + logged and the thread idles alive; prints flush. Run with
python -u and > console.log to capture the death reason on any recurrence. The engine-
side question (why a dead console freezes peer replication + the disconnect handler's
gameListenerSocket-vs-consoleListenerSocket close) is logged in open-questions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two more master rules in the trn entry broke replicants:
(1) the LOCKSTEP body weld: a replicant never runs the body SM ('joints only',
mech4.cpp:1989), so the first trn entry armed the body to state 4 where it stuck
forever -- bodyAnimationState==Standing then blocked EVERY later trn entry (the
peer 'rotates as a statue'). Replicants now skip the weld gate and do not arm
the inert body channel.
(2) today's authentic full [0,standSpeed] entry range: a replicant's DERIVED speed
sweeps that band on every dead-reckoned start/stop with derived turnDemand
pinned +-1, so trn kept arming mid-locomotion and speed-exiting (jerky walking).
Replicants keep the narrow near-zero entry gate (the pre-#64b accommodation).
Masters keep the authentic dispatcher (full range + lockstep weld) unchanged. [T3
replicant accommodation / T1 master logic]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Today's authentic trn dispatcher folded the master-perf turn-STOP exit into leg/body
case 4. But the master perf (FUN_004a9b5c) runs on MasterInstance mechs, NOT
replicants -- and a replicant derives turnDemand from the noisy REPLICATED yaw rate
(mech4.cpp:1968), which dips into the +-0.05 deadband between dead-reckon updates. So
the exit kicked the peer out of trn every few frames -> the peer 'rotated as a statue'
+ jerky (user-reported regression from yesterday). Gate both turn-stop exits to
GetInstance() != ReplicantInstance; the replicant advances the trn clip from its
replicated turn signal exactly as before the exit was added. Also BT_REPL_TRN probe
(mech4.cpp) logs the replicant yawRate/turnDemand/legState.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BT_SPAWN_AT teleport set only X/Z, keeping the old (dropzone) Y -- so teleporting to
a new spot embedded the mech in sloped terrain (user-reported). Lift the teleport
+500 above the old elevation so the authentic per-frame ground SNAP (BoxTree
FindBoundingBoxUnder, no gravity) settles it onto the surface next frame (the snap
only lowers, so it must start above the surface). Diagnostic only.
Also BT_COOL_LOG (heat.cpp): logs a heat subsystem's damageLevel/heatLoad/coolantDraw
when it carries damage -- for the 'do damaged mechs leak coolant' investigation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The radar map derived its rotation from EulerAngles(Quaternion), whose decomposition
is AMBIGUOUS for a yawing mech: as yaw sweeps past +/-pi the quaternion double-cover
flips it onto the pitch=roll=pi branch, so euler.yaw REVERSES -- the whole radar
counter-rotates the wrong way past 180deg (user: 'flops every 180 degrees'). Switched
both radar sites (view-matrix build + blip delta rotation) to YawPitchRoll, which
applies yaw first and yields a clean continuous 360deg heading -- the SAME fix the
compass HeadingPointer already uses (btl4gaug.cpp:2185). A/B verified live (BT_RADAR_LOG,
spinning madcat): euler.yaw folds (-0.30 vs true -2.84 rad) while ypr.yaw tracks clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Logs each SubsystemCluster::Execute's drawn CurrentTemperature vs the degradation/
failure thresholds + drawState. Confirmed (BT_DEV_GAUGES + madcat + autofire): the
per-subsystem temperature bars build lazily on the engineering MFD screen, execute
live with animating values (weapons heat toward the 1000K degradation line = the jam
threshold) and correct 1000/2000 markers, drawState=0 online. No behavior change (env).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reproduced the QA report end-to-end from the decomp + raw-binary-verified constants.
The coolant-priority effect is real but fire-rate-dependent: at the ~11s combat
cadence, not-boosted jams (T=1058>1000) while boosting the AFC's condenser keeps it
below the line (T=953, no jam) -- exactly as the QA described. Max-spam (8s) overheats
regardless (why the earlier 'weak boost' read was wrong); 16s never jams. All AFC100
constants verified authentic vs BTL4.RES raw bytes (afc_dump.py). No bug, no stand-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the coolant/jam investigation. BT_AF_PERIOD pulses the trigger every <sec>s so
a slower-than-max fire rate can be tested; afc_dump.py reads the AFC100 record raw from
BTL4.RES. Both confirm the QA report is FAITHFULLY reproduced (see KB).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the coolant/jam probe: boost one condenser's valve to 50 and starve the rest
to 1 (share ~0.9 vs the ~1/N baseline) -- the authentic 'coolant priority' scenario,
for measuring the per-weapon boost effect. No behavior change (env-gated).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two-agent decomp hunt on the QA report ('AFCs jam on 3rd shot if coolant priority
not boosted'). Conclusion: the whole coolant->jam chain is reconstructed end-to-end
and FAITHFUL -- the weapon's constant coolantFlowScale=1.0 is authentic (only
condensers' 0x15C is written from the valve, via RecomputeCondenserValves over the
condenser chain mech+0x7cc). Authentic mechanism is second-order-in-equation but
first-order-in-outcome: weapon -> its condenser -> bank; boosting a condenser's valve
share (~5x conductance swing) keeps its weapon below degradationTemp -> no jam.
Distinguished the sticky probabilistic jam (degrad 1000, JammedState 5) from the
self-clearing overheat lockout (failure 2000, weaponAlarm 7). Empirically the port is
directionally correct (~4th-shot probabilistic) but not the QA's deterministic 3rd
shot -- a calibration/scenario question pending QA clarification, NOT a stubbed path.
Do not tune the heat economy to force it without ground-truth.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Probes for verifying the autocannon jam/coolant mechanic against QA ground-truth
(AFCs jam on ~3rd shot uncooled). BT_JAM_LOG logs currentTemp/degradT/failT/alarm/p
at each fire attempt; BT_VALVE=<n> forces every condenser valveState so cooling can
be tested at a known level. No behavior change (both gated on env).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closed the loop: BT_CRIT_PROBE=8 on solo DEV.EGG drove the full per-zone FX chain
end-to-end -- [zonefx] entity 1:321 seg 12 psfx 12/13 -- proving TakeDamage ->
mechdmg effect loop -> BTStartZoneEffect -> StartEntityEffectImplementation (segment
world-pos resolve) -> BTStartPfxAttached fires live. The 2-node fight harness didn't
engage (mechPicks=0, an MP-harness spawn/connect issue, not FX); BT_CRIT_PROBE is the
reliable solo liveness path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reverting a wrong 'correction' I just made: I claimed the six L4VIDRND explosion
renderables were STUBBED and 'never draw'. WRONG -- ScalingExplosionRenderable has a
real ctor/Execute body, and the impact/fire/detonation FX were reconstructed in the
Fire VISUALS (48c9c84) + Impact-FX FORENSICS (065c114) waves. The '//STUBBED: DPL RB'
markers I grepped are benign 2007 empty ctor/dtor notes on unrelated renderables
(InnerProjectile/DPLObjectWrapper/ChildLight), not the explosion FX. Only genuine
open item: the chain is not yet liveness-verified headlessly (no solo enemy).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-zone effect dispatcher was reconstructed + wired the same day the audit
flagged it as 'RECOMMENDED NEXT TARGET' -- the entry was never updated. Correct it:
dispatcher done (btl4vid.cpp:889, wired at mechdmg.cpp:1120); the real remaining
FX gap is the six still-STUBBED L4VIDRND scaling-explosion renderable bodies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The master-perf disasm (0x4aa3d3-0x4aa4ff) computes the yaw rate as
lerp(walkingTurnRate@0x574, runningTurnRate@0x578) by ground speed, not a
constant. Wired it into the drive, replacing the bring-up constant kDriveTurnRate:
- walkingTurnRate/runningTurnRate are now REAL named members. The ctor's
`Wword(0x15d)/(0x15e) = model->...TurnRate * DegreesToRadians` writes were
NO-OPS -- Wword() returns a shared static scratch cell, so the rates were
silently discarded. Now stored + read by name.
- The drive (mech4.cpp) computes authTurnRate: base = walkingTurnRate; above
reverseSpeedMax(0x538) lerp walk->run across [walkStride 0x534 .. topSpeed
0x34c] with an over-run falloff runningTurnRate/t^2; clamp >=0; zeroed in the
death/limbo/airborne leg states (1/2/3) or when deathAnimationLatched(0x650) is
set. Falls back to kDriveTurnRate if the model gave no rates.
The lerp is a RUN-speed refinement -- below reverseSpeedMax (walk / turn-in-place)
the rate is the constant walkTR base; runningTurnRate < walkingTurnRate (a running
mech is less maneuverable). Verified headless (DEV mech, BT_TURN_LOG): walkTR
1.309 rad/s (75 deg/s), runTR 0.873 (50 deg/s), turn-in-place + walk yaw at walkTR,
no crash. [T1 logic / T2 runtime]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The turn-in-place dispatcher lives in the master-perf gap 0x4a9b5c-0x4ab188 that
Ghidra never lifted. Disassembled it with objdump (scratchpad/masterperf.asm) and
hand-decoded the real logic, replacing the two [T3] stand-ins I had invented:
- Entry gate was `commandedSpeed < 0.25*standSpeed` (a near-zero guess). AUTHENTIC
(0x4aa505): arm trn when TURNING (|angVel|>1e-4, port proxy = turnDemand deadband)
AND speed in the FULL [0, standSpeed] sub-walk range AND turnCapable. trn is gated
on TURNING, not on being slow.
- Case-4 exit had a ×4 fast-forward + SetLegAnimation(5) invention. Restored the
VERBATIM decompiled leg-SM exits (part_012.c:12013): standSpeed<spd or reverse ->
Standing, else advance the pivot. Added the master-perf turn-STOP exit (0x4aa5c6):
turn stopped -> Standing + legResetLatch=1 (folded into case 4 since the port has
no separate master-perf frame).
- Body case-4 twin mirrors the authentic exits (mj=0, tracks state for lockstep).
The fast-forward was invented to dodge a turn->walk stutter that was ACTUALLY the
body channel leaking joints (mj=1) -- already root-caused + fixed by mj=0. The
trn->walk seam (legFrm7 cycle-end -> swr legFrm1) is the authentic pose-matched
boundary and the same seam the fast-forward version hit, just at the correct release
speed. Also decoded (documented, not yet wired) the authentic turn-RATE lerp
(walkingTurnRate@0x574 -> runningTurnRate@0x578 by speed; angVel = turnDemand*rate);
the bring-up drive still yaws at the constant kDriveTurnRate.
Headless-verified (BT_AUTODRIVE/BT_FORCE_TURN/BT_WALK_DELAY/BT_GAIT_TRACE): turn-in-
place enters state 4, ramp past standSpeed -> 4->stand->swr->walk, both channels in
lockstep. Feel re-verification pending. [T1 logic / T2 runtime]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User repro (100%): turn in place, push forward before the turn stops -> the gait
skips/stutters + visibly reduced bob; standstill starts always clean. Three-layer
fix, user-verified:
1) THE DISPLAY BUG (the actual visible artifact): v5's "body advances first with
mj=1, leg overwrites last, so body drift can't show" was FALSE. Whenever the two
gait channels phase-split, the BODY channel's out-of-phase joint writes leaked
into the rendered skeleton (rhythmic leg skips, averaged-down bob) while every
leg-channel trace read clean -- the leg DATA was fine, the RENDERED pose wasn't
the leg's. v6: AdvanceBodyAnimation(dt, mj=0) -- the body still advances +
projects for replication (records/cycle speeds unchanged) but no longer touches
the skeleton. One writer, structurally; matches the binary's own observable
("body phase drift is locally INVISIBLE in the binary"). BT_BODY_MJ=1 = old A/B.
2) THE SPLIT SEED: the bring-up trn trigger armed only the LEG channel, so a
turn-in-place entry guaranteed the channels re-entered walk frames apart and the
walk cycles ran permanently out of phase. Lockstep: the Standing trn entry arms
BOTH channels the same frame (body case-4 twin added, same rate/keying), gated on
both Standing. The authentic dispatcher (un-decompiled master-perf gap 0x4a9b5c-
0x4ab188, the sole reader of turnDemand/turnCapable) armed both -- the body's
case-4 machinery is dead code otherwise [T1].
3) THE POSE-MATCH INVARIANT: the engine has NO pose blending; transitions avoid
pops purely by authored pose-matched boundaries. The old trn exits cut the pivot
clip MID-STEP (legFrm 7->1 teleport). Now: entry gated on near-zero speed
(turn-IN-place); on forward command the pivot FAST-FORWARDS to completion (x4)
and the authentic finish callback lands Standing at the stand pose -> the normal
pose-matched stand->walk runs. Decompiled reverse abort kept; the standSpeed
mid-clip abort subsumed (it WAS the pose cut). [T3: 0.25*standSpeed threshold +
4x rate stand in for the gap's constants.]
Harness: BT_FORCE_TURN now reaches gBTDrive.turn (was silently inert for the
gait), BT_WALK_DELAY=<s> holds forced throttle then ramps (the turn-first repro),
BT_GAIT_TRACE=1 per-frame gait trace. Regressions: standstill start, turn-entry,
pure pivot loops, run cycle -- all clean, bob full amplitude (1.33/1.32).
KB: locomotion.md v6 section + trn reconstruction + symptom-family closure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Investigation (workflow) found the searchlight-driven fog swap never worked in the
ORIGINAL 1995 binary either -- it's a latent bug, not a port regression:
SearchlightSimulation (@004b841c) reads requestedOn@0x1E0 (never written) while
ToggleLamp (@004b860c) toggles commandedOn@0x1DC; no bridge exists, so lightState
@0x1D8 is perpetually 0 (lamp never lights). The self-consistent sibling
ThermalSight reads the field it toggles (0x1DC) -- the tell. The port reproduces
the bug faithfully (searchlight.cpp:189). Additionally the port never constructs
PullFogRenderable, so even absent the bug the swap wouldn't fire.
Decision: LEAVE AS-IS + document (faithful to the buggy original); the port keeps
the static lights-ON fog= values. A working swap would DEVIATE from the shipped
binary (repair the sim to read commandedOn + construct PullFogRenderable at the
btl4vid MakeMechRenderables inside pass + a toggle input) -- fully scoped in the KB
if ever wanted. No code change this commit.
Documented in rendering.md (fog section), open-questions.md (deferred subsystems),
subsystems.md (Searchlight). Core per-map/time/weather fog (task #63) is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verifying the searchlight/nosearchlightfog swap (user request): it does NOT work.
The arcade swaps active fog between fog= (headlight on) and nosearchlightfog=
(off, darker/tighter) via PullFogRenderable::Execute (edge-triggered on the mech's
light attrs -> SetFogStyle searchLightOn/Off). But PullFogRenderable is NEVER
CONSTRUCTED in the port (grep: only the .h decl + .cpp def; no `new`), and
searchLightOn/Off is called from nowhere else. Empirical confirmation via the new
BT_FOG_LOG hook: over a 22s run SetFogStyle fired only style 0, zero style 2/3.
So fog is static at the searchlight-ON (fog=) values -- normal lights-on play, so
core per-map/time/weather fog is unaffected -- but the nosearchlightfog variant is
dead. Corrects my earlier "the swap is wired end-to-end" claim (code paths exist,
trigger never instantiated). KB rendering.md updated; wiring it back is a scoped
reconstruction (bind the Searchlight subsys light state to a constructed
PullFogRenderable at viewpoint setup). No behavior change this commit -- diag only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User: "aren't some levels supposed to have fog?" -- they were, and none showed.
BTDPL.INI (the DPL env INI, L4DPLCFG) authors fog=near far r g b (+ often
nosearchlightfog=) on EVERY map/time/WEATHER leaf; weather is a fixed egg field
(clear/fog/soup). Shipped eggs pin cavern/night/clear -> page dsnitclear
(near 90, far 1100, dark blue). The env pipeline resolved + pushed it to D3D, but
it rendered INVISIBLE: D3DRS_FOGTABLEMODE=D3DFOG_LINEAR (table fog) derives its
factor from the perspective-NONLINEAR z-buffer, so without WFOG the 90..1100 range
collapsed to fog-factor ~1 (no fog everywhere).
Fix (L4VIDEO.cpp world pass): auto-detect D3DPRASTERCAPS_WFOG -> per-pixel W-fog
(smooth, == the arcade dpl_fog_type_pixel_lin); else VERTEX fog (eye-space but
per-vertex -> splotchy on coarse terrain tris, so only a fallback). FOGENABLE + the
fog mode are re-asserted each world-pass frame (the old one-time set got clobbered
per-pass, which is why plain table fog showed nothing). Verified on this GPU:
WFOG present -> per-pixel table fog, user-confirmed smooth.
Also corrected task #20's false "shipped maps define no fog" comment (it checked
.MAP/.RES, not BTDPL.INI). Env hooks: BT_FOGMODE=table|vertex|off, BT_WEATHER=
clear|fog|soup, BT_FOG="near far r g b" (now honored on the authored branch too);
[fog] resolved/model diagnostics. KB: rendering.md fog section added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User: struck a mech ~4x, still standing -- but the [dmghit] trace showed the
DIRECT victim taking 16 applications for 8 impacts while the intended splash
bystander took 0. Root cause: BTApplySplashDamage delivered splash through the
shooter's SubsystemMessageManager::AddDamageMessage, which CONSOLIDATES every
damage message of a frame onto the FIRST hit entity (commonDamageInformation.
entityHit, messmgr.cpp:279). The direct hit registered the primary as the common
entity, so the bystander's splash was consolidated onto the primary too -- the
excluded direct victim took 8 direct + 8 mis-routed splash = 2x, and the real
bystander took nothing.
Fix: deliver splash with a DIRECT e->Dispatch to each victim, matching the T0
source (EXPLODE.cpp:246 target_entity->Dispatch); Dispatch reroutes cross-pod for
a replicant on its own. Verified (BT_DMG_LOG): 8 impacts -> 8 dmghit on the
direct victim + 8 on the bystander (was 16/0); no death; per-hit 5.83.
KB: combat-damage.md warns not to route splash through the consolidating msgmgr.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User live regression: missiles killed a mech in ~2 shots ("takes way more than 2
normally"). Root cause -- DamageZone::TakeDamage (arcade @0041e4e0 == WinTesla
DAMAGE.cpp:379) is `damageLevel += amount*scale` and IGNORES burstCount. So the
arcade's ONE cluster Missile per trigger lands its damageAmount EXACTLY ONCE; the
missileCount/burstCount split is cosmetic for zone damage (only gyro-bounce +
splash-falloff read burstCount). The port re-expresses that one cluster as N
flying rounds and was damaging on EVERY round = ~missileCount x too lethal on the
direct hit (mirrors the splash over-application fixed in 145a69f).
Fix (mislanch.cpp FireWeapon): only the salvo-LEAD round (i==0) carries damage
(damageData.damageAmount) AND the cluster splash; the other N-1 rounds are VISUAL
(damage 0) -- the tracer ripple, no extra damage. AC unaffected (one round/shot,
not a cluster).
Verified (BT_DMG_LOG [dmghit] trace): per-hit missile damage = clean 5.83, burst
1, once. Missiles-ONLY (BT_AF_MISSILE now independent of BT_AUTOFIRE), single
enemy, 45s: 8 damaging hits, top zone reaches only 0.18, NO death (was death in
~3 pulls) -- "way more than 2" restored. Earlier test deaths were BT_AUTOFIRE
firing laser+PPC+missiles combined, not missiles alone.
KB: combat-damage.md documents burstCount-ignored + the N-round cluster trap
(direct + splash both once-per-salvo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live regression: a clustered bystander died in ~2 missile salvos ("suddenly
lethal"). Root cause, not authentic: the arcade fires ONE cluster Missile per
trigger (burstCount=missileCount) doing ONE SplashDamage event with baseBurst =
missileCount, floored at 1 ONCE. The port re-expresses that cluster as N flying
BTProjectile rounds, and task #62 fired splash PER ROUND -- each baseBurst=1,
each floored at 1 -- so the distance floor was applied N times = ~missileCount x
too much splash.
Fix: BTProjectile.splashBurst tags ONLY the salvo-lead round. MissileLauncher::
FireWeapon passes nmiss (the cluster count) on i==0 and 0 on every other round;
the contact + world-impact splash hooks fire only when splashBurst>0, using it as
baseBurst. One splash event per salvo with baseBurst=missileCount -- matches the
single arcade cluster missile. Replicant mirror rounds carry 0 (damage 0 too) so
the master's cross-pod splash isn't doubled. AC unaffected (not a MissileLauncher;
splash_burst defaults 0).
Verified headless (BT_SPAWN_ENEMY=2 clustered rig, 45s): bystander now takes 6
splash events (1/salvo, baseBurst=6) and SURVIVES (was ~2 shots); primary dies to
direct hits in ~3 trigger pulls (36 missiles == single-enemy TTK); AC does not
splash; no crash. Also: BT_SPAWN_ENEMY read as a COUNT (=2 clusters a bystander
within SplashRadius) for eyeballing splash; entity-id logging on [enemy]/[splash].
KB: combat-damage.md documents the N-round cluster trap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Area-of-effect blast on missile detonation, from the T0 source EXPLODE.cpp:50-254
(@0042fad0) + the arcade decomp. Fires on ANY missile impact (world or mech) --
the +0x360 gate sits in the Missile::Perform collision branch (part_013.c:10097).
Only missiles splash; the AC's tracer is not a Missile.
Radius source CORRECTED: it is the ROUND's own GameModel (type-0xf) +0x50, seeded
from the launcher's linked AmmoBin ammoModelFile @0x1e8 (part_013.c:8778) -- NOT
the launcher's ExplosionModelFile (which resolved to 0). Live-resolved radius = 30
for both MP missile launchers; 0 for AC/Emitters.
Burst falloff = baseBurst / dist^exp floored at 1 (arcade 1.25 = decomp 0x3ff40000;
WinTesla EXPLODE.cpp:209 drifted to 1.2f). damageType/amount pass through unchanged;
only burstCount, radial damageForce, and impactPoint are set. Excludes shooter +
direct victim; dist>radius gated; delivered via msgmgr (cross-pod) or Dispatch.
New: BTResolveSplashRadius / BTApplySplashDamage (mech4.cpp), hooked at both the
world-impact and contact detonation paths; BTAmmoRoundModelResource bridge
(ammobin). Env: BT_SPLASH_LOG, BT_SPLASH_TEST (synthetic near-miss), BT_AF_MISSILE
(missile autofire). Verified: near-miss dist=15 -> 1 burst to a bystander; live
missiles detonate + exclude the direct victim; AC does not splash; no crash.
Deferred (T3): per-player enable sub-gate missile+0x360 = BTPlayer+0x264 (writers
read as a per-frame toggle, not a config flag) -- port treats SplashRadius>0 as the
enable. KB: combat-damage.md + open-questions.md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The autocannon gains its authentic muzzle effect AND becomes visible when an
enemy fires it -- the ballistic-FX gap behind "I only see the AC from my own
view."
MUZZLE FLASH (the genuine shipped effect, not the cut card):
- MUZFLASH.BGF is an orphaned/cut asset (nothing references it) -- so it is NOT
rendered. The shipped projectile-gun muzzle effect is DAFC.PFX, which
BTDPL.INI documents as "the effect used on all projectile guns" (psfx 6
external / 14 internal): an orange fire-smoke blast (btfx:firesmoke1),
maxIssue 25 over ~0.2s so the emitter auto-expires = one burst per shot.
- BTFlashMuzzle (mech4.cpp) spawns it on the gun-port SEGMENT via the existing
BTStartPfxAttached path; the segment frame sprays -Z out the barrel. Hooked
at ProjectileWeapon::FireWeapon (the fire edge). Default ON (BT_MUZZLE=0
disables). AC only -- lasers show their beam, missiles their launch.
ENEMY AC FIRE NOW REPLICATES (the real find):
- ROOT CAUSE: the subsystem-record replication channel EXISTS and works (mech
ticks subsystem->PerformAndWatch(update_stream); Entity::UpdateMessageHandler
routes incoming records to GetSimulation(subsystemID-1)->ReadUpdateRecord).
The emitter (beam) and MissileLauncher (salvo mirror) both call ForceUpdate()
so their fire serializes -> enemy lasers + missiles ARE visible on the peer.
The AUTOCANNON set only `simulationFlags |= 0x1` (the +0x28 instance flag,
NOT the updateModel bit WriteSimulationUpdate walks) and had NO fire record,
so its shot never crossed the wire -- the enemy's cannon was invisible.
- FIX (the AC twin of the missile salvo mirror): ProjectileWeapon::
WriteUpdateRecord/ReadUpdateRecord (fire counter + aim) + ForceUpdate() in
FireWeapon. The replicant edge-detects the counter and mirrors ONE visual
round + DAFC muzzle flash from its own resolved muzzle; a null/untargeted aim
streaks straight out the barrel (launchVelocity) instead of toward origin.
- Verified live 2-node: the watching node logs REPLICANT AC shots + DAFC
flashes at the enemy's gun-port (seg 7), aimed shots fly to the real target;
no crashes.
TRACER: all ACs author TracerInterval=1 (every round is a tracer); the existing
amber streak is acceptable-authentic, so no tracer change was needed.
KB: open-questions.md -- CORRECTED the (wrong) earlier note that claimed no
subsystem-record channel exists; it does, the AC just wasn't using it. Logged
the methodology lesson: the coverage audit finds UNWRITTEN functions, not
"reconstructed but inert" ones (a function present but never called -- the AC
record + the missile mirror both looked done); a LIVENESS audit would catch
that class.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full 5-path audit of the damage economy vs the decomp (5-finder +
adversarial-verify workflow), resolving the KB self-contradiction the
binary-coverage audit flagged.
CONFIRMED AUTHENTIC as-is (no change needed):
- Energy beam (emitter.cpp): damagePortion = authored DamageAmount x
(charge/seekV)^2; the ctor x1e7 and fire x1e-7 cancel (_DAT_004bafbc
dumped from the exe = x87 80-bit 1e-7 exactly).
- Autocannon (projweap.cpp): full authored DamageAmount from resource
+0x19C delivered unmodified; the 0.0625 at :667 is the shooter's own
gyro recoil, not the round.
- Zone-armor BASE model: damageLevel += amount x damageScale[type]
(engine DAMAGE.cpp:379, called mechdmg.cpp:427), legs x0.5, 1.0=dead.
3 STAND-INS FIXED (all byte-verified against the decomp):
- A. mechdmg.cpp:451 -- read the phantom `stance` member (no binary
offset, zero writers -> perma-0), so the leg-shot-out -> fall/death
branch was DEAD. Now MovementMode() (mech+0x40, @part_012.c:6910).
- B. mechdmg.cpp:458 -- guarded the leg partial-failure graphic on the
always-0 IsAirborne() stub where the binary calls IsDisabled()
(@0049fb54 = movementMode 2||9). On a wreck the binary SUPPRESSES the
write; the stub let it corrupt graphicAlarm 9->4/3 -- the task-#52
wreck-graphic bug, now fixed AT SOURCE (was only masked by the
IsMechDestroyed latch).
- C. mech4.cpp:1551 -- flat kShotDamage=12 fed as the kill-score
damageAmount (the KB self-contradiction: task #8 claimed it retired,
but it was live). The score handler @0x4c02e4 derives the whole kill
award from it, so every kill scored identically regardless of weapon.
Now lastInflictingDamage -- the real killing-blow magnitude, latched
in TakeDamageMessageHandler (mech.cpp:624), mirroring the per-hit path
(mech4.cpp:1207). The phantom `int stance` slot is reused for the new
Scalar member (size-neutral, no layout shift); init 0 in the ctor.
DEFERRED (task #60-D, documented): the missile CLUSTER model -- the port
fires N flying rounds (net armor total authentic) vs the binary's ONE
missile with a random burstCount cluster roll (loses cluster variance +
single-zone concentration). Blocked on an OPEN decomp semantic (does
burstCount multiply armor or only the gyro kick? settle at FUN_004bef78
-> FUN_004be078 -> EXPLODE.cpp:209-210).
VERIFIED live: clean build; 2-node fight -> clean center-mass kill (no
crash, kills 0->1); [zone-armor] dump confirms per-zone armor 50-140 +
legs x0.5. NB the displayed POINTS score still reads 0 -- a SEPARATE
open gap (scoreAward + role/team/tonnage multipliers unwired); fix C
corrected the damage INPUT to that formula.
KB swept: open-questions.md (self-contradiction resolved + task #60
summary + deferred missile item), combat-damage.md (damageScale is
type-indexed not even/odd; task-#52 source fix; kill-score section),
RECONCILE.md (missile = ONE spawn not N), stale comments in mechweap.cpp
(SendDamageMessage is LIVE), mislanch.hpp, mechdmg.cpp (FUN_0049fb54 =
IsDisabled).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MadCat (and every leg-mech) leans -8deg into a walk / -11deg into a run --
an authored jointhip (hingex) pose in the EXTERIOR gait clips that the walk/run
CYCLE clips never rebind, so it HOLDS. The 1995 game keeps a separate INTERIOR
('i'-suffix) clip set for the mech you PILOT: those shake jointshakey (cockpit
rattle) and OMIT jointhip, so your own view stays level while everyone else sees
you lean. The port had this entirely dead -- the local cockpit mech pitched
-8deg into the ground (user-reported "staring at the ground").
ROOT CAUSE (two stacked bugs):
- The authentic ctor clip-set gate (@part_012.c:10308-10320) was reconstructed
as no-op stubs: LoadLowDetailBody/LoadHighDetailBody MISLABELED the
FUN_004a80d4/86c8 GAIT-CLIP loader addresses as a "body LOD" pair, so the gate
did nothing and a separate unconditional LoadLocomotionClips always loaded
EXTERIOR clips.
- LoadLocomotionClipsExt (the interior loader) was a stub aliased to the
exterior loader.
FIX:
- Reconstructed the real 4-char INTERIOR loader (byte-exact vs @004a86c8; the
'i'-suffix table dumped from the exe @0x10d74d: swri/wwri/wwli/...; all 18
base clips confirmed present in BTL4.RES for mad/blh/ava).
- Fixed the ctor gate: getenv("L4VIEWEXT") || (instanceFlags&0xC)==4 -> exterior
(replicant / forced external view); else -> interior (local cockpit master).
Removed the mislabeling no-op stubs.
- PORT ADAPTATION (MaintainViewClipSet, per-frame in PerformAndWatch): the port
never sets the replicant COPY bit (all MP mechs build as local masters --
heat-sim/scoring/torso-watcher-connect all run on both, by design), so the
ctor gate lands everyone on interior. Pick the set by VIEWPOINT instead: the
mech you pilot keeps interior (level cockpit), every other mech flips once to
exterior (the lean). Reloads only on a viewpoint-status change; the sets
share stride data so the swap is seamless. Model pointers stashed at ctor.
VERIFIED live (BT_HIP_LOG probe): your walking mech writes jointhip=0 (level);
the peer's replica in the other pod's view leans at exactly -8.0/-11.1deg --
the authored clip values. A/B control: L4VIEWEXT=1 forces the walking master
back to 74 sustained -8/-11 writes.
KB: locomotion.md ("NO walk lean" audit conclusion CORRECTED -- the lean is
authored + sustained + clip-set-specific), asset-formats.md (.ANI parses
pitch/yaw/roll for all joint types; KeyJointPos translation; cycle clips carry
no pitch), open-questions.md (item closed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MadCat torso twists, the view turns with it, and targeting follows.
Three reconstruction fronts closed:
THE ELECTRICAL WATCHDOG CHAIN (why the torso never powered up):
- PowerWatcher::UpdateWatch reconstructed (@004b181c, the REAL registered
Performance -- PTR @0050f5fc; Ghidra missed the fn start): the watchdog
MIRRORS the watched subsystem's electrical level (+0x278), brownout
downgrade when gen output <= minVoltage% x rated. @004b1804 relabeled
ResetToInitialState (slot 10) -- the old "Simulation" tag was wrong.
- The factory watcher-CONNECT pass reconstructed (vtable slot +0x38,
@004aee2c/@004b1a40 byte-identical, recovered from raw exe bytes):
watchedLink.Add(roster[watchedSubsystem]) on the master node. Was the
SubProxy::Start() no-op -- every watchdog sat at 0 forever.
- MinVoltageScale = 0.01 (a 10-byte x87 literal @0x4b1924; was 1.0f =
permanent brownout) and PowerWatcher's Derivation chains its REAL base
HeatWatcher (the HeatableSubsystem stand-in broke IsDerivedFrom for the
whole Torso/Searchlight/ThermalSight family).
- KB correction swept: derivation tag 0x50e604 = HEATWATCHER (not
"HeatSink"); the btl4gaug heat-widget gate now tests it via the
BTIsHeatWatcher bridge.
THE CROSSHAIR (task #58 forensics, 6-agent workflow + live probes):
- The VIEW is TORSO-MOUNTED: jointtorso -> jointeye -> siteeyepoint in
every twist-capable .SKL; the camera + canopy ride the same hinge
subtree through HingeRenderable's live matrix-stack compose -- ALREADY
WORKING in the port. The crosshair stays screen-centered (center IS
the boresight); the twist reads on the tape carets/compass/radar.
- The real bug was the port's gBTAimX = tan(twist) slew (the falsified
"body-mounted view" model): the camera already carried the twist, so
the crosshair counter-slid to hull-forward and the fire ray with it.
Deleted; the pick ray inherits the twist from the yawing eye basis.
- Two instrumentation traps documented (chase-eye-as-default-camera,
BT_FORCE_TORSO clobbering real joints -> the hook now only fills
unresolved ones); an over-correcting explicit eye compose was added on
those false readings and retired the same day.
CONTROLS + REPLICATION:
- Q/E spring-center on release (the axis is a twist-RATE demand; the old
hold-deflection model drifted forever); X also zeroes the axis and
pulses the authentic torso Recenter (@004b6918). M cycles control
mode via the real CycleControlMode body.
- Torso update-record DIRECTION fixed: engine truth is Write=serialize /
Read=apply; @004b6a78 is the READ (was mislabeled Write) and the
missing WRITE @004b6a1c recovered from raw disasm (recordLength 0x1C,
twist/vel/rate at +0x10/14/18) -- kills the replicant's 0xCDCDCDCD
-140-degree ghost twist.
- Marching-ghost desync: 4 Standing-case guards zero stale reverse
cycleSpeed (negative cadence passed the <= ZeroSpeed stop gate).
- Kill credit rerouted to the OBSERVED killer (lastInflictingID ->
killer's player link) -- kills count, target K/D populates.
KB: subsystems.md (watcher chain), multiplayer.md (record direction),
combat-damage.md + gauges-hud.md + cockpit-view.md (torso-mounted view
re-correction), decomp-reference.md (new addresses + tag fix),
open-questions.md (dead capability-roster loops 2-4, snapshot CD read).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>