Commit Graph
234 Commits
Author SHA1 Message Date
arcattackandClaude Opus 4.8 f094d78df1 MP: restore peer turn-step on the body channel -- arm turn state 4 from replicated turn (task #50)
The body-channel swap (96a896a) fixed the stop-slide but regressed turn-in-place
to a rotating statue: the body Standing case only enters walk/reverse/stand, never
the turn clip (state 4).  On the master that arming comes from the LEG Standing
case cross-arming both channels (mech2.cpp:937) -- which the peer no longer runs.

FIX (peer branch): arm/exit the body turn state from the replicated turn
(replMppr->turnDemand, already derived from the replicated yaw rate with
hysteresis): standing + turning -> SetBodyAnimation(4) so the body case 4 advances
the trn clip (peer STEPS through the pivot); turn stops -> back to Standing.
Walk/reverse transitions still own their own exits.

Verified autonomous (forced spin): peer body channel holds state 4, trn clip
advances (frmAvg 0.2-0.3, frmMax 1-2), back-steps 0, no crash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:38:33 -05:00
arcattackandClaude Opus 4.8 96a896ae43 MP: peer poses from the BODY channel following replicated state -- the authentic single-channel design (task #50)
Answers 'is this different from the original?' -- NO, it RESTORES it.  Decomp
(workflows wh1h5gnmc/w1s9ou02o): the 1995 game had ONE live skeletal channel,
the BODY channel FUN_004a5678 via IntegrateMotion; the LEG channel FUN_004a5028
is DEAD CODE (zero call sites, SetLegAnimation never runs).  Both master and peer
posed the whole skeleton from the body channel, the PEER FOLLOWING the master's
replicated body-anim state (bodyStateAlarm@0x728, set by the type-3 reader's
SetBodyAnimation, mech.cpp:1913) + replicated bodyTargetSpeed@0x6b4.

Our port RESURRECTED the dead leg SM as the peer's poser (mech4.cpp AdvanceLeg-
Animation) fed a LOCALLY re-derived commanded speed.  The leg SM's phase-
independent pre-switch wind-down (mech2.cpp:560-568: force-jump {6,7,8,9}->stand
when legCycleSpeed<=0) made the peer SKIP the master's decel state 8 and snap to
stand while the body coasted -- the 'slid forward after legs stopped' slide, the
reverse 'slippy sliding in place', and the whole re-derivation desync class.

FIX (peer branch, gated BT_PEER_LEGCH=1 to revert): pose+travel from
AdvanceBodyAnimation(dt, mj=1) -- the body channel, which follows the replicated
state and has NO early wind-down, playing the master's exact clips (walk/decel-8/
reverse/stand).  ZERO new netcode -- the state is already on the wire and already
decoded into the body channel; we were simply posing from the wrong (resurrected,
dead) channel.  Single-player untouched (replicant-gated).

Verified autonomous (through-zero sweep): no crash; body channel advances
(frmAvg 0.3-0.75, states 5/6/7/10 following master); slide-in-stand events
519 -> 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:50:43 -05:00
arcattackandClaude Opus 4.8 3792a04661 Fix throttle-detent Abs() macro bug + KB analog-throttle + slide diagnostics (task #50)
Uncommitted work from the speed-model + peer-motion investigation:
 - btl4mppr.cpp: the L4MechControlsMapper full-throttle detent used the
   unparenthesized Abs() macro (STYLE.H:118) on an expression -- Abs(throttlePos
   - 1.0f) mis-expands to -(throttlePos + 1.0f), always <= 0.05, so the detent
   snapped throttle to full EVERY frame.  Diff into a temp first (same class as
   the 7615ecd angular-resync Abs fix).
 - context/pod-hardware.md: document the decomp-verified analog-continuous pod
   throttle path (RIO Ranger 0-800 counts, 0.05 deadband, no notching; '5 speeds'
   is false) from workflow w0odszxro.
 - mech4.cpp: BT_SLIDE/[mslide] per-frame slide diagnostics (peer position moving
   while legs in stand; master decel profile) -- used to prove the stop-slide is a
   peer leg-SM-winds-down-early desync, not master momentum.  Env-gated.

The 'pretty good' coupled-motion gameplay state is already shipped (a9ab3db).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:13:56 -05:00
arcattackandClaude Opus 4.8 a9ab3db952 MP: FIX peer gliding-stops -- snap the residual offset when legs are at rest (task #50)
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>
2026-07-14 21:51:14 -05:00
arcattackandClaude Opus 4.8 38fed81d8a MP: tighten peer re-anchor -- error-proportional absorption (residual snap, task #50)
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>
2026-07-14 21:41:30 -05:00
arcattackandClaude Opus 4.8 47771ba256 KB: authentic coupled peer motion DONE -- the single-source gait pipeline (task #50)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:30:16 -05:00
arcattackandClaude Opus 4.8 b0137427a6 MP: PROMOTE coupled peer motion to DEFAULT -- user + metrics confirm (task #50)
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>
2026-07-14 21:29:48 -05:00
arcattackandClaude Opus 4.8 c52a1ad79a MP: COUPLED peer motion -- authentic gait-driven position + master gait send-mirror (task #50, env-gated)
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>
2026-07-14 21:22:02 -05:00
arcattackandClaude Opus 4.8 ea39af1ff6 KB: peer coupled-motion architecture -- completion plan + session landings (task #50)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:00:25 -05:00
arcattackandClaude Opus 4.8 702822f9c5 MP: root-motion peer-position EXPERIMENT (env-gated BT_ROOT_POS; default unchanged) + KB notes (task #50)
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>
2026-07-14 19:59:22 -05:00
arcattackandClaude Opus 4.8 2c6db6a2de Diag: speed-change investigation -- authentic analog throttle CONFIRMED, harnesses + gait probes (task #50)
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>
2026-07-14 19:47:03 -05:00
arcattackandClaude Opus 4.8 93456be051 MP: FIX keyboard-driving peer skip -- non-pose records no longer kick the dead-reckon clock (task #50)
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>
2026-07-14 19:12:52 -05:00
arcattackandClaude Opus 4.8 d78e77bf84 MP: FIX walk+turn peer skip -- incremental heading integration + scalar yaw mirror (task #50)
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>
2026-07-14 18:52:15 -05:00
arcattackandClaude Opus 4.8 b7be95b584 Diag: BT_TRNRATE probe -- turn-in-place body-rate vs trn-clip cadence (task #50)
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>
2026-07-14 18:26:55 -05:00
arcattackandClaude Opus 4.8 a8eb8a427f MP: FIX peer spin hang/divergence -- exact quaternion integration + frequent orientation (task #50)
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>
2026-07-14 17:05:16 -05:00
arcattackandClaude Opus 4.8 9eff043973 Diag: render-rate rotation-evenness probe + angular-drift instrumentation (task #50 spin residual)
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>
2026-07-14 16:27:04 -05:00
arcattackandClaude Opus 4.8 7615ecd316 MP: FIX peer spin freeze/half-rate -- unparenthesized Abs() macro floods angular resync (task #50)
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>
2026-07-14 16:04:32 -05:00
arcattackandClaude Opus 4.8 6374efc26a MP: FIX peer gait jitter -- feed the leg channel the REPLICATED commanded speed (task #50)
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>
2026-07-14 14:08:22 -05:00
arcattackandClaude Opus 4.8 d5d512e087 WIP checkpoint: TCP_NODELAY + dense-send + BT_JIT/BT_ANIM probes + FOGDAY test egg (task #50 investigation)
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>
2026-07-14 13:43:35 -05:00
arcattackandClaude Opus 4.8 94647dd378 MP: FIX phantom walk-in-place -- master sends a pose record on came-to-rest (task #50)
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>
2026-07-14 11:30:18 -05:00
arcattackandClaude Opus 4.8 2aba89f962 MP: FIX replicant walk-entry floor -- gate on standSpeed not walkStrideLength (task #50)
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>
2026-07-14 11:07:45 -05:00
arcattack 936c34c997 MP: FIX replicant 'slides before it steps' -- walk-entry floor on derived demand
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
2026-07-14 10:50:39 -05:00
arcattackandClaude Fable 5 a3b735d5da MP: replicant motion chop ROOT-CAUSED + FIXED -- stale peer-mirror spammed re-base
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>
2026-07-14 10:08:47 -05:00
arcattackandClaude Fable 5 e6ad29d648 KB: log the console-death replication-freeze open question (task pending recurrence)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 09:31:21 -05:00
arcattackandClaude Fable 5 8674bca2d8 btconsole: survive socket errors + flush output (the mid-session sync-freeze lead)
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>
2026-07-14 09:30:47 -05:00
arcattackandClaude Fable 5 02ce55194e Gait: FIX replicant statue + jerky walk -- replicant-tuned trn entry (regression)
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>
2026-07-14 09:19:12 -05:00
arcattackandClaude Fable 5 992aedece8 Gait: FIX replicant trn regression -- gate master-perf turn-stop exit to masters
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>
2026-07-14 09:11:22 -05:00
arcattackandClaude Fable 5 530bbeb959 Fix BT_SPAWN_AT terrain embed + add BT_COOL_LOG probe
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>
2026-07-14 08:46:00 -05:00
arcattackandClaude Fable 5 b533725ae4 Radar: FIX heading flop every 180deg -- YawPitchRoll not EulerAngles (user-reported)
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>
2026-07-14 08:09:16 -05:00
arcattackandClaude Fable 5 c4c51545fc Diag: BT_TEMPBAR_LOG -- verify the subsystem temperature-bar cluster renders live
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>
2026-07-14 07:38:47 -05:00
arcattackandClaude Fable 5 da80727467 KB: autocannon jam/coolant -- SOLVED from decomp, QA behavior CONFIRMED faithful
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>
2026-07-14 07:27:01 -05:00
arcattackandClaude Fable 5 fcaea1862a Diag: BT_AF_PERIOD=<sec> autofire cadence throttle + afc_dump.py (raw RES verify)
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>
2026-07-14 07:26:12 -05:00
arcattackandClaude Fable 5 4e72f0c4f4 Diag: BT_VALVE_BOOST=<condenserNumber> (route coolant priority to one condenser)
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>
2026-07-14 07:11:15 -05:00
arcattackandClaude Fable 5 21abe2212e KB: autocannon jam/coolant-priority -- mechanism is FAITHFUL + COMPLETE (decomp hunt)
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>
2026-07-14 06:56:34 -05:00
arcattackandClaude Fable 5 3acab416ee Diag: BT_JAM_LOG (per-shot weapon temp/jam roll) + BT_VALVE (force condenser detent)
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>
2026-07-14 06:34:37 -05:00
arcattackandClaude Fable 5 844edbd2aa KB: FX per-zone chain LIVENESS-VERIFIED (BT_CRIT_PROBE solo)
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>
2026-07-14 06:12:12 -05:00
arcattackandClaude Fable 5 a198e5fb4a KB: fix my botched FX entry -- the impact/destruction FX chain IS reconstructed
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>
2026-07-14 06:06:45 -05:00
arcattackandClaude Fable 5 31d338b6ff KB: correct stale StartEntityEffectImplementation entry (dispatcher DONE 2026-07-13)
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>
2026-07-14 06:01:53 -05:00
arcattackandClaude Fable 5 8f7b304069 KB: mark authentic per-mech turn-rate DONE (task #64b)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 05:56:48 -05:00
arcattackandClaude Fable 5 a83995a755 Locomotion: wire the AUTHENTIC per-mech turn-rate lerp (task #64b)
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>
2026-07-14 05:48:51 -05:00
arcattackandClaude Fable 5 35c30bda67 Locomotion: replace task-#64 trn stand-ins with the AUTHENTIC dispatcher (disasm-decoded)
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>
2026-07-14 00:31:56 -05:00
arcattackandClaude Opus 4.8 bfdd41bb9d Gait: turn-then-walk stutter ROOT-CAUSED + FIXED -- one skeleton writer (task #64)
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>
2026-07-13 23:52:35 -05:00
arcattackandClaude Opus 4.8 c0a0ec5e69 KB: document the searchlight fog-swap latent bug (task #63, left as-is by decision)
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>
2026-07-13 22:04:29 -05:00
arcattackandClaude Opus 4.8 225fa5951b Render: fog searchlight-swap verified INERT + BT_FOG_LOG diag (task #63)
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>
2026-07-13 21:36:48 -05:00
arcattackandClaude Opus 4.8 083b148bb0 Render: FOG restored -- authentic per-map/time/weather fog now renders (task #63)
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>
2026-07-13 21:24:06 -05:00
arcattackandClaude Opus 4.8 a9c0d2c854 Combat: FIX splash mis-routed onto the direct victim via the msgmgr (task #62)
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>
2026-07-13 20:10:51 -05:00
arcattackandClaude Opus 4.8 fa0b825b60 Combat: FIX missile direct damage 6x over-application -- damage once per salvo (task #62)
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>
2026-07-13 20:01:44 -05:00
arcattackandClaude Opus 4.8 145a69f865 Combat: FIX missile splash over-application -- once per salvo, not per round (task #62)
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>
2026-07-13 19:43:50 -05:00
arcattackandClaude Opus 4.8 12fbc023a8 Combat: MISSILE SPLASH DAMAGE -- Explosion::SplashDamage reconstructed (task #62)
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>
2026-07-13 19:06:24 -05:00
arcattackandClaude Fable 5 4c54f7ef0c Ballistic FX: DAFC muzzle flash + autocannon fire replication (task #61)
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>
2026-07-13 17:23:59 -05:00