Compare commits

...
Author SHA1 Message Date
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
arcattackandClaude Fable 5 267059ab88 Damage economy RECONCILED: 3 stand-ins fixed, 3 paths confirmed authentic (task #60)
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>
2026-07-13 15:38:04 -05:00
arcattackandClaude Fable 5 2e9c78d604 Gait: the AUTHENTIC walk lean -- interior/exterior clip sets, level cockpit + leaning mechs (task #59)
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>
2026-07-13 14:46:22 -05:00
arcattackandClaude Fable 5 02cdfd6576 Torso: the TWIST goes LIVE -- electrical watchdog chain, centered crosshair, coherent controls (task #57/#58)
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>
2026-07-13 13:27:49 -05:00
54 changed files with 4214 additions and 372 deletions
+6 -1
View File
@@ -157,7 +157,12 @@ add_library(munga_engine STATIC
"engine/MUNGA/TERRAIN.cpp"
"engine/MUNGA/TESTALL.cpp"
"engine/MUNGA/TIME.cpp"
"engine/MUNGA/TIMESTUB.cpp"
# TIMESTUB.cpp REMOVED (2026-07-14): its GetRTC/GetHiRes were 2007 call-counter
# stubs (`return time++` -- the "clock" advanced per CALL, not per ms) that WON
# the /FORCE duplicate-symbol race over the REAL QueryPerformanceCounter clock
# in L4TIME.cpp (LNK4006 "second definition ignored"). Every Now()-domain
# consumer (dead-reckoning above all) ran on call-count pseudo-time -> the
# replicant stall/jump chop. L4TIME.cpp covers every symbol TIMESTUB defined.
"engine/MUNGA/TOOL.cpp"
"engine/MUNGA/TRACE.cpp"
"engine/MUNGA/TRACSTUB.cpp"
+244
View File
@@ -0,0 +1,244 @@
[mission]
adventure=BattleTech
map=arena1
scenario=freeforall
time=day
weather=fog
temperature=27
length=600
[ordinals]
bitmap=Ordinal::BitMap::1
bitmap=Ordinal::BitMap::2
bitmap=Ordinal::BitMap::3
bitmap=Ordinal::BitMap::4
[Ordinal::BitMap::1]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=000038000003C000001FF00000000F00
bitmap=0000F8000003C000003FF80000000F00
bitmap=0001F8000003C00000707C0000000F00
bitmap=0001F8000003C00000603C0000000F00
bitmap=00007801FC0FF00000003C3DF807FF00
bitmap=00007803FE0FF00000003C3FFC0FFF00
bitmap=00007803C703C00000003C3E3C1F0F00
bitmap=000078078303C0000000783C1E1E0F00
bitmap=000078078003C0000000783C1E1E0F00
bitmap=00007807C003C0000000F03C1E1E0F00
bitmap=00007807F003C0000001E03C1E1E0F00
bitmap=00007803FC03C0000003C03C1E1E0F00
bitmap=00007801FE03C0000007803C1E1E0F00
bitmap=000078007F03C000000F003C1E1E0F00
bitmap=000078001F03C000001E003C1E1E0F00
bitmap=000078000F03C000003C003C1E1E0F00
bitmap=000078060F03C0000078003C1E1E0F00
bitmap=000078071E03E0000078003C1E1F1F00
bitmap=00007803FE01F000007FFC3C1E0FFF00
bitmap=00007801FC00F000007FFC3C1E07EF00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[Ordinal::BitMap::2]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=001FFF0000003C0000003C03C0780000
bitmap=001FFF0000003C0000007C03C0780000
bitmap=00000E0000003C000000FC03C0780000
bitmap=00003C0000003C000001BC03C0780000
bitmap=0000700F3E1FFC000003BC0FF07BF000
bitmap=0001E00F7E3FFC0000073C0FF07FF800
bitmap=0003800FFE7C3C0000063C03C07C7800
bitmap=0007F80FFE783C00000C3C03C0783C00
bitmap=0007FE0F80783C0000183C03C0783C00
bitmap=00001E0F00783C0000383C03C0783C00
bitmap=00000F0F00783C0000703C03C0783C00
bitmap=00000F0F00783C00007FFF03C0783C00
bitmap=00000F0F00783C00007FFF03C0783C00
bitmap=00000F0F00783C0000003C03C0783C00
bitmap=00000F0F00783C0000003C03C0783C00
bitmap=00000F0F00783C0000003C03C0783C00
bitmap=00180F0F00783C0000003C03C0783C00
bitmap=001C1F0F007C7C0000003C03E0783C00
bitmap=000FFE0F003FFC0000003C01F0783C00
bitmap=0007FC0F001FBC0000003C00F0783C00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[Ordinal::BitMap::3]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=001FFF03C07800000000FC03C0780000
bitmap=001FFF03C07800000003FC03C0780000
bitmap=001E0003C07800000007C003C0780000
bitmap=001E0003C0780000000F0003C0780000
bitmap=001E000FF07BF000000F000FF07BF000
bitmap=001E000FF07FF800001E000FF07FF800
bitmap=001E0003C07C7800001E0003C07C7800
bitmap=001FFC03C0783C00001EFC03C0783C00
bitmap=001FFE03C0783C00001FFE03C0783C00
bitmap=00001F03C0783C00001F1F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00180F03C0783C00001E0F03C0783C00
bitmap=001C1F03E0783C00001F1F03E0783C00
bitmap=000FFE01F0783C00000FFE01F0783C00
bitmap=0007FC00F0783C000007FC00F0783C00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[Ordinal::BitMap::4]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=001FFF03C07800000007FC03C0780000
bitmap=001FFF03C0780000000FFE03C0780000
bitmap=00000F03C0780000001F1F03C0780000
bitmap=00000F03C0780000001E0F03C0780000
bitmap=00000F0FF07BF000001E0F0FF07BF000
bitmap=00001F0FF07FF800001E0F0FF07FF800
bitmap=00001E03C07C7800001E0F03C07C7800
bitmap=00003E03C0783C00001E0F03C0783C00
bitmap=00003C03C0783C00000F1E03C0783C00
bitmap=00003C03C0783C000007FC03C0783C00
bitmap=00007803C0783C000007FC03C0783C00
bitmap=00007803C0783C00000F1E03C0783C00
bitmap=00007803C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003E0783C00000F1E03E0783C00
bitmap=0000F001F0783C00000FFE01F0783C00
bitmap=0000F000F0783C000007FC00F0783C00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[pilots]
pilot=127.0.0.1:1502
pilot=127.0.0.1:1602
[127.0.0.1:1502]
hostType=0
advancedDamage=1
loadzones=1
name=Aeolus
bitmapindex=1
experience=expert
badge=VGL
patch=Yellow
role=Role::Default
dropzone=one
vehicle=bhk1
vehicleValue=1000
color=White
[127.0.0.1:1602]
hostType=0
advancedDamage=1
loadzones=1
name=Boreas
bitmapindex=2
experience=expert
badge=VGL
patch=Yellow
role=Role::Default
dropzone=one
vehicle=ava1
vehicleValue=1000
color=Red
[largebitmap]
bitmap=BitMap::Large::Aeolus
[BitMap::Large::Aeolus]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=000003FE0000000001E0000000000000
bitmap=000007FF0000000001E0000000000000
bitmap=00000F8F8000000001E0000000000000
bitmap=00000F078000000001E0000000000000
bitmap=00000F0781FF00FF81E0F0781FC00000
bitmap=00000F0783FF81FFC1E0F0783FE00000
bitmap=00000F0787C7C3E3E1E0F0783C700000
bitmap=00000F078783C3C1E1E0F07878300000
bitmap=00000F078783C3C1E1E0F07878000000
bitmap=00000FFF8783C3C1E1E0F0787C000000
bitmap=00000FFF8783C3C1E1E0F0787F000000
bitmap=00000F0787FFC3C1E1E0F0783FC00000
bitmap=00000F0787FFC3C1E1E0F0781FE00000
bitmap=00000F07878003C1E1E0F07807F00000
bitmap=00000F07878003C1E1E0F07801F00000
bitmap=00000F07878003C1E1E0F07800F00000
bitmap=00000F078780C3C1E1E0F07860F00000
bitmap=00000F0787C1C3E3E1E078F871E00000
bitmap=00000F0783FF81FFC1E07FF83FE00000
bitmap=00000F0781FF00FF81E03F781FC00000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[smallbitmap]
bitmap=BitMap::Small::Aeolus
[BitMap::Small::Aeolus]
bitmap=00000000000000000000000000000000
bitmap=0000000000000000003E00000C000000
bitmap=006300000C00000000631F0F8CC63C00
bitmap=00633198CCC66200007F3198CCC66000
bitmap=00633F98CCC6780000633018CCC61E00
bitmap=00633018CCC6060000633098CCC64600
bitmap=00631F0F8C7E3C000000000000000000
bitmap=00000000000000000000000000000000
x=64
y=16
width=4
[Role::Default]
model=dfltrole
[Role::NoReturn]
model=noretun
+10 -3
View File
@@ -20,9 +20,16 @@ spec is `docs/ASSET_PIPELINE.md`. Full list: `docs/PROGRESS_LOG.md §5`.
Object=part.bgf, dzone=, tranx/y/z, pitch/yaw/roll`. Build a **DCS tree**; neutral pose ≈
translations (pyr≈0). [T1]
- **`.ANI`** — INI keyframe animation. `[HEADER] framecount/framerate(30)/skeletonfile`; `[frameN]`
joint→`rx ry rz` radians (balltranslate→translate); `[RootTranslation]` per-keyframe `x y z`
where **`.z` = forward root SPEED** (units/s). Walk anims have no baked root translation — forward
motion is animation-driven from `.z` ([[locomotion]]). [T1]
joint→**`pitch yaw roll` radians for EVERY joint type** (ANIMTOOL.cpp:140-142 parses the triple as
EulerAngles unconditionally — the old "balltranslate→translate" note was wrong [T0]); a
balltranslate joint ADDITIONALLY gets a per-frame translation from the separate **`[KeyJointPos]`**
section (ANIMTOOL.cpp:478-483 packs EulerAngles+Vector3D; JMOVER/seqctl apply SetRotation +
SetTranslation). For the root `jointlocal`, KeyJointPos.y = the gait vertical bob;
`[RootTranslation]` per-keyframe `x y z` where **`.z` = forward root SPEED** (units/s). Walk anims
have no baked root translation — forward motion is animation-driven from `.z` ([[locomotion]]).
Walk/run CYCLE clips author NO body pitch — root keys are a ≤1.5° roll sway only; the authored
LEAN lives in the transition (`rwl/rwr/wrl/wrr` jointhip ~7-12°) + `bmp` stagger (~25°) clips
(2026-07-13 sweep of all 331 source ANIs). [T1]
## Materials / textures
- **`.BMF`** — `DIV-BIZ2` (FILETYPE=1) material+texture LIBRARY (no pixels). Material → DIFFUSE color
+14 -1
View File
@@ -61,7 +61,20 @@ inverse(eyeWorld)`. No LookAt anywhere.
- offset matrix = the segment's **GetBaseOffset()** (segment+0x74, the LOCAL rest transform) —
NOT GetSegmentToEntity, NOT an upright basis;
- parent = the eyepoint segment's **parent draw component** (`dcs_array[GetParentIndex()]`) —
liveness (torso pose, gait, gyro) reaches the eye ONLY through this parent chain;
liveness reaches the eye through this parent chain in the PORT TOO (task #58 FINAL): the
twist path is `Torso::PushTwist → Joint::SetRotation → hinge rotationAmount →
HingeRenderable::Execute` (multiplies the live hinge into the matrix stack), and the eye
executes inside that scope (`jointtorso → jointeye → siteeyepoint`; VERIFIED: with the real
jointtorso swept, the active cockpit `[eyefwd]` swings ~60° and the torso hinge renderable
sits in the eye's parent chain). The CAMERA yaws with the twist, the canopy rides the same
subtree, the crosshair stays screen-centered — see [[combat-damage]] Targeting.
⚠ TWO instrumentation traps produced false "eye is frozen" readings first: (1) the headless
default camera is the CHASE eye (root-parented, same DPLEyeRenderable class — gate probes on
the COCKPIT eye being live); (2) `BT_FORCE_TORSO` used to override the joint names with the
BLH demo pair (`jointshakey2`/`jointtshadow`), silently redirecting a MadCat sweep into the
SHADOW hinge — the hook now only fills joints the resource left unresolved. An explicit
eye-side twist compose added on those false readings DOUBLE-ROTATED the view (2× vs the 1×
canopy) and is retired;
- the `EyepointRotation` entity attribute (EulerAngles; Mech member, init 0).
- **Combine order:** `FUN_0040b104(dst,A,B)` computes `dst = B × A` (translation from B) →
the eye local = `baseOffset × R`, R applied FIRST in eye-local space. Recomputed only when the
+131 -26
View File
@@ -32,29 +32,45 @@ player input). [T1]
`mech+0x38c` = targeted sub-zone (1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint,
refreshed each frame. [T1]
**The AUTHENTIC acquisition model (recovered 2026-07-08; corrected 2026-07-08, task #39):** THERE
IS NO FREE-AIM MOUSE CURSOR. The pod had a stick whose yaw drove the **torso twist**
(`MechControlsMapper` writes `HUD::SetFreeAimSlew(stick_x)` → cockpit+0x28C, gated on the torso's
horizontal-twist being enabled — `mechmppr.cpp:735/762`, hud.hpp:167 [T1]); the crosshair marks
where the **torso guns boresight** relative to the body-mounted view. You aim by **steering the
mech / twisting the torso**, not by moving a cursor. The engine `Reticle` struct
(`engine/MUNGA/RETICLE.h`) carries `reticlePosition`, `pickPointingOn`, `rayIntersection`,
`targetEntity`, `targetDamageZone` — a general struct shared with Red Planet — but in BT
`reticlePosition`/HotBoxVector (@mech-HUD+0x1FC) is **COMPUTED by HudSimulation** from the mech
pose quaternion (`FUN_0040954c` quat→euler of mech+0x10C) + the target geometry, and **zeroed to
centre when there is no target** (part_013.c:5680, `FUN_00408440(...,&DAT_004e0f74)` [T1]) — it is
NOT a free-floating cursor. The pick then hits whatever the guns point at; that entity + its
DAMAGE ZONE become the target (`0x37c` rayIntersection, `0x388` targetEntity, `0x38c`
targetDamageZone → aimed-zone damage). Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0`)
boresight dead-ahead — the crosshair sits centred and you point the whole mech. Once locked,
`Emitter::FireWeapon` converges with NO aim/arc test (part_013.c:7758). `MechWeapon::
UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange = dist < (1 hostZoneDamage) ×
weaponRange`. The 0x388 WRITER (the per-frame reticle→mech copy) is in an un-exported decomp gap.
**Port status: RECONSTRUCTED + runtime-verified (tasks #36/#39, 2026-07-08) [T2].** The chain:
the crosshair = **torso boresight** (`BTTwistToReticleX(torsoTwist)`, L4VIDEO — tan(twist)
projected through the live per-axis projection; **0 = dead-centre on the fixed-torso BLH**;
`BT_AIM="x y"` pins it for headless tests) → `BTGetAimRay` (the ACTIVE eye publishes pos+LookAtRH
basis via `BTSetAimCamera`, the render loop publishes proj._11/_22 + backbuffer dims) →
**The AUTHENTIC acquisition model (recovered 2026-07-08; task #39; RE-CORRECTED 2026-07-13, task
#58 crosshair-twist forensics):** THERE IS NO FREE-AIM MOUSE CURSOR — and **the VIEW is
TORSO-MOUNTED, not body-mounted**. The cockpit eye hangs off the torso yaw hinge in every
twist-capable skeleton (`jointtorso` (hinge-Y) → `jointeye``siteeyepoint`; all 14 twist-enabled
Torso records name `TorsoHorizontalJoint=jointtorso`; `Torso::UpdateJoints` writes currentTwist
into that joint each frame; VIEW = inverse of the LIVE joint chain, `FUN_004c22c4`
part_013.c:11742, no de-twist anywhere [T1]). So when the torso twists, **the CAMERA yaws with
the guns and the crosshair stays SCREEN-CENTERED** — truthfully, because screen center always IS
the boresight; the world rotates past it. The twist reads on INSTRUMENTS: the bottom 21-tick tape
carets, the compass (= body yaw + twist, part_013.c:5674-5676), the radar wedge
(part_013.c:11836-11859) [T1]. The old "crosshair = torso boresight offset in a body-mounted
view" model was falsified — it was never discriminated before because every prior live test used
the fixed-torso BLH, where both models center the crosshair.
The engine `Reticle` struct (`engine/MUNGA/RETICLE.h`) carries `reticlePosition` (a variable
screen position 1..+1 [T0]) — the reticle CAN move, but its writer is in an un-exported decomp
gap (`s_TargetReticle` has exactly ONE xref binary-wide: the read-side lookup part_014.c:5132);
the only coherent twist-era mover is the **fixed-torso free-aim channel**: `MechControlsMapper`
writes `HUD::SetFreeAimSlew(stick_x)` → cockpit+0x28C **only when torso twist is disabled**
(part_013.c:339-345/381-387 [T1]); HudSimulation integrates it (negated, part_013.c:5717) into
+0x294 clamped ±(+0x29C) → `mech+0x36c` every frame (:5735) — no exported consumer; the
mech+0x36c → reticlePosition link is [T4]. NB: an earlier version of this section conflated
`reticlePosition` with **HotBoxVector** (HUD+0x1FC — the TARGET designator HudSimulation zeroes
with no target, part_013.c:5680); they are different things.
The pick hits whatever the guns point at; that entity + its DAMAGE ZONE become the target
(`0x37c` rayIntersection, `0x388` targetEntity, `0x38c` targetDamageZone → aimed-zone damage).
Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0` — no jointtorso in the skeleton at all)
boresight dead-ahead. Once locked, `Emitter::FireWeapon` converges with NO aim/arc test
(part_013.c:7758). `MechWeapon::UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange =
dist < (1 hostZoneDamage) × weaponRange`. The 0x388 WRITER is in the same un-exported gap.
**Port status: RECONSTRUCTED + runtime-verified (tasks #36/#39/#58) [T2].** The chain: the
cockpit eye inherits the twist AUTHENTICALLY through the draw traversal (`Torso::PushTwist`
hinge `rotationAmount``HingeRenderable::Execute` multiplies the live hinge into the matrix
stack; the eye executes under `jointtorso → jointeye`; verified `[eyefwd]` sweeps with the real
jointtorso — NO explicit eye-side compose exists, one briefly added on false "frozen eye"
probe readings double-rotated the view and is retired, see [[cockpit-view]]); the crosshair sits
at screen center (`gBTAimX = 0` — the old `BTTwistToReticleX` slew was THE bug: the camera
already yawed, so the crosshair counter-slid to HULL-forward, "twisting leaves the crosshairs
behind"; `BT_AIM="x y"` still pins it for headless tests) → `BTGetAimRay` (the ACTIVE eye
publishes pos+basis via `BTSetAimCamera`, leveled in pitch, the twist yaw riding in the basis) →
`Mech::PickRayHit` (world ray → local frame → `BoundingBox::HitBy` slab test vs the collision
template's ExtentBox → world hit point).
@@ -106,9 +122,16 @@ offsets). [T2]
## The RAM economy — CLOSED (2026-07-12) [T1 evidence, T3 normalization]
Three-layer story, measured live + decomp-verified:
1. **The armor economy is POINTS**: every zone streams `damageScale[5]` EVEN = `1/armorPoints`
(armor 50140; `damageLevel += amount/armor`, 1.0 = destroyed — the `[zone-armor]` dump,
BT_DMG_LOG, mechdmg.cpp). Weapons author point-scale amounts (laser 11.77 ≈ 10 torso hits). ✓
1. **The armor economy is POINTS**: every zone streams `damageScale[5]` — 5 cells indexed by
`damageType` DIRECTLY (Collision/Ballistic/Explosive/Laser/Energy; task #60 correction — NOT an
"even/odd slot" parity), each destructive type's cell ≈ `1/armorPoints` (armor 50140).
`damageLevel += amount × damageScale[type]`, 1.0 = destroyed — engine `DAMAGE.cpp:379` (arcade
`@0041e4e0`), called from `mechdmg.cpp:427`; the `[zone-armor]` + per-hit `[dmghit]` dumps,
BT_DMG_LOG. Weapons author point-scale amounts (laser 11.77 ≈ 10 torso hits). **`burstCount` is
NOT in this formula — zone damage IGNORES it** [T1]; one Damage message = one `amount×scale`
application regardless of burstCount (burstCount is read only by the gyro-bounce math + the
SplashDamage falloff). A cluster missile therefore lands its damageAmount ONCE — see the
SALVO-LEAD FIX below for why the port's N-round missiles must damage once per salvo. ✓
2. **StaticBounce prices rams with the AUTHORED moverMass ≈ 1.3e6 units** → ~600×v² ≈ 59,000
points for a 10 m/s ram (measured: 59221@9.94, 398@0.81; `[collide-tx]` now logs mass/e).
The binary DISPATCHES it raw (@part_012:15324-15358) — but in pod MP it landed on the local
@@ -402,6 +425,88 @@ is the death `movementMode`: `IsDestroyed == (movementMode 2 || 9)` (`FUN_0049fb
the death modes never revert. Fix: `IsMechDestroyed()` = `movementMode 2||9` (latch) **OR**
`graphicAlarm >= 9` (the vital-kill TRIGGER that first enters the transition). Lesson: **a gauge/status
alarm is never a state latch** — later writers rewrite it; latch on the state machine's own mode.
**ROOT-CAUSE now fixed AT SOURCE (task #60):** the illegitimate wreck rewrite to 4/3 came from
`mechdmg.cpp:458` gating the leg partial-failure graphic on the always-0 `IsAirborne()` stub instead
of `IsDisabled()` (`@0049fb54` = movementMode 2||9). On a wreck the binary SUPPRESSES that write; the
stub let it fire and corrupt `graphicAlarm`. Now `mech->IsDisabled()` — the latch above remains as
defence-in-depth. Sibling fix: `mechdmg.cpp:451` read the phantom `stance` (perma-0) instead of
`MovementMode()`, so the leg-shot-out → `graphicAlarm=9` fall/death branch was entirely DEAD; now live.
## Kill-score damage (task #60)
`BTPostKillScore` (btplayer.cpp:1491) feeds the ScoreMessage `damageAmount` into the kill award
(`@0x4c02e4` → `(damageAmount + scoreAward) × roleScalar × teamMult × tonnageRatio`, `@0x4c052c`).
The port passed a flat `kShotDamage=12` (mech4.cpp:1551), so every kill scored identically regardless
of the finishing weapon. Now it passes `lastInflictingDamage` — the real killing-blow magnitude
latched in `TakeDamageMessageHandler` (mech.cpp:624), mirroring the working per-hit score path
(mech4.cpp:1207). `scoreAward=0` (the authentic kill BONUS) stays an open bring-up gap — it lives in
the un-exported master-perf writer (0x4a9770-0x4ab188 [T4]); the exact authentic `damageAmount` that
writer emitted (msg+0x24) is likewise unrecovered, so the killing-blow magnitude is the faithful
stand-in until it is. [T1 mechanism / T4 exact authentic value]
## Missile splash damage — `Explosion::SplashDamage` (task #62, 2026-07-13) [T1/T2]
Area-of-effect blast on **missile** detonation — NOT the vestigial whole-mech `DeathSplashDamage`
above (mech+0x520, never read). The T0 source is `engine/MUNGA/EXPLODE.cpp:50-254` (`@0042fad0`),
called ONLY from the Missile's per-frame `Perform` (`@004bef78`, part_013.c:10097) inside the
collision branch — so it fires on **any** missile impact (mover OR world geometry), gated on
`Missile+0x360`. The AC's tracer is not a Missile and never splashes.
**The model [T0, `EXPLODE.cpp`]:** build a Y-axis cylinder of `SplashRadius` at the blast, collect
every `Mover` in it (+ static world), then for each target that has `damageZones` and is not the
direct victim (`entityHit`), the detonator (`this`), or `excluded`:
- `burstCount = baseBurst / pow(dist, 1.2f)`, `Min_Clamp(…, 1)` — dist = `|target.localOrigin
blastCenter|` (center-to-center). The **arcade** exponent is **1.25** (decomp `0x3ff40000` at
part_004.c:865; the decompiler hid the x87 FDIV + the `FUN_004dcd94`=ROUND that WinTesla writes as
truncation). Point-blank AMPLIFIES (dist<1 → >baseBurst); no distance floor (real victims are never
at dist~0 — the one AT the center is the excluded direct victim).
- `damageType`/`damageAmount` pass through UNCHANGED; only `burstCount`, `damageForce` (radial), and
`impactPoint` (= blastCenter+offset) are set. Delivered via `TakeDamageMessage(inflicting, zone=-1)`.
**The SplashRadius source [T1]:** it is the ROUND's GameModel resource `+0x50`, NOT the launcher's
`ExplosionModelFile`. The launcher seeds each spawned Missile's model from its linked **AmmoBin**
`ammoModelFile @0x1e8` (part_013.c:8778); the Missile ctor `SearchList(that_id, GameModelResourceType)`
→ `+0x50` → `Missile+0x364` (part_013.c:10184). The MissileThruster GameModel parser
`FUN_004bf8ec` writes `"SplashRadius"` to `+0x50` of the type-`0xf`/size-`0x54` record. Live-resolved
radius for both MP missile launchers = **30** (`BT_ROSTER` dump).
**Port impl (`mech4.cpp`):** `BTResolveSplashRadius` gates on `MissileLauncher::ClassDerivations`,
resolves launcher → `BTWeaponAmmoBin` → `BTAmmoRoundModelResource` (+0x1e8) → GameModel +0x50.
`BTApplySplashDamage` walks `BTGetTargetCandidates` (excludes shooter), skips the direct victim +
destroyed/zoneless mechs, applies the `dist>radius` gate + the burst falloff, and delivers via a
DIRECT `e->Dispatch` to each victim (T0 `EXPLODE.cpp:246`). Hooked at BOTH detonation paths
(world-impact + contact). **⚠ MUST NOT route splash through the shooter's `SubsystemMessageManager`:
`AddDamageMessage` CONSOLIDATES every damage message of a frame onto the FIRST hit entity
(`commonDamageInformation.entityHit`, messmgr.cpp:279) — so splash for the bystander landed on the
DIRECT victim instead (2026-07-13 bug: the direct victim took 8 direct + 8 mis-routed splash = 16
applications, the bystander 0; a direct `Dispatch` splits them correctly 8/8).** Dispatch reroutes
cross-pod for a replicant victim on its own.
**⚠ THE SALVO-LEAD FIX (task #62 bug, found 2026-07-13 by live regression) — the N-round trap.**
**KEY FACT [T1]: `DamageZone::TakeDamage` (arcade `@0041e4e0` == WinTesla `DAMAGE.cpp:379`) is
`damageLevel += damageAmount * damageScale[type]` and IGNORES `burstCount`.** So ONE arcade cluster
Missile per trigger (damageAmount = authored/missileCount, burstCount = missileCount) applies its hit
EXACTLY ONCE to a zone — `burstCount` is cosmetic for zone damage (only the gyro-bounce math
`gyro.cpp:834` and the SplashDamage falloff `damage.burstCount/dist^exp` read it). The port
re-expresses that ONE cluster as N flying `BTProjectile` rounds (visual tracers), and task #62
damaged + splashed on EVERY round → **~`missileCount`× too lethal on BOTH the direct hit and the
splash** (user-reported "missiles kill in 2 shots" — a mech that should take many salvos). Fix
(`mislanch.cpp` FireWeapon): only the salvo-LEAD round (`i==0`) carries damage (`damageData.
damageAmount`) AND the cluster splash (`splashBurst = nmiss`); the other N-1 rounds are VISUAL
(damage 0, `splashBurst 0`) — the ripple of tracers. `BTProjectile.splashBurst` gates the two splash
hooks; the direct-hit block already skips `p.damage<=0`. Replicant mirror rounds carry 0 (master
delivers cross-pod — no double). **Lesson:** a cluster weapon re-expressed as N rounds must apply ALL
zone effects (direct damage AND splash) ONCE per salvo, not once per round — the arcade's single
Missile is one damage event, `burstCount` notwithstanding.
**Verified [T2]:** per-hit missile damage = clean 5.83, burst 1, ONCE (`[dmghit]` trace); radius 30
for missiles / 0 for AC+Emitters. Missiles-ONLY, single enemy, 45s: 8 damaging hits, top zone reaches
only 0.18, **NO death** (was death in ~3 pulls pre-fix) — "way more than 2 shots" restored. AC (one
round/shot, not a cluster) unaffected. Env `BT_SPLASH_LOG`, `BT_DMG_LOG` (per-hit `[dmghit]` +
zone-armor), `BT_AF_MISSILE` (missile-only autofire, independent of BT_AUTOFIRE), `BT_SPAWN_ENEMY=N`
(N clustered dummies). ⚠ `BT_AUTOFIRE` fires ALL weapon groups (laser+PPC+AC) — use `BT_AF_MISSILE`
alone to isolate missile TTK.
**Deferred [T3]:** the per-player enable sub-gate `Missile+0x360 = BTPlayer+0x264` (part_013.c:8757) —
its decomp writers (4668/10512) read as a per-frame state toggle, not a clean config flag, and the
port's `showDamageInflicted` label is itself a guess; port treats authored `SplashRadius>0` as the
enable. See [[open-questions]].
## Key Relationships
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
+13 -1
View File
@@ -83,12 +83,24 @@ Identify a class at runtime with `sub->GetClassID()` (== `sub+4`). [T1]
| **0x511830** | **MechWeapon**::ClassDerivations | The reticle AddWeapon loop (part_014.c:5386) hard-aborts on missing WeaponRange/PipPosition/… attrs for every member; part_012.c:9344 counts ready weapons; the Mech ctor roster at this[0x1ef] (`weaponRoster`, ex-mislabel "poweredSubsystems") ORs weapon `capabilityFlags`@+0x334. [T1] |
| 0x50f4bc | PoweredSubsystem | btl4gau2 gauge wave. [T1] |
| 0x50e3ec | HeatableSubsystem | btl4gaug widget gate. [T1] |
| 0x50e604 | HeatSink | btl4gaug widget gate. [T1] |
| 0x50e604 | **HeatWatcher** (task #57 CORRECTION — was "HeatSink") | `HeatWatcher::TestInstance` @4aec38 tests its OWN class vs this tag; the factory watcher-CONNECT loop (slot +0x38) selects on it; the btl4gaug widget gate ORs it after HeatableSubsystem (a HeatSink test there would be redundant — HeatSink : HeatableSubsystem). 0x50e590 = the HeatSinkBank (0xBBE) tag. [T1] |
| 0x51155c | roster at Mech this[0x1eb] (`heatableSubsystems` label) | tag ≠ 0x50e3ec — the label is UNVERIFIED [T4]; arbitrate before relying on it. |
| 0x50e4fc | roster at Mech this[499] (`damageableSubsystems` label) | [T3] |
| 0x50bdb4 | Mech | emitter.cpp destroyed-check. [T1] |
| 0x5121a8 | ProjectileWeapon-family (checked after 0x511830; reads ammo state @+0x364) | [T3] |
### Watcher electrical chain (task #57 — all [T1], several from RAW-BYTE recovery)
| Address | What |
|---|---|
| @004aec54 | `HeatWatcher::CreateStreamedSubsystem` — parses `WatchedSubsystem=<name>` → segment index **+2** at resource+0xE4 |
| @004aee2c / @004b1a40 | vtable slot 14 (+0x38) CONNECT bodies (HeatWatcher / PowerWatcher+Torso, byte-identical): master-gated `watchedLink(+0x114).Add(owner->roster[watchedSubsystem(+0x128)])`. **Ghidra missed both starts** — recovered from raw exe bytes (vtables.tsv rows have GAPS at skipped slots; dump vtable+slot*4 bytes when a slot looks absent) |
| @004b181c | `PowerWatcher::UpdateWatch` — THE registered Performance (PTR @0050f5fc); watchdogAlarm(+0x184) MIRRORS watched subsystem's electrical level (+0x278); brownout→1 when `gen outputVoltage(+0x1DC) <= minVoltage(+0x180) × ratedVoltage(+0x1D8)`. Torso sims call it first-line |
| @004b1804 | `PowerWatcher::ResetToInitialState` (slot 10) — old recon MISLABELED it "Simulation" |
| 0x4b1924 | `MinVoltageScale` — a **10-byte x87 literal** (`0a d7 a3 70 3d 0a d7 a3 f8 3f` = **0.01**); the port had 1.0f → permanent brownout |
| @004b6a78 | `Torso::ReadUpdateRecord` (slot 6; base 41bd34) — REPLICANT apply of twist extras +0x10/14/18. Old recon mislabeled it Write |
| @004b6a1c | `Torso::WriteUpdateRecord` (slot 7; base 41c500) — MASTER serialize; recordLength=0x1C; **Ghidra missed the start** |
| 41bd34 / 41c500 | base Subsystem Read (apply) / Write (produce) update-record fns — engine [T0]: **Write=produce, Read=apply**; don't flip |
---
## 3. Mech struct offsets (`Mech*`, binary layout)
+10 -3
View File
@@ -146,7 +146,8 @@ queue. `SeekVoltageGraph`'s 4 Seek* attrs are a cluster-child (not config-called
view only, via the recovered **dpl2d** 2D display-list API (recorders @0x487f34-0x488630; opcode
map + coordinate model in `phases/phase-02-dpl2d-reticle.md`; port: `game/reconstructed/dpl2d.cpp`).
Geometry is the ctor's hardcoded calibration (originX 0.35, originY 0.25, scaleY 0.5, right range
ladder 0..1200 m, bottom heading tape, center cross + dot; tick ladders via FUN_004cd938). The
ladder 0..1200 m, bottom TORSO-TWIST tape (NOT a heading tape — stale wording swept task #58; see
the tape entry below), center cross + dot; tick ladders via FUN_004cd938). The
range caret binds to the live target range (`BTSetHudTargetRange`, fed by mech4's targeting step).
**Weapon pips:** the build loop (part_014.c:5386) registers EVERY subsystem
`IsDerivedFrom(0x511830 = MechWeapon)` — lasers, PPCs AND missile launchers (BLH = 7 pips) — via
@@ -196,8 +197,14 @@ and every instrument is now live [T2]:**
around the hotbox point, +1/11.5 vertical (K=2.8145 baked projection; the port uses the live
per-axis projection) — switching to the left/right edge ARROW past ±1.6 or behind.
- Reticle state Off/On + `PrimaryHudOn` (mask 0x20) picks full HUD vs the "simple X" list;
the aim group translates by `reticlePosition` = the TORSO BORESIGHT (SetMatrix; NOT a free
cursor — see [[combat-damage]] Targeting: the stick twists the torso, no mouse-aim exists).
the aim group SetMatrix-translates by `Reticle::reticlePosition` (screen 1..+1 [T0]) every
frame — but **NOT by torso twist** (task #58 CORRECTION): the VIEW is torso-mounted (the eye
hangs off jointtorso → jointeye → siteeyepoint), so the crosshair stays SCREEN-CENTERED through
a twist — screen center IS the boresight; the twist reads on the bottom tape/compass/radar
wedge instead. The reticlePosition writer is un-exported (one xref binary-wide: the read-side
lookup part_014.c:5132); its coherent use is the FIXED-torso free-aim channel (mech+0x36c
[T4]). The old "translates to the torso boresight" wording here was the falsified
body-mounted-view model — see [[combat-damage]] Targeting for the full re-correction.
This recovery also CONFIRMS the HUD attr-table ids 4/5/6/8/0xA/0xB/0xC/0xD name↔use pairings
(hud.hpp had flagged them uncertain). Deferred: PNAME1-8.bgf 3D marker chain. (The canopy shell
is now authentic and shows by default — see [[cockpit-view]]; `BT_HIDE_COCKPIT=1` hides it.)
+124 -10
View File
@@ -22,6 +22,64 @@ engine `AnimationInstance::Animate(dt, move_joints)` integrates `movement += Δt
moves the body that far → feet plant by construction (no stride constant; k=1). Idle anims have
z=0. The `.MOD` physics governed thrust VEHICLES, NOT walk gait. [T1]
## The walk lean IS authored — and INTERIOR clips omit it (2026-07-13, task #59 CORRECTION) [T1/T2]
**A walking mech DOES lean forward — it's authored, sustained, and clip-set-specific.** (This
supersedes the earlier "NO walk lean" audit conclusion, which was right that no CODE writes body
pitch and that the CYCLE clips carry no pitch keys, but wrong about the net behavior: the
TRANSITION clips ramp `jointhip` (hingex, parent of `jointtorso` → the whole upper body + cockpit)
to a steady pose that the cycle clips never rebind, so it HOLDS.) Decoded from BTL4.RES + live
`[hip]` probe [T2]:
- `<pfx>swr` ramps `jointhip` 0 → **8°** (stand→walk), `<pfx>wrl/wrr` 8 → **11.1°** (walk→run),
`<pfx>wsl/wsr` 8 → 0 (walk→stand). The dedicated return clip only exists because the lean
persists. The BLH has NO `jointhip` channel in any clip → it stays level (both views).
- **Two clip sets per mech.** The ctor gate (@part_012.c:10308-10320) binds the **INTERIOR** set
(4-char `'i'` suffix, `LoadLocomotionClipsExt` @004a86c8) to the **local cockpit master** — those
clips animate `jointshakey` (cockpit shake, a BALL joint) and OMIT `jointhip`, so the PILOT's own
view stays level. The **EXTERIOR** set (3-char, `LoadLocomotionClips` @004a80d4) goes to network
REPLICANTS and the forced `L4VIEWEXT` external view — it carries the `jointhip` lean, so everyone
ELSE sees the mech lean into its stride.
- **Port fix (task #59):** the interior loader was a stub aliased to the exterior loader, and the
authentic ctor gate was a no-op (`LoadLowDetailBody`/`LoadHighDetailBody` mislabeled the
FUN_004a80d4/86c8 clip-loader addresses as a body-LOD pair) — so the local cockpit mech played
exterior clips and pitched 8°. Now `LoadLocomotionClipsExt` is the real 4-char interior loader
and the ctor gate selects it (`getenv("L4VIEWEXT") || (instanceFlags&0xC)==4` → exterior; else
interior). Verified live: default (interior) → 4 stray `jointhip` writes over a continuous walk;
`L4VIEWEXT=1` (exterior) → 74 sustained writes at 8/11°.
- **Copy-flag → VIEWPOINT adaptation (the other half, DONE):** the port never sets the replicant
COPY bit (`instanceFlags & 0xC == 4`) — all MP mechs build as local masters (replication rides
the registry + update-records; heat-sim/scoring/torso-watcher-connect all run on both by design,
so flipping the bit would break them). The ctor gate therefore lands EVERY mech on interior. To
restore the authentic RESULT, `Mech::MaintainViewClipSet()` (mech3.cpp, called each frame from
`PerformAndWatch`) picks the set by VIEWPOINT: the mech you pilot (== `GetViewpointEntity()`)
keeps INTERIOR (level cockpit — you never see your own mech externally in the real pod); every
other mech flips ONCE to EXTERIOR (the 8/11° lean). Reloads only on a viewpoint-status change;
the two sets share stride data so the swap is seamless. Verified live: your mech `jointhip`=0
while walking; the peer's replica leans at 8°/11.1° in your view. (Model pointers stashed at
ctor via `BTStashClipState`; the GameModel resource persists for the mission.)
Other pitch sources (unchanged, still correct): `bmp` stagger (≈25° — THE knockdown pitch); JAK's
four directional fall clips (`JAKFB/FF/FL/FR`, root pitch to 1.79 rad — the only mech with them);
`IntegrateMotion`@0x4ab1c8 rotates orientation by `angularVelocity`@0x2d4·dt (yaw only); ground
snap is Y-only (terrain pitch/roll → `jointshadow`). The gyro's `bodyOrientation` spring
(@004b30ec) writes only `jointeye` (cockpit hit-bounce, not a lean). `fallDirection`@0x4a8/
`fallScalar`@0x4b4 are record-payload only. [T1]
## The MARCHING-GHOST desync — ROOT-CAUSED + FIXED (2026-07-13, live diagnosis) [T2]
The long-parked "replicant walks in place while its master stands still" reproduced live with
telemetry running: the master's `[gaitSM]` showed `state=0 cycleSpeed=-2.50725 kfCur=19` — BOTH
channels stuck at a REVERSE cadence inside Standing. Mechanism: the walk-family stop gate
(`cycleSpeed <= ZeroSpeed`, mech2.cpp) passes trivially for NEGATIVE (reverse) cadences, and
several stand-entry paths (turn exit, terminal poses) never touch the cycle — so a reverse-stop
enters Standing at full reverse cycle speed. The master looks still (case-0 never advances the
clip; `kfCur` freezes) but the stale cycle REPLICATES, and the peer's replicant animates it =
the marching ghost. A clean FORWARD stop decays the cycle first (`legSum ~3e-8` on the healthy
node) — which is why the bug was intermittent and direction-dependent. FIX: all four
Advance*Animation Standing cases zero their channel's cycle on entry + ForceUpdate(8) so the
peer hears the stop immediately. (The signed-demand change (task #15) is what let negative
cycles reach machinery written for forward decel.) The earlier "bob reduction + stutter"
symptom family did NOT share this root — it was the body channel's mj=1 joint writes leaking
through on channel phase-splits, closed by v6 (task #64, see the two-channel section).
## The two-channel gait (real controls)
The BT `SequenceController` (`seqctl.cpp`, from `SelectSequence@004277a8`+`Advance@0042790c`) drives
locomotion. Under `BT_REAL_CONTROLS` two channels run — **roles corrected 2026-07-09 (task #49) from
@@ -36,20 +94,76 @@ raw disasm** (both Advance fns had NO static decomp caller; found by byte-scanni
locally INVISIBLE in the binary. The literal projectedVelocity store is unwired, but the
update-record velocity feed is live via the localVelocity/worldLinearVelocity maintenance
(mech4.cpp VELOCITY STORAGE block) — replicant gait (task #50) runs off it. [T1]
**v5 (current): display == travel BY CONSTRUCTION through the LEG** (body advances first, leg last).
History: v3 (travel=leg, display=body) foot-slipped — two channels, different consumers; v4 unified
BOTH on the body (mis-reading 0x4ab430 as the travel source) — feet planted, but whenever the two
state machines phase-split, the out-of-phase LEG pose showed through on frames the body didn't write
(Standing/wind-down) = the RECURRING "legs stutter / lose sync" report. The split mechanism [T2,
reproduced via `BT_FORCE_OSC` + `BT_FORCE_TURN` + `BT_SYNC_LOG`]: (a) SEED — the bring-up trn trigger
arms only the leg (authentic trn dispatcher still unlocated), so the walk re-entry lands 1 frame
apart; (b) AMPLIFIER — with offset clips the end-of-clip callbacks fire on different frames, so a
demand change landing between them picks DIFFERENT next states (one winds down, one keeps walking)
→ opposite-phase churn. Under v5 both effects still exist but only skew the projection channel. [T2]
**v6 (current, task #64 2026-07-13): the body channel does NOT write joints — `AdvanceBodyAnimation(dt, mj=0)`.**
Display == travel == the LEG channel STRUCTURALLY (one skeleton writer), matching the binary's own
observable ("the body channel's phase drift is locally INVISIBLE in the binary"). History: v3
(travel=leg, display=body) foot-slipped; v4 unified BOTH on the body — the out-of-phase LEG pose
showed through on frames the body didn't write = the recurring stutter; **v5 (body-first/leg-last,
believing the leg's overwrite hid body drift) was WRONG the same way MIRRORED** — user-verified
2026-07-13: whenever the channels phase-split, the BODY's out-of-phase mj=1 writes leaked into the
displayed skeleton (rhythmic leg skips + visibly REDUCED BOB) while every leg-channel trace read
clean — the leg DATA was fine; the RENDERED pose wasn't the leg's. The turn-then-walk repro (below)
made it 100% reproducible. `BT_BODY_MJ=1` restores the double-writer for A/B. The old split
mechanism [T2]: (a) SEED — the trn trigger armed only the leg → walk re-entry frames apart;
(b) AMPLIFIER — offset clips → end-of-clip callbacks on different frames → divergent transitions.
Both are ALSO mitigated at the source now (the lockstep trn arming below), but mj=0 is what makes any
residual split invisible, structurally. ⚠ The "bob reduction + stutter" symptom family (previously
parked as "likely shares the reverse-stop root") is THIS — CLOSED. [T2 user-verified]
The gait STATE MACHINE (`AdvanceBodyAnimation`, mech2.cpp) slews `bodyCycleSpeed` toward the demand
through walk/run caps (`LoadLocomotionClips`, mech3.cpp:326 — measures `walkStride`/`standSpeed` via
`legAnimation.SelectSequence`). Transition callbacks (`Mech::BodyClipFinished @0x4a6d8c`,
`LegClipFinished @0x4a6928` — PE-parsed jump tables) alternate walk 6↔7 / run 12↔13 + walk→run. [T2]
## Turn-in-place (trn, state 4) — AUTHENTIC dispatcher decoded (task #64, 2026-07-14)
**The entry dispatcher is NOT in the Ghidra decomp** — it lives in the master-perf gap
`0x4a9b5c0x4ab188` (functions_index.tsv jumps FUN_004a9770→FUN_004ab188; the SOLE reader of
`turnDemand`@mapper+0x12c and `turnCapable`@0x588) [T1 proof of absence]. **Ghidra never lifted it, so
it was disassembled with objdump** (`scratchpad/masterperf.asm`, 1530 lines) and hand-decoded — this
REPLACES the earlier [T3] stand-in guesses (`0.25·standSpeed` entry gate + `×4` fast-forward) with the
real logic. `animationClips[4]` = the `trn`/`trni` clip (loaders 13767/13971). Port recon (mech2.cpp):
- **THE POSE-MATCH INVARIANT:** this engine has NO pose blending — `SelectSequence` hard-resets to
frame 0 — and avoids pops purely by AUTHORING (every transition clip starts in its predecessor's end
pose; a turn-in-place CYCLE returns to the neutral/stand pose at its last frame). The trn→walk seam
therefore steps `trn legFrm7 (cycle end) → swr legFrm1` — a small ONE-TIME bob step that is authentic
(the binary cuts on speed, not at an arbitrary frame). **What made it STUTTER was NOT this cut** — it
was the body channel leaking out-of-phase joints into the rendered skeleton every walk cycle (mj=1);
fixed at the source by `AdvanceBodyAnimation(dt, mj=0)` (see two-channel section). With mj=0 the
authentic cut renders cleanly.
- **Turn rate (disasm 0x4aa3d30x4aa4ff) [T1] — WIRED (task #64b):** `spd = |linearSpeed|`; base
`turnRate = walkingTurnRate`(mech+0x574, rad/s). If `spd ≥ reverseSpeedMax`(0x538): `t = (spd
walkStride 0x534)/(topSpeed=reverseStrideLength 0x34c walkStride)`; `turnRate = t≤1 ?
lerp(walkingTurnRate, runningTurnRate 0x578, t) : runningTurnRate/t²` (over-run falloff). `turnRate =
max(0, turnRate)`. Then yaw `= turnDemand(mapper+0x12c) × turnRate`, ZEROED if legState∈{1,2,3} or
the death latch (`deathAnimationLatched`@0x650) is set. **Note the lerp is a RUN-speed refinement**
— below `reverseSpeedMax` (walk / turn-in-place) turnRate is the constant walkTR base; the walk→run
interpolation only engages above it, and `runningTurnRate < walkingTurnRate` (a running mech is less
maneuverable). Port: `walkingTurnRate`/`runningTurnRate` are now REAL named members (mech.hpp, set
from the model deg→rad at mech.cpp:1334-5 — the old `Wword(0x15d/0x15e)` writes were no-ops into a
static scratch bank, so the values were silently discarded). The drive (mech4.cpp ~2617) computes
`authTurnRate` per the formula and yaws by it instead of the bring-up constant `kDriveTurnRate`
(kept as the fallback when a model supplies no rates). Diagnostic: `BT_TURN_LOG``[turnrate]`.
Verified headless (DEV mech): walkTR 1.309 rad/s (75°/s), runTR 0.873 (50°/s); turn-in-place + walk
yaw at walkTR, no crash. [T1 logic / T2 runtime]
- **Entry** (leg Standing case, disasm 0x4aa505 [T1]): arm trn when TURNING (`|angVel|>1e-4`, port
proxy = turnDemand deadband ±0.05) AND `0 ≤ spd ≤ standSpeed` (the FULL sub-walk range — CORRECTS the
`0.25·standSpeed` stand-in) AND `turnCapable`(hasCrashSet@0x588). **LOCKSTEP: arms BOTH channels the
same frame** (SetLegAnimation(4)+SetBodyAnimation(4), gated on body also Standing) — arming only the
leg seeded the channel phase-split. The disasm also gates on `legResetLatch`(0x654), a master-perf
ONE-FRAME debounce (cleared every frame @0x4a9bff, set on wind-down/turn-stop) — **omitted in the
port** because its split leg-SM never per-frame-clears the latch, so gating on it would wrongly block
trn after every walk.
- **Exits:** leg SM (part_012.c:12013 [T1], restored VERBATIM) — `standSpeed<spd` → Standing (walk
takes over) or `spd<ZeroSpeed` → Standing (reverse), else advance the pivot at `idleStrideScale`
(goto advance_normally). PLUS the master-perf turn-STOP exit (disasm 0x4aa5c6 [T1]) folded into
case 4: turn stopped (turnDemand back in deadband) → Standing + `legResetLatch=1`. No fast-forward,
no 0.25× — both were stand-ins, now removed. Body case-4 twin mirrors the exits (does NOT set the
latch; mj=0, so it only tracks state for lockstep).
- Verified headless (DEV.EGG, `BT_AUTODRIVE=0.5 BT_FORCE_TURN=1 BT_WALK_DELAY=2 BT_GAIT_TRACE=1`):
turn-in-place enters state 4; throttle ramp past standSpeed → 4→Standing→5(swr)→walk cycle;
legFrm7(trn end)→legFrm1(swr) at the pose-matched boundary; both channels enter walk the same frame.
Harness: `BT_FORCE_TURN` (reaches gBTDrive.turn), `BT_WALK_DELAY=<s>` (turn-then-ramp repro),
`BT_GAIT_TRACE=1` (per-frame [gaitSM]). [T2] **Feel re-verification pending** (release now at
standSpeed vs the invented 0.25×). [T1 logic / T2 runtime]
**Aliasing bug class (namedClip):** `namedClip[]`@0x5e0 == `animationClips[]`@0x5cc+0x14 (ONE array);
`gimpBaseClip`@0x64c == `animationClips[0x20]`; `bodyAnimationState`@0x728 == `bodyStateAlarm.level`.
Declared-separate members don't see each other's writes — alias them.
+36
View File
@@ -237,6 +237,30 @@ engine/port member. Wire sizes verified live (0x14/0x20/0x2c/0x78). Key mechan
A TU-local stub type forced the `BTMapperSpeedDemandRaw(void*)` bridge (mech.cpp carries a local
`struct MechControlsMapper` recon stub — typed cross-TU signatures mangle differently).
## Subsystem update-record DIRECTION + the Torso pair (task #57, 2026-07-13) [T0/T1]
**Engine semantics [T0 SIMULATE.cpp:270-297]: `WriteUpdateRecord(record, model)` PRODUCES the
outgoing record from the object (master serialize; base sets `recordLength=sizeof`, stream
advances by `recordLength` — variable-length records are the mechanism); `ReadUpdateRecord(record)`
APPLIES the record into the object (replicant).** The names read backwards — do not flip them.
Base fns in every subsystem vtable: slot 6 = 41bd34 (Read/apply), slot 7 = 41c500 (Write/produce,
3 args).
- **Torso pair:** slot 6 `@004b6a78` = `ReadUpdateRecord` (apply: lastUpdateTime clock stamp +
twistAtUpdate/twistVelocity/twistRate ← record +0x10/14/18). Slot 7 `@004b6a1c` =
`WriteUpdateRecord`**Ghidra missed the function start**; raw-disasm recovery: chains base,
sets `recordLength=0x1C`, writes currentTwist/twistVelocity/twistRate → +0x10/14/18, snapshots
`twistAtUpdate = currentTwist`.
- **The bug this fixed:** the old recon had the APPLY body on the WRITE virtual (and no Write at
all) — the master consumed its own uninitialized stream buffer (0xCDCDCDCD → twistAtUpdate =
4.316e8) and never serialized twist; the replicant MadCat pinned at 140° (the "torso ghost
twist"). With the pair correct the replicant tracks `targetTwist = twistAtUpdate +
twistRate·elapsed` cleanly (cur=0 when master still). [T2 live]
- **Residual (authentic):** the initial full-state snapshot record does NOT carry the torso
extras; the replicant's blind +0x10 read can pick up 0xCD fill ONCE at spawn. The binary does
the same read; the twist clamp contains it (on the fixed-torso BLH it pins to ±0.01° —
invisible). First real twist record overwrites it.
## Remaining (P6 phase 4 / Phase 7)
The pod-LAN config (real IPs, bare-IP pilot entries); update-record velocity sourced from the body-channel
projection (above). See [[open-questions]]. [T3]
@@ -369,3 +393,15 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
## Key Relationships
- Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]]
(entity lifecycle). Detail: `docs/HARD_PROBLEMS.md` (P6).
## Authentic coupled peer motion — DONE (task #50, 2026-07-14) [T1]
Decomp-verified (workflows w0odszxro/wh1h5gnmc, 3 make-or-break claims adversarially CONFIRMED; committed c52a1ad + default-promoted). The 1995 peer per-frame path FUN_004ab430 -> FUN_004ab1c8 (IntegrateMotion) drives motion SINGLE-SOURCE:
- LINEAR position = GAIT. IntegrateMotion advances the BODY gait channel FUN_004a5678 (FUN_004a5028 is DEAD CODE, zero call sites), turns its cycleDistance into velocity `{0,0,-cd/dt}`, rotates by heading, integrates into projectedOrigin.linear@0x260 (part_012.c:14994/14997/15008); localOrigin.linear@0x100 is copied VERBATIM from it (FUN_00408440, part_012.c:15131 -- NOT a lerp). updateVelocity.linear is never used for position.
- ANGULAR/heading = replicated-velocity slerp (the ONLY thing Mover::DeadReckon-style velocity extrapolation drives): projectedOrigin.angular@0x26c built from updateVelocity.angular@0x2d4 (FUN_004ab188), localOrigin.angular slerped toward it.
- 0x260 aliasing: "motionDelta@0x260" and "projectedOrigin@0x260" are LITERALLY THE SAME field (base Mover::projectedOrigin, Origin 0x1c); "worldPose@0x26c" is its quaternion half. Our reconstruction's shadow names hid this and drove the false velocity-vs-gait "contradiction".
- Re-anchor: motionEventVector@0x598 = updateOrigin - localOrigin captured on poseSyncLatch, DECAYED into localOrigin over ~_DAT_004ab9cc (~0.2s) -- no snap. Zeroed in idle states.
- MASTER + PEER run the SAME predictor. The master's SEND-mirror (FUN_004a9b5c @0x4aab9c) advances projectedOrigin by IntegrateMotion(mj=0) fed the LAST-SENT bodyTargetSpeed, then deadbands |localOrigin-projectedOrigin|^2 vs UpdatePositionDiffrence@0x768. A gait-driven peer REQUIRES this gait mirror (a constant-velocity mirror under-sends -> tug-of-war, measured ratio 1.86).
- T4 CLOSED: both leg(0x65c) and body(0x6bc) animators are AnimationInstances of the SAME JointedMover/JointSubsystem (JMOVER.cpp:1382) -- either poses the WHOLE skeleton. Our port keeps the peer on the LEG channel (body unbound on the peer); faithful functional equivalent.
RECONSTRUCTION STATE: coupled path is DEFAULT ON (mech4.cpp: s_drPos gait-coupled, s_gaitMirror/Send). Reverts: BT_DR_POS=1 (peer velocity), BT_NO_MASTER_GAITMIRROR (master const-vel mirror). Measured: single-source coupling backward-steps 0.1% (vs the two-source split's churn), position ratio 1.043. RESIDUAL (follow-up): occasional ~2.9u snap from the leg(peer)-vs-body(mirror) channel mismatch -- to close, run the master mirror on a leg-channel prediction fed last-sent speed, matching the peer's poser. Also open: exact _DAT_004ab9cc decay constant; whether IntegrateMotion's 2-stage angular integrate is intended.
+211 -2
View File
@@ -21,7 +21,85 @@ authentic path scoped.
- Pod specifics for Phase 8: the 7-monitor driver setup, the RIO cockpit I/O protocol, current
Win10+wrapper pod config. Status: OPEN.
## BINARY-COVERAGE AUDIT discoveries (2026-07-13, 6-agent decomp census) [T1 verified-uncited]
New unaccounted functionality no prior list knew (addresses verified absent from game/+context/+docs):
- **✅ `BTL4VideoRenderer::StartEntityEffectImplementation` @004d097c — DONE (2026-07-13) [T2].**
The per-zone EFFECT DISPATCHER (+helpers @4d0c14 StopAll / @4d0c5c) is reconstructed
(btl4vid.cpp:889) + WIRED: `MechDamageZone` change → `BTStartZoneEffect`
RendererManager::StartEntityEffect → this virtual → the band effect ATTACHED to the struck
zone's segment (smoke/fire follows the mech); the Destroyed descriptor fires on kill
(mechdmg.cpp:1120). Band smoke/fire (psfx ≥1000) render through the live PFX layer
(BTStartPfxAttached); the 3-15 specialfx band + scaling booms/flames render through the
reconstructed L4VIDEO specialfx engine + `ScalingExplosionRenderable` (see next bullet).
**LIVENESS-VERIFIED (2026-07-14):** `BT_CRIT_PROBE=8` on solo DEV.EGG (hammers a local zone) drove
the full chain — `[zonefx] entity 1:321 seg 12 psfx 12/13 at(527,5.8,-669)`: TakeDamage → the
mechdmg damage-effect loop → BTStartZoneEffect → StartEntityEffectImplementation resolves the
struck segment's world pos → BTStartPfxAttached. (The 2-node harness `scratchpad/fx_liveness.sh`
did NOT engage — nodes spawned but never targeted/drove into combat, `mechPicks=0`; an MP-harness
spawn/connect issue, orthogonal to FX. `BT_CRIT_PROBE` is the reliable solo FX-liveness path.)
- **✅ The impact/destruction FX visual chain — RECONSTRUCTED (Fire VISUALS wave 48c9c84 +
Impact-FX FORENSICS wave 065c114, 2026-07-12/13) [T2].** The i860-firmware-decoded specialfx
engine (L4VIDEO.cpp: `specialfxN` descriptor pages, per-round detonations, authored firesmoke
sheet, vertex-alpha effect cards), the case-4 wreck dressing, and `ScalingExplosionRenderable`
(L4VIDRND.cpp:3413 — real ctor/Execute, the scaling-boom motion) are all live. The audit's six
renderable-body addresses (@4540ac/45447c/455eb8/456cf0/4589e0/458e5c) map to this reconstructed
set; a byte-level coverage cross-check of each vs the raw bodies is a minor open audit, NOT a
dead chain. (NOTE: the `//STUBBED: DPL RB 1/14/07` markers scattered in L4VIDRND are benign 2007
empty ctor/dtor notes on UNRELATED renderables — InnerProjectile/DPLObjectWrapper/ChildLight —
not the explosion FX; do not mistake them for a gap.)
- **FUN_00472480** (273 ln) — unnamed analog→discrete stepper Execute in the gauge band
(hysteresis + key-repeat), no caller found; the one hole in "gauge system complete".
Settle via vtable lookup (plausibly a config/roster scroller).
- **FUN_00454a70** (193 ln) — the PNAME1-8/PLACE1-8.bgf MP name-billboard loader (the known
"MP target identification" gap's exact function).
-**Explosion::SplashDamage @0042fad0 — RECONSTRUCTED (task #62, 2026-07-13) [T1/T2].** ONE call
site, **Missile::Perform (FUN_004bef78, part_013.c:10097)**, in the collision branch (fires on ANY
missile impact — mover OR world), gated on `missile+0x360`, radius = `missile+0x364`. **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 → missile ctor SearchList @10184) — NOT the
launcher's `ExplosionModelFile`. The MissileThruster parser `FUN_004bf8ec` writes `"SplashRadius"` to
+0x50 (size-0x54 record). Burst falloff `baseBurst / dist^exp` floored at 1 (arcade 1.25 / WinTesla
`EXPLODE.cpp:209` 1.2f). Only missiles splash (AC tracer is not a Missile). The engine `EXPLODE.cpp`
diff is DONE — the T0 source confirmed the model; the only 1995-vs-WinTesla drift is the exponent.
Port: `BTResolveSplashRadius`/`BTApplySplashDamage` (mech4.cpp), see [[combat-damage]]. **Remaining
T3:** the per-player enable sub-gate `missile+0x360 = BTPlayer+0x264` (its writers read as a per-frame
toggle, not a config flag; the `showDamageInflicted@0x264` label is a guess) — port treats authored
`SplashRadius>0` as the enable. The mech's `DeathSplashDamage`/`DeathSplashRadius` (mech+0x520/0x524)
stay **VESTIGIAL** (written, never read; no mech-death splash mechanic — death is visual only).
- **The binary's own VelociRender BGF/BMF/BSL/VTX loader (~4,550 ln, dead code)** — a free
ground-truth cross-check for [[bgf-format]]/[[asset-formats]] claims.
Full ranked audit: the workflow output (2026-07-13); top-10 ranking cross-checked against this
register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTORY in the KB
(task #8 "landed" vs the kShotDamage=12 bring-up residue) — audit before reworking.
## Deferred subsystems / feeds (authentic path scoped, marked in code)
- **Searchlight-driven fog swap — DEFERRED, and it is an ORIGINAL 1995 LATENT BUG (task #63,
2026-07-13) [T1 decomp].** The arcade swaps fog between `fog=` (lights on) and `nosearchlightfog=`
(off) via `PullFogRenderable` watching the Searchlight's `lightState`. It never fires — in the PORT
because `PullFogRenderable` is never constructed, AND in the BINARY ITSELF because the Searchlight
never lights: `ToggleLamp` (@004b860c) toggles `commandedOn` @0x1DC but `SearchlightSimulation`
(@004b841c) reads `requestedOn` @0x1E0, which is zeroed by the ctor and never written — no
0x1DC→0x1E0 bridge exists (self-consistent sibling ThermalSight reads the field it toggles, 0x1DC).
So `lightState` is perpetually 0. The port reproduces this faithfully (searchlight.cpp:189).
**DECISION: left as-is + documented (faithful to the buggy original); port keeps the static
lights-ON `fog=`.** A working swap is a DESIGNER-INTENT deviation, fully scoped if ever wanted:
(1) sim reads `commandedOn` (mirror ThermalSight); (2) construct `PullFogRenderable` at
btl4vid.cpp `MakeMechRenderables` reticle-build/inside pass (== arcade part_014.c:5173, Dynamic,
bound per Searchlight `lightState` via a new `LightStatePtr()` accessor); (3) a toggle input (the
authentic cockpit button-5→`ToggleLamp` dispatch is a controls-family reconstruction; a dev key
stands in). Also flags a deferred **`Mech::ControlsAllowLights()`** (searchlight.hpp:158 stub).
See [[rendering]] fog section. Verified inert live: BT_FOG_LOG shows zero `SetFogStyle(2/3)`.
- **Factory capability-roster loops 2-4 are STILL DEAD (task #57 discovery).** mech.cpp's
post-roster loops add to `heatableSubsystems`(0x51155c)/`weaponRoster`(0x511830)/
`damageableSubsystems`(0x50e4fc) through the local `SubProxy` stub whose `IsDerivedFrom`
RETURNS 0 — so those three mech-level rosters stay EMPTY (whatever consumes them sees
nothing; weapons demonstrably work through other paths, so audit consumers before assuming
impact). Loop 1 (the watcher CONNECT, 0x50e604) was reconstructed for real in task #57
use the same bridge pattern (family-side derivation test + typed body) for the other three. [T2]
- **Initial-snapshot torso record carries no twist extras**: the replicant's blind +0x10 read
can latch 0xCDCDCDCD ONCE at spawn until the first real twist record; clamps contain it
(invisible on fixed-torso mechs). Binary-authentic blind read — fix only if a visibly wrong
spawn pose ever surfaces. See [[multiplayer]]. [T2]
- **✅ 0xBD3 SubsystemMessageManager — UNTANGLED + LIVE (task #7, 2026-07-11) [T1/T2].**
Both halves landed: (1) the factory case builds the REAL messmgr (ctor @0049bca4, 0x130,
static reconstruction messmgr.cpp) cached at `mech+0x434` — the binary-wide census found
@@ -221,7 +299,11 @@ authentic path scoped.
in the task #8 synthesis) → the pod's 4-8-hit heavy-weapon pacing. The weapon-side
SendDamageMessage (@004b9728) is LIVE from Emitter::FireWeapon (damageData filled incl.
**damageForce = targetmuzzle → the gyro's directional hit-bounce feed**); the mech4
bring-up damage block + flat kShotDamage are retired to diag hooks. **ROOT-CAUSE find: the
bring-up damage block + flat kShotDamage are retired to diag hooks **(CORRECTED, task #60:
`kShotDamage=12` was only HALF-retired — still LIVE at the kill-score `mech4.cpp:1551` until
task #60 replaced it with `lastInflictingDamage`, the real killing-blow magnitude; only the
cross-pod force hook `mech4.cpp:3255` was env-gated. See the task #60 reconciliation below).**
**ROOT-CAUSE find: the
CheckFireEdge NaN latch** -- TriggerState carries ControlsButton INTS (release 65 = a
negative NaN); the binary's x87 unordered-compare treated it as "released" but an
IEEE-correct float compare latched the detector shut after the first release (why the
@@ -231,6 +313,38 @@ authentic path scoped.
LODReuseHysteresis corrected 0.82→0.33 (a double); the MP beam-kill live-verify awaits a
clear-sightline spawn (mechanism identical to solo; force-dmg cross-pod cycles verified).
- **✅ DAMAGE-ECONOMY RECONCILIATION (task #60, 2026-07-13) [T1/T2].** Full 5-path audit vs the
decomp (5-finder + adversarial-verify workflow). **Clean as-is:** energy beam (`emitter.cpp`,
closed form byte-exact; `_DAT_004bafbc` dumped = x87 1e-7), autocannon (`projweap.cpp`, full
authored DamageAmount from resource +0x19C; the 0.0625 at :667 is the shooter's own gyro recoil,
not the round), zone-armor BASE model (`damageLevel += amount·damageScale[type]`, engine
DAMAGE.cpp:379, legs ×0.5, 1.0=destroyed). **3 stand-ins FIXED:** (A) `mechdmg.cpp:451`
`mech->stance` (phantom, perma-0 → the leg-shot-out fall/death branch was DEAD) → `MovementMode()`
(mech+0x40, @part_012.c:6910); (B) `mechdmg.cpp:458` `IsAirborne()` (always-0 stub) → `IsDisabled()`
(@0049fb54) — the task-#52 wreck-graphic corruption fixed AT SOURCE (was only masked by the
IsMechDestroyed latch); (C) `mech4.cpp:1551` flat `kShotDamage=12` kill-score → `lastInflictingDamage`
(real killing-blow, latched in TakeDamageMessageHandler; the score handler @0x4c02e4 derives the
whole kill award from it, so a flat 12 scored every kill identically). Phantom `int stance` slot
reused for `lastInflictingDamage`. **KB corrected:** the "damageScale[5] EVEN=1/armorPoints"
prose is a mild inaccuracy — the array is 5 cells indexed by damageType directly
(Collision/Ballistic/Explosive/Laser/Energy); the per-type value IS ≈1/armorPoints for the
destructive types (net model unchanged). See [[combat-damage]].
- **DEFERRED (task #60-D): the missile CLUSTER model.** The port fires N flying rounds each
carrying `authored/N` damage (net salvo total = authored, so per-armor damage is correct), but
the binary (@004bcc60) fires ONE missile whose `burstCount` is a RANDOM cluster-hit roll
(`min(rand(0..N-1)+N/4, N)`, FUN_004bef78) applied to ONE cylinder-resolved zone. The port thus
loses (i) cluster-hit VARIANCE (always full N) and (ii) single-zone CONCENTRATION (N rounds
scatter across zones). Net-neutral on total armor, so LOW priority. **OPEN semantic gating the
severity:** does `burstCount` multiply ARMOR damage or only the gyro kick? DAMAGE.h:49-54 [T0]
says "times to apply the damage" (→ apply-count → port total authentic); settle by reading the
consumer chain FUN_004bef78 → FUN_004be078 → EXPLODE.cpp:209-210 (does it loop burstCount over
DamageZone::TakeDamage or apply once?). Fix only if made byte-faithful (roll clusterHits, thread
`burst` through BTPushProjectile, one damage-bearing round + rest visual-only).
- Also open (bring-up, not stand-ins): `scoreAward=0` (btplayer.cpp:1526 — the authentic kill
bonus lives in the un-exported master-perf writer 0x4a9770-0x4ab188, T4); the exact authentic
kill `damageAmount` from that same writer (msg+0x24) is unrecovered — the killing-blow magnitude
is the faithful stand-in until it is.
- **HEAT (task #9, 2026-07-11): authentic 1e7-unit heat LIVE; conduction ROUTING defect
remains [T2/T3].** Landed + byte-anchored: emitters emit `heatCostToFire x 1e7 x
(charge/seekV)^2` (closed form; PPC = 1.1e8 -> +632 K on its 174000-mass sink); the missing
@@ -299,6 +413,33 @@ authentic path scoped.
under reconstruction: config-mode regrouping (task #6), generator reassignment (ids 4-8),
coolant valves.
- **✅ AUTOCANNON JAM / coolant-priority — FAITHFUL + CONFIRMED against QA ground-truth, SOLVED
purely from the decomp (2026-07-14) [T1/T2].** An original QA dev reported: "all AFCs jam on their
3rd shot if coolant priority isn't boosted; boosted → almost never jam." VERDICT: the whole chain
is reconstructed end-to-end, FAITHFUL, and reproduces the reported behavior — no bug, no stand-in.
**DO NOT "fix" it by driving a weapon's `coolantFlowScale` from the valve; that field being a
constant 1.0 for weapons is AUTHENTIC** (the ONLY writer of any HeatSink `+0x15C` from a valve value
is `RecomputeCondenserValves`/FUN_0049f788, which iterates ONLY the condenser chain `mech+0x7cc`).
Mechanism: a weapon dumps `heatCostToFire` (AFC100=6.5e7 raw) into its OWN linked condenser (AFC100→
Condenser4); the condenser sheds to the shared bank at a rate scaled by its valve SHARE
(`coolantFlowScale = valveState_i / Σ valveState`, MoveValve msg id 4, detents 1/5/50/0). The
weapon's OWN thermal conductance (35500) caps how fast it sheds into its condenser, so coolant
priority shifts the weapon's equilibrium only ~100 K — but that is EXACTLY enough to straddle the
`degradationTemperature`(1000) jam line at combat fire rates. Cross `1000``heatAlarm`≥1 → the
jam roll arms (`p=clamp(0.41·T/failT, 0.05, 1)`); on a hit → sticky `JammedState`(5), clears only on
ResetToInitialState. (Distinct: `failureTemperature`(2000) → self-clearing overheat lockout,
recoil-pinned + weaponAlarm 7.) **ALL constants VERIFIED AUTHENTIC vs the raw BTL4.RES bytes**
(`scratchpad/afc_dump.py`): AFC100 startT=77, degradT=1000, failT=2000, thermalConductance=35500,
thermalMass=94700, heatCostToFire(+0x1A4)=6.5e7, recharge(+0x190)=8, damage=25, minJamChance=0.05 —
no mis-scaling. **THE ANSWER IS FIRE-RATE-DEPENDENT** (measured, BT_JAM_LOG/BT_VALVE_BOOST/BT_AF_PERIOD,
madcat AFC100 → Condenser4): at MAX spam (8s recharge) the AFC overheats regardless (~1460 boosted
~1360 — both jam); at a SLOW cadence (16s) it plateaus ~770 and never jams; at the ~11s COMBAT cadence
it is DECISIVE — **not boosted → 1058 K (jams), boosted (Condenser4 valve→50, others→1) → 953 K (never
jams)**. So the QA is right and the reconstruction is faithful; the earlier "boost is weak" read was
an artifact of testing at max spam (above the window). Diag probes committed: `BT_JAM_LOG`
(projweap.cpp), `BT_VALVE`/`BT_VALVE_BOOST=<condenserNum>` (heatfamily_reslice.cpp), `BT_AF_PERIOD=<sec>`
(mech4.cpp). See [[combat-damage]], [[subsystems]] heat.
- **Subsystem-panel online/offline gate — fields unidentified [T4] (2026-07-12).** The binary's
SubsystemCluster draw-state reads `*(subsystem+0x40)==1` and `*(subsystem+0x278)!=4`; neither
1995 field is identified (weapon simulationState is 0/2/3/4 -- +0x40 isn't it). The port pins
@@ -308,7 +449,24 @@ authentic path scoped.
destroyed-subsystem dark-panel look.
## Locomotion / combat polish (non-gating)
- Authentic per-mech TURN-RATE constant (currently a bring-up constant rate).
- **✅ Interior vs exterior gait CLIP SET — DONE 2026-07-13 (task #59) [T2].** The local cockpit
mech was leaning 8° into every walk because the authentic ctor clip-set gate
(@part_012.c:10308-10320) was a no-op (`LoadLowDetailBody`/`LoadHighDetailBody` mislabeled the
FUN_004a80d4/86c8 clip-loader addresses) and `LoadLocomotionClipsExt` was a stub aliased to the
exterior loader. Reconstructed the real 4-char INTERIOR (`'i'`-suffix) loader (omits `jointhip`,
shakes `jointshakey`). Because the port never sets the replicant copy bit the ctor gate lands
everyone on interior, so `Mech::MaintainViewClipSet()` picks the set by VIEWPOINT each frame
(your mech → interior/level; every other mech → exterior/lean). Both verified live: cockpit
level, peer replica leans 8/11°. See [[locomotion]] "The walk lean IS authored".
- FOLLOW-UP (non-blocking): if the copy bit ever gets set at construction for other reasons,
the ctor gate already handles it and MaintainViewClipSet becomes a no-op agreement — no
conflict, but revisit whether the per-frame maintenance is still needed.
-**Authentic per-mech TURN-RATE — DONE (task #64b, 2026-07-14) [T1/T2].** The master-perf
disasm (0x4aa3d3-0x4aa4ff) turn-rate lerp (walkingTurnRate@0x574 → runningTurnRate@0x578 by
ground speed, run-speed refinement) is wired into the drive, replacing the `kDriveTurnRate`
bring-up constant; the rates (which the `Wword(0x15d/0x15e)` no-op was discarding) are real
members now. Also the trn-in-place DISPATCHER (entry/exit) reconstructed from the same disasm,
replacing the task-#64 `0.25×`/fast-forward stand-ins. See [[locomotion]].
- Body-callback gimp handlers (states 16-19, targets 0x70b2/0x7161 undecoded → fall back to stand);
airborne callbacks (`FUN_004a6344`/`FUN_004a7970`).
- Wall-block-vs-climb tuning. **✅ Collision DAMAGE application — DONE 2026-07-08 [T2].** The two deferred
@@ -383,6 +541,19 @@ authentic path scoped.
needs slots 0xf/0x10 promoted to named members when P6 replication work resumes.
## Multiplayer (Phase 7 / P6)
- **OPEN: console death froze peer replication BOTH ways (2026-07-14) [T4 mechanism].** Live 2-node
session: the btconsole.py emulator process died mid-match (cause unknown — its recv loop only
caught socket.timeout; any OSError killed threads silently; NOW hardened + `-u` + console.log) and
peer↔peer update replication froze in BOTH directions at ~that moment (each node's replicant held a
frozen stale velocity; own sims + rendering fine). The engine NEVER logged a console disconnect —
though `CheckBuffers` recv==0 dispatches `HostDisconnectedMessage` and the loop keeps servicing
other hosts — so either the console host's receive pad was full (recv skipped → close never seen)
or the dispatch doesn't reach `HostDisconnectedMessageHandler` in the port (routing gap). ⚠ Also
found (unfired, latent): that handler's ConsoleHostType branch closes **`gameListenerSocket`** (the
GAME listener) where the comment says it re-posts a CONSOLE listen (L4NET.CPP:969) — suspect
`consoleListenerSocket` was intended. Diagnose on recurrence: console.log (death reason) +
whether `[repltrn]` velocities freeze at console death. Why peer replication depends on the
console connection at all is the core unknown.
- ✅ Cross-pod COMBAT — DONE (tasks #46/#47: replicant targeting + damage rerouted to the owning
master, full cross-pod kill verified). ✅ Replicant GAIT animation — DONE (task #50: replicant
derives speedDemand from the replicated velocity, full stand→walk→run lifecycle verified).
@@ -396,6 +567,25 @@ authentic path scoped.
- The WinSock2 stack (L4NET.CPP) is 2-node verified end-to-end (cross-pod kills, beams,
replicant gait — tasks #46-#51); remaining = the real pod-LAN config (real IPs, bare-IP
pilot entries).
- **✅ Subsystem-record replication IS wired — the AC just wasn't using it (task #61,
2026-07-13) [T2, CORRECTED].** (An earlier draft of this note WRONGLY claimed "no
subsystem-record channel" — that was concluded from an AC-only test before the AC's
ForceUpdate gap was found. The channel exists and works.) MECHANISM: the mech's per-frame
tick calls `subsystem->PerformAndWatch(till, update_stream)` for every subsystem
(mech4.cpp:3893) → base `Simulation::WriteSimulationUpdate` walks the subsystem's `updateModel`
and serializes any set record; `Entity::UpdateMessageHandler` (ENTITY.cpp:387) routes each
incoming record to `GetSimulation(subsystemID-1)->ReadUpdateRecord` on the peer. A subsystem
replicates its fire IFF its `FireWeapon` calls **`ForceUpdate()`** (sets the updateModel bit).
The EMITTER (beam) and MISSILELAUNCHER (salvo mirror, mislanch.cpp:326) both do → enemy lasers
+ missiles ARE visible on the peer. The AUTOCANNON set only `simulationFlags |= 0x1` (the
+0x28 instance flag, NOT updateModel) and had no fire record → its shot never serialized → the
enemy's cannon (tracer + DAFC muzzle flash) was invisible. FIX: added
`ProjectileWeapon::WriteUpdateRecord/ReadUpdateRecord` (fire counter + aim, the AC twin of the
salvo mirror) + `ForceUpdate()` in FireWeapon. Verified live: watching node now logs
REPLICANT AC shots + muzzle flashes. **LESSON (methodology):** the binary-coverage audit is a
function-PRESENCE census — it can't see "reconstructed but inert" code (a function that exists
but is never CALLED, like the salvo mirror was thought to be, or a missing ForceUpdate). A
LIVENESS audit (does a live call path reach each reconstructed fn?) would catch this class.
## Rendering follow-ups (non-blocking)
- **.PFX effect-layer polish (deferred 2026-07-08; the layer itself is LIVE + verified).** The
@@ -420,3 +610,22 @@ authentic path scoped.
## Key Relationships
- Feeds from: every subsystem/render topic (their deferral notes collect here).
- Gating master: [[source-completeness]] (the missing BT source).
## Peer coupled-motion architecture — COMPLETION PENDING (task #50 tail, 2026-07-14)
The user's speed-change peer glitch is the last visible MP motion artifact. Root
cause is ARCHITECTURAL and decomp-confirmed: the original peer (FUN_004ab430 ->
FUN_004ab1c8) drove position FROM the clip's root travel between records (feet
locked to ground by construction) + pose-sync offset decay; our port dead-reckons
position from velocity while legs run on commanded speed — they disagree exactly
during accel/decel. The PEER half is implemented env-gated (BT_ROOT_POS=1,
mech4.cpp replicant branch) but measured worse alone: with sparse records the
master's VELOCITY mirror under-sends for a gait-driven peer (11u snaps); with
dense records the two authorities tug-of-war (path/net 1.86). COMPLETION NEEDS
THE MASTER HALF: (1) master send-gate linear mirror driven by the same gait
travel (its own channel-B IntegrateMotion projection, as the binary's), so
records model the peer; (2) then sparse authentic deadband cadence + offset
decay. All fixed-and-committed groundwork: exact quaternion integration
(a8eb8a4), incremental peer heading (d78e77b), clock guard (93456be), commanded-
speed cadence feed (6374efc), Abs-macro fix (7615ecd). Speed model decomp-settled
(2c6db6a): analog throttle, '5 speeds' false, throttleState@0x4a4 is actually the
fall-surface material (RENAME PENDING).
+30
View File
@@ -25,6 +25,36 @@ to keyboard/gamepad on a dev box; real wiring is a pod bring-up task (Phase 8).
model — `LBE4ControlsManager` groups are fed by all devices (RIO on the pod, DirectInput on dev);
the `MechControlsMapper` interprets them ([[locomotion]]). [T2]
### The pod throttle is ANALOG-CONTINUOUS, not notched (verified end-to-end) [T1]
The authentic pod throttle path — traced 2026-07 (task #50 throttle-fidelity question):
1. **Hardware → RIO:** serial `AnalogReply` packet → `Ranger("Throttle", 0, 800, .05)` — raw counts
0800, 5% deadband, auto-ranging offset, output a CONTINUOUS Scalar; the sign is inverted
("Throttle counts BACKWARDS", `engine/MUNGA_L4/L4RIO.cpp:1374-1377`, Ranger @L4RIO.cpp:461-701).
No quantization/notching anywhere in `Ranger::Update`.
2. **Manager:** `LBE4ControlsManager::Execute` → `scalarGroup[ScalarThrottle].Update(&rioPointer->
Throttle, mode_mask)` on every AnalogReply (`L4CTRL.cpp:1379-1382`). ScalarThrottle = index 0 →
manager+0x24 (scalarGroup base 0x24, 0x20/entry; buttonGroup base 0x1c0, keyboardGroup 0x160).
3. **Streamed `.CTL` mapping (the "handled elsewhere"):** `MechRIOMapper`'s ctor @004d266c binds NO
throttle — the bind comes from the type-19 `ControlMappingStream` resource named **"L4"** (child
of the per-mech type-6 `ControlMappingsList`; installed by `BTL4APP MakeViewpointEntity` via
`CreateStreamedMappings` @0047703c). BTL4.RES "L4" record [1]:
`Scalar Throttle → subsystemID 0 (ControlsMapper slot), DirectMapping, attr 4, mask 0xffffffff`.
Attr 4 = "ThrottlePosition" @ mapper+0x11c (binary IndexEntry table @0050efd8: id 4 → offset
0x11d-1 = 0x11c, name "ThrottlePosition" @0050f28f). Reverse = record [2]: `Button Throttle1
(0x3F, on the throttle handle) → attr 6 ReverseThrust@0x124`. Turn = pedals value-bound in the
ctor (manager+0x44/+0x64 → mapper+0x1b4/+0x1b8).
4. **Interpretation:** `L4MechControlsMapper::InterpretControls` @004d196c applies the ONLY software
detent — snap to 1.0 when |t1.0| ≤ 0.05 — then `MechControlsMapper::InterpretControls` @004afd10
computes `speedDemand@0x128 = maxSpeed(mech+0x34c) × throttlePosition(0x11c) × scale(mech+0x5c0)`
(forward) or `maxSpeed × throttlePosition` (reverse flag 0x124).
**Consequence:** the original pod produced a continuously varying speedDemand while the lever moved
(updated per serial AnalogReply, NOT per render frame). There are NO "5 throttle notches" anywhere
in the software path. (Keyboard keys '1'-'5' in @004d1bf0 set controls-manager MODE masks; the
'+'/'-' pair steps a [0,5] value that drives `pow(2,x)` into mech+0x404 = HUD zoom 1×–32× — neither
is a speed setting.) The mech's `throttleState@0x4a4` writer remains un-exported (likely in the
0x4a9b5a0x4ab188 gap) — [[open-questions]].
## Multi-surface gauge path (intact, pod-only by default)
The pod multi-surface path EXISTS and is intact: `DPLRenderer::FindBestAdapterIndices` (multi-
adapter selector, honors PRIMGAUGE/SECGAUGE/MFDGAUGE/SPANDISABLE), `SVGA16::BuildWindows` (a
+33
View File
@@ -52,6 +52,39 @@ DECLOUDS must stay in the sky pass). Authored TEXTURE **SCROLL** (BMF TEXTURE ta
`L4DPLCFG`): resolves map+time → clip/fog/ambient + a directional sun. Bring-up stubs
(EnsureValidProjection / per-frame ambient / uninit cloud colours) had clobbered it — now they
RE-ASSERT the env values. Debug: copy BTDPL.INI → *DBG.INI with `debug=True`. [T2]
- **Fog — per map/time/WEATHER (task #63).** `BTDPL.INI` authors `fog=<near> <far> <r> <g> <b>` (+ often
`nosearchlightfog=`) on EVERY map/time/weather leaf. WEATHER is a fixed authored egg field
(`clear`/`fog`/`soup`, `mission->GetMissionWeather()`; missing = fatal in the arcade); the env pipeline
branches location→time→weather to the leaf. Shipped eggs pin `cavern/night/clear` → page `dsnitclear`
(near 90, far 1100, dark blue 0.1,0.1,0.12); `fog``dsnitfog` (500), `soup``dsnitsoup` (300). The
arcade fog model is per-PIXEL linear (`dpl_fog_type_pixel_lin`, dpl.h:265). **It rendered INVISIBLE in
the port:** `D3DRS_FOGTABLEMODE=D3DFOG_LINEAR` (table fog) reads the perspective-NONLINEAR z-buffer, so
without WFOG the authored 90..1100 range collapsed to fog-factor≈1 (no fog). FIX (L4VIDEO.cpp world
pass ~8194): auto-detect `D3DPRASTERCAPS_WFOG` → per-pixel W-fog (smooth, == the arcade); else VERTEX
fog (eye-space but per-vertex → splotchy on coarse terrain tris); FOGENABLE + mode re-asserted each
world-pass frame (the old one-time set got clobbered per-pass). **Task #20's "shipped maps define no
fog" was WRONG** — it checked `.MAP`/`.RES`, but fog lives in BTDPL.INI (the DPL env INI), as in the
arcade. Night fog is dark BY DESIGN (distance→murk, not light haze); day pages author light haze. Env:
`BT_FOGMODE=table|vertex|off`, `BT_WEATHER=clear|fog|soup`, `BT_FOG="near far r g b"` (all branches);
`[fog] resolved`/`[fog] model`/`[fogstyle]` (BT_FOG_LOG) log lines. [T1 pipeline / T0 arcade model / T2 fix]
**⚠ SEARCHLIGHT SWAP IS INERT — and it was inert in the ARCADE too (task #63 workflow, 2026-07-13).**
The arcade swaps the active fog between `fog=` (headlight ON) and `nosearchlightfog=` (OFF,
darker/tighter) via `PullFogRenderable::Execute` (L4VIDRND.cpp:4601, edge-triggered on two light attrs →
`SetFogStyle(searchLightOn/OffFogStyle)`). TWO independent reasons it never fires:
(1) **Port:** `PullFogRenderable` is NEVER CONSTRUCTED (grep: only its .h decl + .cpp def; no `new`);
`searchLightOn/Off` is called from nowhere else (BT_FOG_LOG: over 22s only `SetFogStyle(0)`, zero 2/3).
(2) **Original 1995 LATENT BUG [T1, decomp]:** even if wired, the Searchlight never lights —
`ToggleLamp` (@004b860c) toggles `commandedOn` @0x1DC (part_013.c:6087) but `SearchlightSimulation`
(@004b841c) reads `requestedOn` @0x1E0 (part_013.c:5978), which the ctor zeroes and NOTHING ever writes;
no bridge 0x1DC→0x1E0 exists anywhere in the binary. So `lightState`@0x1D8 is perpetually 0. The
self-consistent sibling **ThermalSight** reads the SAME field it toggles (0x1DC) — the tell that
Searchlight's sim reads the wrong slot. The port reproduces this faithfully (searchlight.cpp:189 reads
requestedOn). **DECISION (2026-07-13): left as-is + documented** (faithful to the buggy original); the
port keeps the static lights-ON `fog=` values. To make the swap WORK is a DESIGNER-INTENT fix that
DEVIATES from the shipped binary — repair the sim to read `commandedOn` (mirror ThermalSight) + construct
`PullFogRenderable` at btl4vid.cpp `MakeMechRenderables` (the reticle-build/inside pass, == arcade
part_014.c:5173, ExecutionType Dynamic, bound to each Searchlight `lightState`) + a toggle input. See
[[open-questions]]. Core per-map/time/weather fog is unaffected.
- **Projection / stencil (task #55):** the BTDPL.INI `viewangle=60` is the **HORIZONTAL** FOV —
authentic 60×47 frustum at 4:3; fovY derived via `BTFovYFromHorizontal` (L4VIDEO.cpp), applied at
all 5 projection sites (it was mis-applied as vertical). The device depth-stencil is now
+34
View File
@@ -41,6 +41,11 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
- **WAVE 3** — power bus (Generator/PoweredSubsystem) + Emitter/PPC fire-path (end-to-end fire; heat
conducts to the central sink via the linked-sink roster).
- **WAVE 4** — standalone readouts: Sensor/Searchlight/ThermalSight/AmmoBin (de-shim, gate fixes).
- ⚠ **Searchlight has an ORIGINAL latent bug [T1, task #63]:** `SearchlightSimulation` (@004b841c)
reads `requestedOn`@0x1E0 (never written) while `ToggleLamp` (@004b860c) toggles `commandedOn`@0x1DC
— no bridge, so `lightState`@0x1D8 is perpetually 0 (lamp never lights). Present in the 1995 binary
(sibling ThermalSight reads the field it toggles, 0x1DC); the port reproduces it faithfully. Left
as-is by decision; it makes the searchlight→fog swap inert. See [[open-questions]] + [[rendering]] fog.
- **WAVE 5** — Torso (aim twist/elevation; joint-I/O reconstructed; the BLH record disables torso →
faithfully no visible twist).
- **WAVE 6** — Myomers (mover-coupled; structural un-stub is INERT — no live mover/heat coupling).
@@ -55,6 +60,35 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
an untangle. NOT the valve/advanced-damage gate — that's the owning **BTPlayer** @mech+0x190, see
[[combat-damage]]/[[open-questions]]). [T2]
## The watcher electrical chain (task #57, 2026-07-13 — the torso power gate)
The Watcher branch is POWERED indirectly: a watcher WATCHES another roster subsystem and mirrors
its electrical state. The full chain, byte-verified [T1]:
- **Data:** the model entry `WatchedSubsystem=<name>` → segment index **+2** at resource+0xE4
(`HeatWatcher::CreateStreamedSubsystem` @004aec54); the ctor @004aeb40 stores it at
watcher+0x128 (`watchedSubsystem`).
- **Bind (factory post-roster loop 1):** vtable slot 14 (+0x38) — `@004aee2c` (HeatWatcher) /
`@004b1a40` (PowerWatcher/Torso override, byte-identical); **Ghidra missed both function
starts** (recovered from raw bytes; the vtables.tsv rows have GAPS where the exporter skipped
slots — dump the exe bytes at vtable+slot*4 when a slot looks missing). Master-gated
(`(owner->simulationFlags & 0xC)==0 && (flags & 0x100)`); binds `watchedLink(+0x114).Add(
owner->roster[+0x128][watchedSubsystem])`. Port: `BTWatcherWatchedIndex`/`BTWatcherBindTarget`
bridges (heatfamily_reslice.cpp) called from the mech.cpp factory loop.
- **Tick:** `@004b181c` = the REAL `PowerWatcher` Performance (PTR @0050f5fc → 004b181c) =
`UpdateWatch()`: heat mirror (FUN_004aeac4) + `watchdogAlarm.SetLevel(watched->electrical
level @+0x278)` + brownout downgrade to 1 when `gen->outputVoltage(+0x1DC) <= minVoltage(+0x180)
× gen->ratedVoltage(+0x1D8)`. The Torso sims (@004b5cf0/@004b65f8) call it first-line.
(`@004b1804` is slot-10 **ResetToInitialState**, NOT the Simulation — old recon mislabel, fixed.)
- **MinVoltageScale = 0.01** — a 10-byte x87 literal at 0x4b1924 (`0a d7 a3 70 3d 0a d7 a3 f8 3f`);
the port had 1.0f, making minVoltage 100× too big → the brownout latched every watchdog at 1.
- **PowerWatcher::GetClassDerivations chains HeatWatcher** (real base) — the old HeatableSubsystem
stand-in broke `IsDerivedFrom(HeatWatcher)` for Torso/Searchlight/ThermalSight and silently
skipped them in the connect pass.
- **Effect:** `Torso::ElectricalStateLevel()==Ready(4)` un-gates `effectiveTwistRate` — the MadCat
torso twists at its authored 50°/s (±140° limits, roster 17 watching 15 → generator @10000V);
the BLH is authentically fixed (`horizontalEnabled=0`, limits ±0.01°). [T2 live-verified]
- **STILL DEAD:** factory loops 2-4 (heatable/weapon/damageable capability rosters) go through the
`SubProxy` stub whose `IsDerivedFrom` returns 0 — they add NOTHING. See [[open-questions]].
## The four systemic checks (every subsystem)
See [[reconstruction-gotchas]]: (1) shadowing (re-declared engine-base fields), (2) the `Wword` trap,
(3) message-handler chaining, (4) entity validity. Plus resource-struct layout (must mirror the class
+7 -3
View File
@@ -1173,9 +1173,13 @@ flight entity.
- `SetPerformance(&ProjectileWeapon::ProjectileWeaponSimulation)` — wired (ctor).
- **MissileLauncher** (`mislanch.cpp/.hpp`) — `FireWeapon` (override @004bcc60)
reconstructed: `ConsumeRound()`, recoil cooldown, then spawn `missileCount`
guided `Missile::New` entities per salvo (damage already pre-split per-missile
in the ctor). Ticks via the inherited `ProjectileWeaponSimulation`; its vtable
reconstructed: `ConsumeRound()`, recoil cooldown, then spawn ONE guided
`Missile::New` entity per salvo (task #60 correction — NOT `missileCount`
entities; the single missile carries `burstCount` = the random cluster-hit
roll, and per-missile damage = authored/missileCount pre-split in the ctor).
The PORT re-expresses this as N flying rounds (net armor total identical;
loses cluster variance + single-zone concentration — see [[open-questions]]
task #60-D). Ticks via the inherited `ProjectileWeaponSimulation`; its vtable
`FireWeapon` override is dispatched from there.
- **Missile** (`missile.cpp/.hpp`) — flight-entity tick wiring ADDED.
+32 -8
View File
@@ -11,6 +11,34 @@
#include "line.h"
#include "app.h"
#include "notation.h"
#include <math.h>
//
// EXACT axis-angle rotation composition -- matches the 1995 BT binary's angular
// integrator (FUN_00409f58): build a unit rotation quaternion from the rotation
// VECTOR `rotVec` (angle = |rotVec|, axis = rotVec/angle) as { axis*sin(angle/2),
// cos(angle/2) } and Hamilton-multiply it onto `base`. The dead-reckoner previously
// did `out.Add(base, rotVec)` -- adding a scaled angular-velocity vector to the heading
// quaternion. That is only a small-angle approximation: fine per-frame (tiny angle),
// but over a long replicant dead-reckon gap it DIVERGES (the heading drifts to ~180deg
// then snaps -- the spinning-peer hesitation). This composition is exact for any angle
// and stays on the unit sphere.
//
static void ExactAngularProject(Quaternion &out, const Quaternion &base, const Vector3D &rotVec)
{
const Scalar ang = rotVec.Length();
if (ang > 1.0e-6f)
{
const Scalar h = 0.5f * (Scalar)fmodf((float)ang, 6.2831853f); // half of angle mod 2pi
const Scalar s = (Scalar)(sinf((float)h) / ang); // sin(angle/2)/angle
const Quaternion dq(rotVec.x * s, rotVec.y * s, rotVec.z * s, (Scalar)cosf((float)h));
out.Multiply(base, dq); // base (X) dq
}
else
{
out = base;
}
}
//#############################################################################
//############################### Mover #################################
@@ -395,10 +423,8 @@ Logical
//-------------------------------
//
position_delta.Multiply(updateVelocity.angularMotion, time_slice);
projectedOrigin.angularPosition.Add(
updateOrigin.angularPosition,
position_delta
);
ExactAngularProject(projectedOrigin.angularPosition, // was .Add (diverging vector-add)
updateOrigin.angularPosition, position_delta);
projectedVelocity = updateVelocity;
Check_Fpu();
@@ -460,10 +486,8 @@ Logical
updateVelocity.angularMotion,
time_slice
);
projectedOrigin.angularPosition.Add(
updateOrigin.angularPosition,
position_delta
);
ExactAngularProject(projectedOrigin.angularPosition, // was .Add (diverging vector-add)
updateOrigin.angularPosition, position_delta);
//
//-----------------------------------
+18
View File
@@ -2118,6 +2118,12 @@ Logical L4NetworkManager::CheckBuffers(NetworkPacket *network_packet)
{
continue;
}
// TCP_NODELAY on the accepted GAME socket (not the console):
// without it Nagle coalesces the inbound per-frame update
// records into bursts -> the peer is received in lurches.
BOOL gameNoDelay = TRUE;
if (setsockopt(tempSocket, IPPROTO_TCP, TCP_NODELAY, (char*)&gameNoDelay, sizeof(gameNoDelay)))
DEBUG_STREAM << "WARN: could not set TCP_NODELAY on accepted game socket; setsockopt() failed with " << WSAGetLastError() << "\n" << std::flush;
}
remote_host->SetNetworkSocket(tempSocket);
@@ -2798,6 +2804,18 @@ SOCKET L4NetworkManager::OpenConnection(
WSACleanup();
PostQuitMessage(AbortExitCodeID);
}
// TCP_NODELAY: disable Nagle so the small per-frame update records ship
// immediately instead of being coalesced. With Nagle on (the default),
// Nagle + delayed-ACK batch the tiny position packets into ~40-200ms
// bursts, so a peer that moves at a steady speed is RECEIVED in lurches
// (measured: dead-reckoned ground speed swinging 3x per 0.25s window).
// The pod net carries steady real-time state where latency, not
// throughput, is what matters -- exactly the case NODELAY is for.
{
BOOL noDelay = TRUE;
if (setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (char*)&noDelay, sizeof(noDelay)))
DEBUG_STREAM << "WARN: could not set TCP_NODELAY on active socket; setsockopt() failed with " << WSAGetLastError() << "\n" << std::flush;
}
return sock;
}
else if(connection_type == NETNUB_TCP_LISTEN)
+165 -8
View File
@@ -669,6 +669,12 @@ struct BTPfxEmitter
float emitAccum; // fractional particles owed (rate * dt integration)
int issued;
int active;
// ATTACHED effects (the @004d097c per-zone dispatcher): the emitter rides
// the owner entity's SEGMENT -- zone smoke follows the walking mech, and
// StopAllEntityEffects (@004d0c14: the respawn cleanup) kills every
// emitter tagged to the entity. ownerTag==0 = a free world effect.
void *ownerTag;
int followSeg;
};
struct BTPfxParticle
{
@@ -798,6 +804,8 @@ void BTStartPfxFrame(int effect_number, float x, float y, float z,
e.emitAccum = 1.0f; // first particle immediately
e.issued = 0;
e.active = 1;
e.ownerTag = 0;
e.followSeg = -1;
gBTPfxEmitters.push_back(e);
}
@@ -806,6 +814,52 @@ void BTStartPfx(int effect_number, float x, float y, float z)
BTStartPfxFrame(effect_number, x, y, z, 0, 0, 0);
}
// ATTACHED effect start (the @004d097c per-zone dispatcher): like
// BTStartPfxFrame, but the emitter rides the owner entity's segment -- the
// per-frame sim re-resolves the segment's world transform through the
// game-side bridge below, so a damage-band smoke plume TRAILS the walking
// mech instead of hanging at the hit point. rows9 = the initial 3x3 basis
// (rows = local axes in world), pos = the segment's current world position.
extern int BTResolveSegmentWorld(void *entity, int seg_index,
float *pos3, float *rows9); // mech4.cpp (game side)
void BTStartPfxAttached(int effect_number, void *owner, int seg_index,
float x, float y, float z, const float *rows9)
{
if (effect_number < 0 || effect_number >= BT_PFX_SLOTS)
return;
const BTPfxDef &d = gBTPfxDefs[effect_number];
if (!d.valid)
return;
if (gBTPfxEmitters.size() > 256)
return;
BTPfxEmitter e;
e.def = &d;
e.pos = D3DXVECTOR3(x, y, z);
e.ax = rows9 ? D3DXVECTOR3(rows9[0], rows9[1], rows9[2]) : D3DXVECTOR3(1, 0, 0);
e.ay = rows9 ? D3DXVECTOR3(rows9[3], rows9[4], rows9[5]) : D3DXVECTOR3(0, 1, 0);
e.az = rows9 ? D3DXVECTOR3(rows9[6], rows9[7], rows9[8]) : D3DXVECTOR3(0, 0, 1);
e.emitAccum = 1.0f;
e.issued = 0;
e.active = 1;
e.ownerTag = owner;
e.followSeg = seg_index;
gBTPfxEmitters.push_back(e);
}
// StopAllEntityEffects (@004d0c14 analog): the firmware killed every effect
// instance tagged (playerIdx<<16 .. |0xffff); the port kills every emitter
// tagged to the entity (the respawn cleanup -- a respawned mech must not
// trail its corpse's zone smoke). Emitted particles fade out naturally.
void BTStopEntityPfx(void *owner)
{
if (owner == 0)
return;
for (size_t i = 0; i < gBTPfxEmitters.size(); ++i)
if (gBTPfxEmitters[i].ownerTag == owner)
gBTPfxEmitters[i].active = 0;
}
// Spawn a few particles of a slot's effect DIRECTLY at a moving point (no
// emitter instance) -- the per-frame projectile SMOKE TRAIL (psfx 0 = dsrm,
// "the lrm smoke trail": its velocities stream +Z = BEHIND the round, so the
@@ -832,6 +886,7 @@ void BTPfxTrailPuff(int effect_number, float x, float y, float z,
D3DXVECTOR3 ay; D3DXVec3Cross(&ay, &az, &ax);
e.ax = ax; e.ay = ay; e.az = az;
e.emitAccum = 0.0f; e.issued = 0; e.active = 0;
e.ownerTag = 0; e.followSeg = -1;
for (int i = 0; i < count; ++i)
BTPfxSpawn(e);
}
@@ -1064,6 +1119,18 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
{
BTPfxEmitter &e = gBTPfxEmitters[ei];
if (!e.active) continue;
// attached emitters ride their owner's segment (@004d097c dispatcher)
if (e.ownerTag != 0)
{
float p3[3], r9[9];
if (BTResolveSegmentWorld(e.ownerTag, e.followSeg, p3, r9))
{
e.pos = D3DXVECTOR3(p3[0], p3[1], p3[2]);
e.ax = D3DXVECTOR3(r9[0], r9[1], r9[2]);
e.ay = D3DXVECTOR3(r9[3], r9[4], r9[5]);
e.az = D3DXVECTOR3(r9[6], r9[7], r9[8]);
}
}
const BTPfxDef &d = *e.def;
// CONTINUOUS emission at `rate` particles/second until maximum_issue is
// exhausted. Data-verified semantics: in EVERY shipped .PFX,
@@ -4010,6 +4077,10 @@ void
void
DPLRenderer::SetFogStyle(FogStyle my_fog)
{
if (getenv("BT_FOG_LOG"))
DEBUG_STREAM << "[fogstyle] SetFogStyle(" << (int)my_fog
<< ") 0=update 1=noUpdate 2=searchLightOn 3=searchLightOff 4=winnersCircle\n"
<< std::flush;
//STUBBED: DPL RB 1/14/07
switch(my_fog)
{
@@ -4630,6 +4701,11 @@ void
if(master_notation_file->GetEntry(starting_page_name, "fog" ,&TempStringPtr))
{
sscanf(TempStringPtr, "%f %f %f %f %f", &fogNear, &fogFar, &fogRed, &fogGreen, &fogBlue);
// BT (task #63): BT_FOG="near far r g b" overrides the AUTHORED fog for
// tuning/demo (works on the main branch now, not just the no-fog fallback).
if (const char *bf = getenv("BT_FOG"))
if (!(bf[0]=='0' && bf[1]==0))
sscanf(bf, "%f %f %f %f %f", &fogNear, &fogFar, &fogRed, &fogGreen, &fogBlue);
searchLightFogRed = fogRed;
searchLightFogGreen = fogGreen;
searchLightFogBlue = fogBlue;
@@ -4642,6 +4718,9 @@ void
noSearchLightFogFar = fogFar;
currentFogNear = fogNear;
currentFogFar = fogFar;
DEBUG_STREAM << "[fog] resolved page='" << starting_page_name << "' near=" << fogNear
<< " far=" << fogFar << " rgb=(" << fogRed << "," << fogGreen << ","
<< fogBlue << ")\n" << std::flush;
// Force a 0-0 black fog on startup
// dpl_SetViewFog(dplMainView, dpl_fog_type_pixel_lin, 0.0, 0.0, 0.0, 0.01, 0.05);
//TODO: for fog testing, just set the values here
@@ -4655,13 +4734,15 @@ void
else
{
//
// BT port (task #20): the SHIPPED maps define no fog entry at all (verified:
// no 'fog' key in any .MAP source nor in BTL4.RES strings) -- so the world
// edge and the void beyond are fully visible, which no pod player ever saw
// (cockpit FOV + mission design kept views inward). Provide a period-
// plausible default curtain: color matched to the horizon so geometry fades
// into the void seamlessly. env BT_FOG="near far r g b" overrides;
// BT_FOG=0 disables.
// CORRECTION (task #63): task #20's claim "the SHIPPED maps define no fog"
// was WRONG -- it looked in .MAP/.RES, but the arcade sourced fog from the
// DPL env INI (L4DPLCFG = content/BTDPL.INI), which authors a fog= (usually
// nosearchlightfog= too) key on EVERY map/time/weather leaf page. The main
// (if) branch above handles the real authored fog; this else branch is only
// reached by a page that genuinely lacks a fog= key (e.g. a *_default page
// whose weather compare is commented out AND has no own fog). Keep a period-
// plausible fallback curtain there so such a page still fades to the horizon;
// BT_FOG="near far r g b" overrides, BT_FOG=0 disables.
//
float fN = 150.0f, fF = 520.0f, fR = 0.44f, fG = 0.44f, fB = 0.62f;
int fogOn = 1;
@@ -5100,7 +5181,11 @@ void
}
else if(strcmp(target_string,"weather") == 0)
{
compare_strings[token_count] = mission->GetMissionWeather();
// BT (task #63): BT_WEATHER overrides the egg's authored weather so
// clear/fog/soup fog curtains can be A/B'd without re-authoring eggs
// (the shipped eggs pin weather=clear). Authentic default = the egg.
const char *wov = getenv("BT_WEATHER");
compare_strings[token_count] = (wov && *wov) ? wov : mission->GetMissionWeather();
// std::cout<<target_string<<" = "<<compare_strings[token_count]<<"\n";
}
else if(strcmp(target_string,"scenario") == 0)
@@ -8124,6 +8209,47 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
}
mDevice->SetRenderState(D3DRS_FOGSTART, *((DWORD*)(&currentFogNear)));
mDevice->SetRenderState(D3DRS_FOGEND, *((DWORD*)(&currentFogFar)));
// BT (task #63): the arcade fog was EYE-SPACE linear (dpl_fog_type_pixel_lin).
// The port left D3DRS_FOGTABLEMODE = D3DFOG_LINEAR (table/PIXEL fog), whose fog
// factor comes from the perspective-NONLINEAR z-buffer -- and without a WFOG cap
// FOGSTART/END are z-buffer [0,1] units, so the authored 90..1100 range gave a
// fog factor of ~1 (NO fog) across the entire scene: fog was wired but invisible.
// Use VERTEX fog: it is computed per-vertex in EYE SPACE, so FOGSTART/END are the
// authored world near/far and the haze curtain renders across the real range.
// This is the world-geometry pass (SHOULD be fogged; sky/cockpit passes manage
// their own). BT_FOGMODE=table = the old z-based mode (A/B); BT_FOGMODE=off.
{
static const char *s_fm = getenv("BT_FOGMODE");
static const int s_fOff = (s_fm && strcmp(s_fm, "off") == 0) ? 1 : 0;
// Fog MODEL. The arcade used per-PIXEL linear fog (dpl_fog_type_pixel_lin) --
// smooth. D3D fixed-function per-pixel (TABLE) fog is only eye-space (correct
// over the authored near/far) when the GPU exposes D3DPRASTERCAPS_WFOG; without
// it, table fog reads the NONLINEAR z-buffer and the 90..1100 range collapses
// to no fog. Auto-detect: WFOG -> table (smooth, faithful); else VERTEX fog
// (eye-space but per-vertex -> splotchy on coarse terrain tris). s_mode:
// 1=table, 0=vertex. BT_FOGMODE=table|vertex|off forces.
static int s_mode = -1;
if (s_mode < 0)
{
if (s_fm && strcmp(s_fm, "table") == 0) s_mode = 1;
else if (s_fm && strcmp(s_fm, "vertex") == 0) s_mode = 0;
else
{
D3DCAPS9 caps; memset(&caps, 0, sizeof(caps));
s_mode = (SUCCEEDED(mDevice->GetDeviceCaps(&caps))
&& (caps.RasterCaps & D3DPRASTERCAPS_WFOG)) ? 1 : 0;
}
DEBUG_STREAM << "[fog] model=" << (s_mode ? "table/WFOG (per-pixel, smooth)"
: "vertex (per-vertex; splotchy on coarse tris)")
<< " (BT_FOGMODE=table|vertex|off overrides)\n" << std::flush;
}
mDevice->SetRenderState(D3DRS_FOGENABLE, s_fOff ? FALSE : TRUE);
if (!s_fOff)
{
mDevice->SetRenderState(D3DRS_FOGVERTEXMODE, s_mode ? D3DFOG_NONE : D3DFOG_LINEAR);
mDevice->SetRenderState(D3DRS_FOGTABLEMODE, s_mode ? D3DFOG_LINEAR : D3DFOG_NONE);
}
}
mDevice->SetRenderState(D3DRS_ZWRITEENABLE, true);
mDevice->SetRenderState(D3DRS_ALPHABLENDENABLE, false);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1);
@@ -8451,6 +8577,37 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
const double presentMs = (double)(_rt2.QuadPart - _rt1.QuadPart) * 1000.0 / (double)_rf.QuadPart;
static double sAcc = 0.0, sMaxD = 0.0, sMaxP = 0.0; static int sFrames = 0;
sAcc += drawMs + presentMs; ++sFrames;
// RENDER-frame heading probe (BT_RENDHDG): count render frames that redraw
// the SAME peer heading (stale between sim updates) vs frames where it moved.
// sameHeading >> moved => the peer sim updates slower than the render draws,
// so the rotation stutters regardless of how smooth each sim step is.
if (getenv("BT_RENDHDG"))
{
extern volatile float gBTReplRenderYaw;
static float sPrevY = -999.0f, sMaxStep = 0.0f, sSumStep = 0.0f;
static int sN = 0, sBack = 0; static double sHAcc = 0.0;
const float y = gBTReplRenderYaw;
if (sPrevY > -900.0f)
{
float d = y - sPrevY; // per-RENDER-frame heading change
if (d > 3.14159f) d -= 6.28319f; // unwrap
if (d < -3.14159f) d += 6.28319f;
const float ad = (d < 0.0f) ? -d : d;
sSumStep += ad; if (ad > sMaxStep) sMaxStep = ad;
if (d > 0.0f) ++sBack; // spin is -yaw here; +step == backward
++sN;
}
sPrevY = y; sHAcc += drawMs + presentMs;
if (sHAcc >= 1000.0 && sN > 0)
{
const float avg = sSumStep / sN;
DEBUG_STREAM << "[rendhdg] avgStep=" << avg << " maxStep=" << sMaxStep
<< " max/avg=" << (avg > 1e-6f ? sMaxStep / avg : 0.0f)
<< " backwardFrames=" << sBack << "/" << sN
<< " (smooth: max/avg~1, backward~0)\n" << std::flush;
sMaxStep = 0.0f; sSumStep = 0.0f; sN = 0; sBack = 0; sHAcc = 0.0;
}
}
if (drawMs > sMaxD) sMaxD = drawMs;
if (presentMs > sMaxP) sMaxP = presentMs;
if (drawMs + presentMs > 150.0)
+66 -3
View File
@@ -6,6 +6,11 @@
// (Authentic path = the gyro-driven eye-joint DCS chain -- deferred.)
float gBTEyeBobY = 0.0f;
float gBTEyeSwayX = 0.0f; // lateral weight-shift (the walk "swagger"), same source
// task #58: the player torso's live twist (rad), published by mech4.cpp's HUD
// tick. NOT consumed by the eye anymore -- the cockpit eye inherits the twist
// through the jointtorso HingeRenderable in the draw traversal (see the note in
// DPLEyeRenderable::Execute). Kept published for diagnostics/correlation.
float gBTEyeTwist = 0.0f;
#pragma hdrstop
#include "l4vidrnd.h"
@@ -1340,6 +1345,21 @@ void
//
if(oldHinge != *myHinge)
{
// task #58 probe (BT_HINGE_LOG): which hinge renderables actually see
// their joint move (standing + BT_FORCE_TORSO => only the torso hinge
// and any doors change). kids = child renderables that would inherit
// the rotation through the stack.
{
static const int s_hl = getenv("BT_HINGE_LOG") ? 1 : 0;
static int s_hln = 0;
if (s_hl && (s_hln++ % 60) == 0)
DEBUG_STREAM << "[hinge] this=" << (void*)this
<< " hinge=" << (void*)myHinge
<< " rot=" << (float)(Radian)(myHinge->rotationAmount)
<< " kids=" << (int)(End() - Enumerate())
<< " par=" << (void*)GetParentProbe()
<< "\n" << std::flush;
}
oldHinge = *myHinge;
hingeOffsetMatrix.BuildIdentity();
//#if SINGLE_AXIS_HINGE
@@ -5604,6 +5624,24 @@ void
// that assumed +Z=forward/+Y=up (which mis-framed the cockpit); the look/up axes now
// fall out of the eye's own basis. pos/at/up above are kept only for the aim-boresight.
D3DXMATRIX eyeW = mat4.ToD3DMatrix();
// task #58 NOTE (torso twist vs the camera): the COCKPIT eye needs
// NO explicit twist compose -- it executes inside the mech's draw
// traversal under jointtorso's HingeRenderable (Torso::PushTwist ->
// Joint::SetRotation -> hinge rotationAmount; HingeRenderable
// multiplies the live hinge into the matrix stack), so `prev` above
// already carries the twist and the view yaws authentically with
// the canopy. VERIFIED live: with the real jointtorso swept, the
// active cockpit [eyefwd] swings ~60 deg and the torso hinge sits
// in the eye's parent chain (kids=2). An explicit
// W' = W*E^-1*R_y(twist)*E compose was briefly added here and
// DOUBLE-ROTATED the cockpit view (2x view vs 1x canopy: "the HUD
// passes through the cockpit frame") and swung the CHASE camera
// (parented on the root, same class) with the twist ("torso fixed,
// legs twisting"). Both "eye is frozen" measurements that motivated
// it were instrumentation artifacts: (1) the chase eye was the
// active camera in the headless default view; (2) BT_FORCE_TORSO
// used to redirect the sweep into the SHADOW hinge (see the hook's
// trap note in torso.cpp).
D3DXMATRIX view;
D3DXMatrixInverse(&view, NULL, &eyeW);
@@ -5618,6 +5656,30 @@ void
D3DXMatrixInverse(&view, NULL, &eyeW);
}
}
// TWIST-FOLLOW PROBE (task #58, BT_EYE_LOG): does the ACTIVE eye's
// forward yaw with the torso twist? Correlate with [torso] twist.
// Also print the STACK TOP the eye composed with (prev) -- its
// translation tells whether the eye executed inside the full
// parent-chain traversal (t ~= eye rest position) or with a bare
// stack (t == entity origin / zero).
{
static const int s_eyeLog = getenv("BT_EYE_LOG") ? 1 : 0;
static int s_eyeLogN = 0;
if (s_eyeLog && myRenderer->mCamera == this && (s_eyeLogN++ % 120) == 0)
{
DEBUG_STREAM << "[eyefwd] this=" << (void*)this
<< " fwd=(" << eyeFwdW.x << "," << eyeFwdW.y
<< "," << eyeFwdW.z << ") stackT=(" << prev(3,0) << ","
<< prev(3,1) << "," << prev(3,2) << ") chain:";
HierarchicalDrawComponent *p = GetParentProbe();
for (int pc = 0; p != NULL && pc < 8; ++pc)
{
DEBUG_STREAM << " " << (void*)p;
p = p->GetParentProbe();
}
DEBUG_STREAM << "\n" << std::flush;
}
}
if (dbgEyeExec < 8)
{
DEBUG_STREAM << "[EYECHK] eyeW=(" << eyeW._41 << "," << eyeW._42 << "," << eyeW._43
@@ -5650,9 +5712,10 @@ void
// on a slope (body pitched ~8 deg to conform to the ground) aim
// its pick ray INTO the ground, short of a distant target
// (mechPicks=0). Level the boresight: drop the view direction's
// pitch and rebuild an upright basis (world +Y up). The reticle
// X still carries the torso twist (BTTwistToReticleX); the reticle
// Y carries any aim elevation. Falls back to the raw basis only
// pitch and rebuild an upright basis (world +Y up). The torso
// twist rides IN eyeFwdW now (task #58 eye compose above) --
// yaw survives the leveling, so the centered crosshair's pick
// ray follows the twist. Falls back to the raw basis only
// if the view is (degenerately) near-vertical.
// Derive the boresight from the AUTHORITATIVE view matrix
// (eyeFwdW = the world look direction from the inverse view),
+3
View File
@@ -69,6 +69,9 @@ public:
virtual bool IsStatic() { return false; }
// task #58 diagnostics: expose the parent link so probes can walk the tree.
HierarchicalDrawComponent *GetParentProbe() const { return m_parent; }
d3d_OBJECT *GetDrawObj() { return this->graphicalObject; }
// Swap this component's drawable in place. Execute() re-reads graphicalObject
// every frame, so changing it makes the segment draw a different mesh next
+11
View File
@@ -185,6 +185,17 @@ int BTAmmoBinFeeding(void *bin)
return (bin != 0 && ((AmmoBin *)bin)->ammoAlarm.Level() == AmmoBin::Feeding) ? 1 : 0;
}
// The round's GameModel resource ID (ammoModelFile @0x1E8, word 0x7A). In the
// arcade the MissileLauncher seeds each spawned Missile's model from AmmoBin+0x1e8
// (part_013.c:8778) and the Missile ctor reads SplashRadius from that model's
// GameModel record +0x50 (part_013.c:10184). The port re-expresses missiles as
// local flying rounds, so BTResolveSplashRadius (mech4.cpp) walks THIS path to
// recover the authored blast radius. Complete-type read of the named member.
int BTAmmoRoundModelResource(void *bin)
{
return (bin != 0) ? ((AmmoBin *)bin)->ammoModelFile : 0;
}
int AmmoBin::FeedAmmo()
{
if (simulationState == 1) // this+0x40 == 1 (destroyed)
+1
View File
@@ -80,6 +80,7 @@
friend struct AmmoBinLayoutCheck; // compile-time offset locks (ammobin.cpp)
friend int *BTAmmoBinCountPtr(void *bin); // panel ammo counter bridge (Streak fix)
friend int BTAmmoBinFeeding(void *bin); // panel reload-state bridge
friend int BTAmmoRoundModelResource(void *bin); // splash-radius resolve (round GameModel +0x50)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Shared Data Support
//
+15
View File
@@ -1411,6 +1411,21 @@ void SubsystemCluster::Execute()
failedState = (BTSubsystemDamageLevelOf(subsystem) >= 1.0f);
}
if (getenv("BT_TEMPBAR_LOG"))
{
Scalar *t = (Scalar *)AttributePointerOf(subsystem, "CurrentTemperature");
Scalar *dg = (Scalar *)AttributePointerOf(subsystem, "DegradationTemperature");
Scalar *fl = (Scalar *)AttributePointerOf(subsystem, "FailureTemperature");
static int s_tbN = 0;
if (t != 0 && ((++s_tbN) % 30) == 0)
DEBUG_STREAM << "[tempbar] cluster EXECUTING "
<< (subsystem ? ((Subsystem *)subsystem)->GetName() : "?")
<< " T=" << *t
<< " degT=" << (dg ? *dg : -1.0f)
<< " failT=" << (fl ? *fl : -1.0f)
<< " drawState=" << GetDrawState() << "\n" << std::flush;
}
int state = GetDrawState();
if (state != previousDrawState)
{
+14 -5
View File
@@ -52,7 +52,8 @@
// FUN_0044a5b4/5dc GraphicsViewRecord ctor/dtor
// FUN_0044a650/630 GraphicsViewRecord Erase / Reset
// DAT_00524e20 DebugStream (warning channel)
// 0x50e3ec / 0x50e604 heat-subsystem class-derivation tables
// 0x50e3ec / 0x50e604 heat class-derivation tags (HeatableSubsystem /
// HEATWATCHER -- task #57 relabel; was "HeatSink")
//
// GraphicsView vtable slots used below (this+0x48):
// +0x08 SetExtent +0x10 SetOrigin +0x18 SetColor +0x24 MoveTo
@@ -363,9 +364,12 @@ void
//
// HeatConnection -- @004c3664 ctor / @004c3720 Transfer.
// ctor: source = the heat subsystem; flag@0x14 records whether it is the
// "primary" heat class (IsDerivedFrom 0x50e3ec) vs. the alternate (0x50e604);
// destination@0x18. Transfer: no subsystem -> 100 ; subsystem operational
// (+0x40 == 1) -> 100 ; else Round( Resolve(subsys+0x114 currentTemperature) ).
// "primary" heat class (IsDerivedFrom 0x50e3ec HeatableSubsystem) vs. the
// alternate (0x50e604 = HEATWATCHER, task #57 relabel); destination@0x18.
// Transfer: no subsystem -> 100 ; subsystem operational (+0x40 == 1) -> 100 ;
// else Round( Resolve(subsys+0x114) ) -- NB for a Heatable that offset is
// currentTemperature, for a WATCHER it is watchedLink (the flag@0x14 likely
// picks the read; the watcher path is unverified [T4]).
// Used by ColorMapperHeat -- THIS is the heat gauge's data feed.
//
// All Transfer bodies are reproduced inside the owning gauge's notes; the
@@ -925,8 +929,13 @@ ColorMapperHeat::ColorMapperHeat(
// The subsystem MUST be heat-bearing (one of the two heat class
// derivation tables) or the data feed is meaningless.
//
extern int BTIsHeatWatcher(Subsystem *sub); // heatfamily_reslice.cpp bridge
if (!subsystem->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()) && // FUN_0041a1a4 (0x50e3ec)
!subsystem->IsDerivedFrom(*HeatSink::GetClassDerivations())) // (0x50e604)
!BTIsHeatWatcher(subsystem)) // (0x50e604 = HEATWATCHER -- task #57 relabel:
// the old "HeatSink" reading was redundant after
// the HeatableSubsystem test; the binary's
// alternate branch is the disjoint WATCHER family
// (ammo bins / torso carry a mirrored heatAlarm))
{
Verify(False); // FUN_0040385c -- "Bad subsystem type" BTL4GAUG.CPP:0x68a
}
+2 -1
View File
@@ -203,7 +203,8 @@
// ColorMapperHeat -- the cockpit heat tint. ANCHOR @004c3f6c.
// Connection: @004c3664 (reads HeatableSubsystem::currentTemperature@0x114
// as a 0..100 percentage). The named subsystem must derive from a heat
// class (IsDerivedFrom 0x50e3ec or 0x50e604) or the ctor asserts (line 0x68a).
// class (IsDerivedFrom 0x50e3ec HeatableSubsystem or 0x50e604 HEATWATCHER
// -- task #57 relabel) or the ctor asserts (line 0x68a).
//
class ColorMapperHeat :
public ColorMapper
+6 -1
View File
@@ -349,7 +349,12 @@ L4MechControlsMapper::MessageHandlerSet&
//
// (1) Full-throttle detent.
//
if (Abs(throttlePosition - 1.0f) <= 0.05f) // _DAT_004d1ac0 / _DAT_004d1ac4
// NB: the Abs() macro (STYLE.H:118) is unparenthesized -- Abs(throttlePosition
// - 1.0f) mis-expands to -(throttlePosition + 1.0f) on the false branch (always,
// since throttle <= 1), which is <= 0.05 UNCONDITIONALLY -> the detent used to
// snap throttle to full every frame. Diff into a temp so the macro sees one token.
const Scalar _thrOff = throttlePosition - 1.0f;
if (((_thrOff < 0.0f) ? -_thrOff : _thrOff) <= 0.05f) // _DAT_004d1ac0 / _DAT_004d1ac4
{
throttlePosition = 1.0f; // @0x11c
}
+26 -6
View File
@@ -697,9 +697,16 @@ void
// Top-down: keep heading only. Extract the yaw, build a
// yaw-only quaternion, SET the rotation block from it.
//
EulerAngles euler;
euler = *currentAngularPointer; // FUN_0040954c
Scalar heading = euler.yaw * HeadingHalf; // * 0.5f
YawPitchRoll euler; // FIX: YawPitchRoll (yaw-first),
euler = *currentAngularPointer; // not EulerAngles -- the latter folds
Scalar heading = (Scalar)euler.yaw * HeadingHalf; // at +/-pi under yaw. * 0.5f half-angle
if (getenv("BT_RADAR_LOG")) {
EulerAngles oldE; oldE = *currentAngularPointer; // old buggy path, A/B
static int s_rv = 0;
if ((s_rv++ % 20) == 0)
DEBUG_STREAM << "[radar-view] euler.yaw=" << (Scalar)oldE.yaw
<< " ypr.yaw=" << (Scalar)euler.yaw << " (rad)\n" << std::flush;
}
SinCosPair sc;
sc = Radian(heading); // FUN_00408328
Quaternion yaw(0.0f, sc.sine, 0.0f, sc.cosine); // FUN_00409948
@@ -965,12 +972,25 @@ void
Scalar sinH = 0.0f, cosH = 1.0f;
if (currentAngularPointer != NULL)
{
EulerAngles e;
e = *currentAngularPointer; // Quaternion -> EulerAngles (operator=)
// FIX (user-reported: radar rotation flops every 180deg): the MUNGA
// EulerAngles(Quaternion) decomposition is AMBIGUOUS for a yawing mech --
// as yaw sweeps past +/-pi the quaternion double-cover flips it onto a
// pitch=roll=pi branch, so euler.yaw REVERSES (the radar spins back).
// YawPitchRoll applies yaw FIRST -> a clean continuous 360deg heading.
// Same fix the compass HeadingPointer already uses (btl4gaug.cpp:2185).
YawPitchRoll ypr;
ypr = *currentAngularPointer;
SinCosPair scH;
scH = Radian(e.yaw); // FUN_00408328
scH = Radian((Scalar)ypr.yaw);
sinH = scH.sine;
cosH = scH.cosine;
if (getenv("BT_RADAR_LOG")) {
EulerAngles e; e = *currentAngularPointer; // old (buggy) path, for A/B
static int s_rn = 0;
if ((s_rn++ % 20) == 0)
DEBUG_STREAM << "[radar] euler.yaw=" << (Scalar)e.yaw
<< " ypr.yaw=" << (Scalar)ypr.yaw << " (rad)\n" << std::flush;
}
}
Scalar rx = delta.x * cosH + delta.z * sinH;
Scalar rz = -delta.x * sinH + delta.z * cosH;
+129
View File
@@ -49,6 +49,9 @@
#if !defined(MECHWEAP_HPP)
# include <mechweap.hpp> // MechWeapon::GetClassDerivations (reticle pip)
#endif
#if !defined(MECHDMG_HPP)
# include <mechdmg.hpp> // Mech__DamageZone::segmentIndex (the @004d097c dispatcher)
#endif
#if !defined(NOTATION_HPP)
# include <notation.hpp>
#endif
@@ -368,6 +371,22 @@ HierarchicalDrawComponent*
LinearMatrix offset_matrix;
offset_matrix = segment->GetBaseOffset(); // [0x74]
// task #58 diagnostics (BT_SEG_DUMP): the draw-tree topology -- which
// segment carries which joint, and who parents whom.
if (getenv("BT_SEG_DUMP"))
{
int ji = segment->GetJointIndex();
DEBUG_STREAM << "[seg] " << segment->GetIndex()
<< " '" << (const char *)segment->GetName()
<< "' parent=" << (segment->GetParent()
? segment->GetParentIndex() : -1)
<< " joint=" << ji
<< " jtype=" << (ji == -1 ? -1
: (int)joint_subsystem->GetJoint(ji)->GetJointType())
<< " site=" << (int)(segment->IsSiteSegment() != 0)
<< "\n" << std::flush;
}
//
// Parent renderable: root if the segment has no parent, else the
// renderable already built for its parent segment.
@@ -866,6 +885,116 @@ void BTRebuildMechModel(Entity *entity)
//
//#############################################################################
// StartEntityEffectImplementation @004d097c (coverage-audit reconstruction)
//#############################################################################
//
// The per-zone EFFECT DISPATCHER -- the target of the whole authentic chain:
// MechDeathHandler's class-5 -> RendererManager::StartEntityEffect ->
// Renderer::StartEntityEffectMessageHandler [T0: resolves the GameModel +
// applies the ExplosionResourceTable graphic-state remap] -> this virtual.
// The binary body (@004d097c, part_014.c:5839):
// 1. resolve the zone -> its transform (+0x74, row 3 = the world position)
// and its VIDEO INDEX (+0xc4->+0xc0) = the segment slot;
// 2. look the entity up in the renderer's per-entity tree (renderer+0x3a4,
// == our mMechRenderTrees) -> the segment's DCS = the effect SOCKET;
// 3. SearchList(resource, VideoModelResourceType) -> walk the video-object
// records; atoi(name) < 1000 WARNS (the authentic gate: only INDIE/psfx
// ids attach per-zone), >= 1000 starts the effect ON the socket, tagged
// with the owning player (+0x190 -> +0x1e0) for the stop-all sweep.
// Port mapping: the socket attachment = BTStartPfxAttached (the emitter rides
// the segment via BTResolveSegmentWorld each frame); the tag = the entity
// (BTStopEntityPfx kills by entity on respawn). Layout access is by NAMED
// members only (zone->segmentIndex; the binary micro-offsets differ on our
// compiled classes -- the databinding trap).
//
void
BTL4VideoRenderer::StartEntityEffectImplementation(
Entity *entity,
DamageZone *damage_zone,
ResourceDescription::ResourceID resource_ID
)
{
if (entity == NULL || damage_zone == NULL)
{
DEBUG_STREAM << "StartEntityEffectImplementation: no entity/zone" << std::endl;
return;
}
// 1. the zone's segment slot (binary +0xc4->+0xc0; our named member)
int seg_index = ((Mech__DamageZone *)damage_zone)->EffectSegmentIndex();
// 2. the segment's world position + frame (binary: zone transform +0x74)
extern int BTResolveSegmentWorld(void *entity, int seg_index, float *pos3, float *rows9);
float pos[3], rows[9];
if (!BTResolveSegmentWorld(entity, seg_index, pos, rows))
{
DEBUG_STREAM << "StartEntityEffectImplementation: entity has no segment table" << std::endl;
return;
}
// 3. the effect resource's VIDEO MODEL (type 10) -- the effect-number list
Check(application);
ResourceDescription *res = application->GetResourceFile()->SearchList(
resource_ID, ResourceDescription::VideoModelResourceType);
if (res == NULL)
{
DEBUG_STREAM << "StartEntityEffectImplementation: " << (long)resource_ID
<< " has no video resource" << std::endl;
return;
}
res->Lock();
// VideoModel payload [T0 L4VIDEO.h L4VideoObject, RES byte-verified]:
// int32 count + count x 32-byte records {char name[15]; pad; int type;
// int modes; float blinkPeriod; float pctOn} -- atoi(name) = the effect id.
const unsigned char *pay = (const unsigned char *)res->resourceAddress;
int count = *(const int *)pay;
if (count < 0 || count > 16)
count = 0; // malformed -- refuse
const unsigned char *rec = pay + 4;
for (int i = 0; i < count; ++i, rec += 32)
{
char name[16];
memcpy(name, rec, 15);
name[15] = 0;
int fx = atoi(name);
if (fx < 1000)
{
// authentic gate (@004d097c: "< 1000" warns) -- per-zone effects
// are INDIE/psfx-band only
DEBUG_STREAM << "StartEntityEffectImplementation: non-INDIE effect "
<< fx << " in zone effect list" << std::endl;
continue;
}
extern void BTStartPfxAttached(int, void *, int, float, float, float, const float *);
BTStartPfxAttached(fx - 1000, (void *)entity, seg_index,
pos[0], pos[1], pos[2], rows);
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[zonefx] entity " << entity->GetEntityID()
<< " seg " << seg_index << " psfx " << (fx - 1000)
<< " at(" << pos[0] << "," << pos[1] << "," << pos[2] << ")" << std::endl;
}
res->Unlock();
}
//
// StopAllEntityEffectsImplementation @004d0c14 -- the respawn cleanup: the
// binary kills every effect tagged (playerIdx<<16 .. |0xffff); the port kills
// every emitter tagged to the entity.
//
void
BTL4VideoRenderer::StopAllEntityEffectsImplementation(Entity *entity)
{
if (entity == NULL)
{
DEBUG_STREAM << "StopAllEntityEffectsImplementation: no entity" << std::endl;
return;
}
extern void BTStopEntityPfx(void *owner);
BTStopEntityPfx((void *)entity);
}
//#############################################################################
// SwapToWreck (ExplosionScripts effect 104, reconstructed)
//#############################################################################
+20
View File
@@ -623,6 +623,26 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
Logical
TestInstance() const;
//
// The per-zone EFFECT DISPATCHER (@004d097c, coverage-audit discovery
// 2026-07-13) -- the missing target of the engine's whole authentic
// StartEntityEffect chain (MechDeathHandler class-5 -> RendererManager
// -> Renderer::StartEntityEffectMessageHandler [T0, applies the
// ExplosionResourceTable graphic-state remap] -> THIS virtual).
// Resolves the zone's SEGMENT socket, walks the effect resource's
// VideoModel (type 10), and starts each >=1000 effect ATTACHED to the
// segment (the smoke follows the walking mech); <1000 warns
// (byte-faithful to the binary's gate). StopAll kills every effect
// tagged to the entity (the respawn cleanup, @004d0c14 analog).
//
virtual void
StartEntityEffectImplementation(
Entity *entity,
DamageZone *damage_zone,
ResourceDescription::ResourceID resource_ID); // @004d097c
virtual void
StopAllEntityEffectsImplementation(Entity *entity); // @004d0c14
//
// Material substitution (mirrors RPL4VideoRenderer).
//
+111 -61
View File
@@ -937,79 +937,111 @@ void
if (enemy_res)
{
enemy_res->Lock();
const ResourceDescription::ResourceID enemyResID = enemy_res->resourceID;
// Place it ahead of the player's drop ALONG THE SPAWN FACING (the mech
// COUNT: BT_SPAWN_ENEMY is read as a count -- "1" (or any truthy value)
// drops one dummy; "2"+ CLUSTERS extra dummies laterally so a bystander
// sits within a missile's SplashRadius (30) of the primary -- the rig for
// eyeballing splash damage (task #62): shoot ONE, watch the OTHER take
// collateral. Clamped to a sane range.
int enemyCount = atoi(getenv("BT_SPAWN_ENEMY"));
if (enemyCount < 1) enemyCount = 1; // non-numeric truthy -> 1
if (enemyCount > 8) enemyCount = 8;
// Base origin: 120 ahead of the player ALONG THE SPAWN FACING (the mech
// faces local -Z; rotate that axis into world by the spawn orientation).
// The old fixed "z -= 120" assumed the bring-up drive's forced heading=0;
// the real-controls drive keeps the authentic spawn orientation, so the
// dummy must follow the actual facing or the auto-walk leaves it behind.
Origin enemy_origin = mech_location;
Origin base_origin = mech_location;
UnitVector spawnZ;
{
AffineMatrix facing;
facing.BuildIdentity();
facing = mech_location.angularPosition; // rotation from the spawn pose
UnitVector spawnZ;
facing.GetFromAxis(Z_Axis, &spawnZ); // local Z basis in world
enemy_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z
enemy_origin.linearPosition.y -= spawnZ.y * 120.0f;
enemy_origin.linearPosition.z -= spawnZ.z * 120.0f;
base_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z
base_origin.linearPosition.y -= spawnZ.y * 120.0f;
base_origin.linearPosition.z -= spawnZ.z * 120.0f;
// FACE THE PLAYER: copying the spawn pose left BOTH mechs facing
// the same way (the player stared at the enemy's back). Flip the
// enemy's yaw 180 deg about Y so the two face each other. Engine
// yaw convention (MATRIX.cpp:196-209): the Z basis = (sin y, 0,
// cos y) -> yaw = atan2(z.x, z.z); enemy yaw = that + pi.
Scalar spawnYaw = (Scalar)atan2((double)spawnZ.x, (double)spawnZ.z);
enemy_origin.angularPosition =
base_origin.angularPosition =
EulerAngles(0.0f, spawnYaw + 3.14159265f, 0.0f);
}
Mech::MakeMessage
create_enemy(
Mech::MakeMessageID,
sizeof(Mech::MakeMessage),
host_manager->MakeUniqueEntityID(),
(Entity::ClassID)Mech::MechClassID,
EntityID::Null,
enemy_res->resourceID,
Mech::DefaultFlags,
enemy_origin,
Motion::Identity,
Motion::Identity,
playerMission->GetBadgeName(),
playerMission->GetColorName(),
((BTMission *)playerMission)->GetPatchName());
enemy_res->Unlock();
Mech *enemy = Mech::Make(&create_enemy);
if (enemy)
// Right vector in the ground plane (perp to the forward XZ): cluster the
// extra dummies sideways so a hit on the primary catches the bystander.
Vector3D rightv(spawnZ.z, 0.0f, -spawnZ.x);
{
Register_Object(enemy);
// Mark it a VALID master so Entity::Dispatch delivers messages
// SYNCHRONOUSLY (Receiver::Receive) instead of posting them as deferred
// "entity invalid" events that never fire -- otherwise TakeDamage never
// lands. (The player gets validated via MakeViewpointEntity's CheckLoad
// handshake; a manually-spawned entity must set it itself. Its resources
// are already loaded -- it renders + its zones are built.) Also force
// pre-run + interest so it ticks like a live entity.
enemy->SetValidFlag();
enemy->SetPreRunFlag();
if (enemy->interestCount == 0) enemy->interestCount = 1;
// GROUND MODEL (task #15): a MASTER mech needs a CollisionAssistant
// for GetCurrentCollisions (the engine iterates it unchecked,
// MOVER.cpp:894). The player gets one in MakeViewpointEntity
// (btl4app.cpp:591); this dummy is a master too, so start its own
// -- without it the authentic ground block skips its collision half.
enemy->StartCollisionAssistant();
gEnemyMech = enemy; // the player's targeting step locks onto this
DEBUG_STREAM << "[enemy] spawned target mech at ("
<< enemy_origin.linearPosition.x << ", "
<< enemy_origin.linearPosition.y << ", "
<< enemy_origin.linearPosition.z << ")\n" << std::flush;
Scalar rl = (Scalar)sqrt((double)(rightv.x*rightv.x + rightv.z*rightv.z));
if (rl < 1e-4f) { rightv.x = 1.0f; rightv.z = 0.0f; }
else { rightv.x /= rl; rightv.z /= rl; }
}
else
for (int ei = 0; ei < enemyCount; ++ei)
{
DEBUG_STREAM << "[enemy] Mech::Make returned NULL\n" << std::flush;
Origin enemy_origin = base_origin;
// #0 at the primary spot; extras step +/-25u (inside SplashRadius 30).
Scalar off = (ei == 0) ? 0.0f
: 25.0f * (Scalar)(((ei + 1) / 2)) * ((ei & 1) ? 1.0f : -1.0f);
enemy_origin.linearPosition.x += rightv.x * off;
enemy_origin.linearPosition.z += rightv.z * off;
Mech::MakeMessage
create_enemy(
Mech::MakeMessageID,
sizeof(Mech::MakeMessage),
host_manager->MakeUniqueEntityID(),
(Entity::ClassID)Mech::MechClassID,
EntityID::Null,
enemyResID,
Mech::DefaultFlags,
enemy_origin,
Motion::Identity,
Motion::Identity,
playerMission->GetBadgeName(),
playerMission->GetColorName(),
((BTMission *)playerMission)->GetPatchName());
Mech *enemy = Mech::Make(&create_enemy);
if (enemy)
{
Register_Object(enemy);
// Mark it a VALID master so Entity::Dispatch delivers messages
// SYNCHRONOUSLY (Receiver::Receive) instead of posting them as deferred
// "entity invalid" events that never fire -- otherwise TakeDamage never
// lands. (The player gets validated via MakeViewpointEntity's CheckLoad
// handshake; a manually-spawned entity must set it itself. Its resources
// are already loaded -- it renders + its zones are built.) Also force
// pre-run + interest so it ticks like a live entity.
enemy->SetValidFlag();
enemy->SetPreRunFlag();
if (enemy->interestCount == 0) enemy->interestCount = 1;
// GROUND MODEL (task #15): a MASTER mech needs a CollisionAssistant
// for GetCurrentCollisions (the engine iterates it unchecked,
// MOVER.cpp:894). The player gets one in MakeViewpointEntity
// (btl4app.cpp:591); this dummy is a master too, so start its own
// -- without it the authentic ground block skips its collision half.
enemy->StartCollisionAssistant();
if (ei == 0)
gEnemyMech = enemy; // the player's targeting step locks onto the primary
DEBUG_STREAM << "[enemy] spawned dummy #" << ei
<< " id=" << enemy->GetEntityID()
<< (ei == 0 ? " (PRIMARY/direct-target)" : " (bystander/splash)")
<< " at ("
<< enemy_origin.linearPosition.x << ", "
<< enemy_origin.linearPosition.y << ", "
<< enemy_origin.linearPosition.z << ")\n" << std::flush;
}
else
{
DEBUG_STREAM << "[enemy] Mech::Make returned NULL (#" << ei << ")\n" << std::flush;
}
}
}
else
@@ -1494,12 +1526,31 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat
{
return;
}
BTPlayer *local_player = (BTPlayer *)application->GetMissionPlayer();
if (local_player != 0)
// OBSERVED-KILL credit (MP KILLS fix, 2026-07-13; the symmetric twin of
// BTPlayerCountObservedDeath): every node maintains LOCAL score copies,
// so the kill must land on the KILLER's player-link object -- the same
// object the PilotList gauge reads (the roster resolves pilots via the
// mechs' GetPlayerLink()). The old GetMissionPlayer() dispatch credited
// a copy the scoreboard never displays (kills stuck at 0 while deaths --
// which already flow through the player link -- counted; user-reported).
// Resolving the killer from the victim's lastInflictingID also credits
// the REMOTE player's local copy when THEY score ("target's kills
// display 0") -- each node witnesses the death transition and tallies
// its own copies self-consistently.
BTPlayer *killer_player = 0;
if (application->GetHostManager() != 0)
{
// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= our mech) so
// the local player is credited via `this->killCount++` -- if it were our own
// mech, ScoreMessageHandler takes the suicide branch (award negated, no kill).
Entity *killer = application->GetHostManager()->GetEntityPointer(
((Mech *)victim)->lastInflictingID);
extern int BTIsRegisteredMech(Entity *e);
if (killer != 0 && killer != victim && BTIsRegisteredMech(killer))
killer_player = (BTPlayer *)((Mech *)killer)->GetPlayerLink();
}
if (killer_player != 0)
{
// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= the
// receiver's mech) so the handler credits `killCount++` -- a suicide
// (killer == victim) never reaches here.
BTPlayer::ScoreMessage kill(
Player::ScoreMessageID, // 0x12
sizeof(BTPlayer::ScoreMessage),
@@ -1507,13 +1558,12 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat
0.0f, // scoreAward (killBonus; 0 for bring-up)
damage, // damageAmount (killing-blow)
victim->GetEntityID()); // senderMechID = victim
local_player->Dispatch(&kill);
killer_player->Dispatch(&kill);
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] *** KILL *** localPlayer=" << (void *)local_player
<< " killCount=" << local_player->GetKillCount()
<< " deaths=" << local_player->GetDeaths()
<< " score=" << (Scalar)local_player->GetScore()
<< " rank=" << local_player->GetRanking() << "\n" << std::flush;
DEBUG_STREAM << "[score] *** KILL *** killerPlayer=" << (void *)killer_player
<< " killCount=" << killer_player->GetKillCount()
<< " deaths=" << killer_player->GetDeaths()
<< " score=" << (Scalar)killer_player->GetScore() << std::endl;
}
// MP DEATH: credit a death to the VICTIM's own player. NULL for the solo
+9
View File
@@ -818,6 +818,15 @@ void
coolantDraw = 0.0f;
}
// DIAG (BT_COOL_LOG): fire whenever THIS heat subsystem carries any damage --
// answers "does damaging a mech drain its coolant?". Shows the damageLevel the
// leak reads (@0xE0), the heat load, the resulting draw, and the live level.
if (getenv("BT_COOL_LOG") && zoneDamage > 0.001f)
DEBUG_STREAM << "[cool] " << GetName() << " dmg=" << zoneDamage
<< " heatLoad=" << heatLoad << " draw=" << coolantDraw
<< " coolantLevel=" << coolantLevel << "/" << thermalCapacity
<< "\n" << std::flush;
Scalar amount = coolantDraw * time_slice;
if (coolantLevel < amount)
{
+84 -10
View File
@@ -337,16 +337,14 @@ void
{
HeatableSubsystem *watched = (HeatableSubsystem *)watchedLink.Resolve(); // FUN_00417ab4(this+0x114)
// BRING-UP GUARD (marked deviation): in the shipped game watchedLink ALWAYS
// resolves -- the HeatWatcher resource's "WatchedSubsystem" name binds to a
// roster segment at load -- so the binary (FUN_004aeac4) derefs `watched`
// unconditionally. Our port does not yet resolve the watch link, so Resolve()
// returns null; the deref AVs the moment a HeatWatcher-derived subsystem's
// Performance actually runs (exposed by the WAVE 4 un-stub: AmmoBinSimulation
// calls this directly; PowerWatcher::Simulation for Searchlight/ThermalSight).
// With no resolved source, hold the heat alarm at Normal (same established
// pattern as Emitter::TrackSeekVoltage's null-source guard). FAITHFUL
// FOLLOW-UP = resolve watchedLink so the alarm tracks the real temperature.
// The watch link is now BOUND by the authentic factory connect pass (task
// #57: the mech factory's post-roster loop calls the binary's vtable slot
// +0x38 -- FUN_004aee2c / FUN_004b1a40 -> BTWatcherBindTarget below), so
// Resolve() succeeds on every master-node watcher whose resource named a
// WatchedSubsystem. The binary derefs `watched` unconditionally; the null
// guard stays for replicant nodes (the binary never binds there either --
// the +0x38 body is master-gated -- and their watcher Performances don't
// run) and for bring-up safety.
if (watched == 0)
{
heatAlarm.SetLevel(0); // NormalHeat (no watched source yet)
@@ -369,6 +367,51 @@ void
}
}
//
// @004aee2c (HeatWatcher vtable slot 14, offset +0x38; the PowerWatcher/Torso
// override @004b1a40 is byte-identical) -- the factory post-roster CONNECT
// pass, disassembled from the raw binary (Ghidra missed both function starts):
//
// owner = this->owner (+0xD0)
// if ((owner->simulationFlags & 0xC) == 0 && (owner->simulationFlags & 0x100))
// watchedLink(+0x114).Add( owner->roster(+0x128)[ watchedSubsystem(+0x128) ] )
//
// The mech factory loop tests IsDerivedFrom(HeatWatcher @0x50e604) then calls
// the slot. Split across the TU boundary the same way as the other
// cross-family helpers: the OWNER-side master gate + roster lookup live in the
// mech family (complete Mech type); these two bridges are the family-side
// derivation test and the link Add. [T1: bytes @004aee2c/@004b1a40]
//
int
BTWatcherWatchedIndex(Subsystem *sub) // -1 = not a HeatWatcher
{
if (sub == 0 || !sub->IsDerivedFrom(*HeatWatcher::GetClassDerivations()))
{
return -1;
}
return ((HeatWatcher *)sub)->watchedSubsystem; // @0x128 (resource +0xE4, name index +2)
}
void
BTWatcherBindTarget(Subsystem *sub, Subsystem *target)
{
((HeatWatcher *)sub)->watchedLink.Add(target); // (**(link+4))(link, target) = FUN_00417a80
}
//
// Cross-family derivation probe: 0x50e604 is the HEATWATCHER derivation tag
// (TestInstance @4aec38 checks its own class against it) -- NOT HeatSink, as
// an old btl4gaug label claimed (HeatSink : HeatableSubsystem would make an
// OR-test after HeatableSubsystem redundant; the binary's alternate branch is
// the disjoint watcher family). Bridged so gauge TUs can test without
// including this family's headers.
//
int
BTIsHeatWatcher(Subsystem *sub)
{
return sub != 0 && sub->IsDerivedFrom(*HeatWatcher::GetClassDerivations());
}
//
// @4aec54 (468 bytes) -- parse the HeatWatcher resource. Stamps classID 0x0BBF
// / size 0xF0; defaults the watched index to -1; requires DegradationTemperature,
@@ -934,6 +977,37 @@ void BTRecomputeCondenserValves(Entity *owner)
Derivation &condClass = *Condenser::GetClassDerivations();
int count = owner->GetSubsystemCount();
// DIAG (BT_VALVE=<n>): force every condenser's valve detent so the coolant/jam
// mechanic can be tested at a known coolant level (0=closed/uncooled .. 50=max).
// DIAG (BT_VALVE_BOOST=<condenserNumber>): the authentic "coolant priority" test --
// give ONE condenser the max detent (50) and starve the rest to the min (1), i.e.
// route the coolant SHARE to that condenser (share ~0.9 vs the ~1/N baseline).
{
const char *fv = getenv("BT_VALVE");
const char *fb = getenv("BT_VALVE_BOOST");
if (fb && fb[0])
{
int boostNum = atoi(fb);
for (int i = 0; i < count; ++i)
{
Subsystem *s = owner->GetSubsystem(i);
if (s != 0 && s->IsDerivedFrom(condClass))
((Condenser *)s)->valveState =
(((Condenser *)s)->condenserNumber == boostNum) ? 50 : 1;
}
}
else if (fv && fv[0])
{
int v = atoi(fv);
for (int i = 0; i < count; ++i)
{
Subsystem *s = owner->GetSubsystem(i);
if (s != 0 && s->IsDerivedFrom(condClass))
((Condenser *)s)->valveState = v;
}
}
}
// pass 1: total valve opening across all condensers
int total = 0;
for (int i = 0; i < count; ++i)
+247 -37
View File
@@ -246,8 +246,9 @@ inline ReconRegistryT *ReconRegistry() { static ReconRegistryT r; return &r; }
// segment/joint API -- it is no longer a free stand-in.
template<class...A> inline Recon LookupVideoObject(A&&...) { return Recon(); }
template<class...A> inline int ResourceFindByName(A&&...) { return 0; }
template<class...A> inline void LoadLowDetailBody(A&&...) {}
template<class...A> inline void LoadHighDetailBody(A&&...) {}
// (LoadLowDetailBody / LoadHighDetailBody removed task #59 -- they were no-op
// stubs mislabeling the FUN_004a80d4/86c8 GAIT-CLIP loaders as a body-LOD
// pair; the authentic clip-set gate now calls the real loaders.)
template<class...A> inline void FinishConstruction(A&&...) {}
template<class...A> inline void ResetPose(A&&...) {}
template<class...A> inline void SetStatusState(A&&...) {}
@@ -621,6 +622,10 @@ void
// WRITTEN -- a reconstruction gap; the natural authentic write site is
// this handler (every damage message carries the inflictor). [T3]
lastInflictingID = message->inflictingEntity;
// task #60: latch the killing-blow magnitude alongside the attacker id so the
// kill score carries the REAL finishing-hit damage (mirrors the per-hit path
// mech4.cpp:1207 that already passes p.damage), not the flat kShotDamage=12.
lastInflictingDamage = message->damageData.damageAmount;
// THREAT feed (task #37, the recovered reticle Execute's ThreatVector
// attr 0xC): when the PLAYER takes a hit, push the attack direction --
@@ -865,6 +870,13 @@ Mech::Mech(
// dispatch (task #56) gates on it every frame; garbage
// nonzero silently disabled the writers until death anims ran
legResetLatch = 0; // @0x654 -- same latch family, same fill hazard
legAnimationState = 0; // @0x3b0 -- same hazard (found 2026-07-14, the real-clock
// crash): the type-3 record writer re-dispatches
// SetBodyAnimation(legAnimationState) on itself; a record
// emitted before the leg SM's first tick passed the raw
// 0xCDCDCDCD as a clip index -> AV in SetBodyAnimation.
// (The stub call-counter clock had merely re-ordered record
// emission so the window was never hit.) Standing = 0.
attrPad = 0.0f; // shared read pad for un-populated gauge attributes
linearSpeed = 0.0f; // live forward ground speed (LinearSpeed gauge); set per frame
radarRange = 1000.0f; // radar display scale (SetTargetRange is stubbed;
@@ -903,6 +915,11 @@ Mech::Mech(
Wword(0xe6) = 0;
poseSyncLatch = 0; // @0x77c (was absorbed Wword(0x1df))
heatLevelSnapshot = 0; // @0x780 (type-7 deadband baseline)
mirrorBodyAdv = 0.0f; // master send-mirror gait travel
angMirrorYaw = 0.0f; // scalar peer-yaw mirror (port addition)
angMirrorRate = 0.0f;
angMirrorValid = 0;
angSyncLatch = 0; // peer heading re-anchor (type-4 receive)
Wword(0xfe) = Wword(0xfc) = Wword(0x105) = 0;
mechName.Copy(&DAT_004e0f8c); // FUN_00408e90(this+0xd8,"")
Wword(0x160) = Wword(0x15f) = 0;
@@ -1187,12 +1204,40 @@ Mech::Mech(
//
// Build the capability sub-rosters by IsDerivedFrom() class tests.
//
for (int id = 2; id < subsystemCount; ++id) // "start" each subsystem
// Loop 1 = the watcher CONNECT pass (task #57). The binary calls vtable
// slot +0x38 on every HeatWatcher-derived subsystem (@0x50e604 test);
// the slot bodies (FUN_004aee2c, PowerWatcher/Torso override @004b1a40,
// byte-identical -- Ghidra missed both starts, recovered from raw bytes)
// bind watchedLink to roster[watchedSubsystem], gated on the owner being
// the live master node ((flags & 0xC)==0 && (flags & 0x100)). The gate is
// hoisted out of the loop unchanged (constant per mech). This replaces
// the old SubProxy::Start() no-op that left every watchdogAlarm at 0 --
// which held the Torso's ElectricalStateLevel() below Ready and zeroed the
// twist rate forever.
//
if ((simulationFlags & 0xC) == 0 // SegmentCopyMask
&& (simulationFlags & 0x100) != 0) // MasterHeatSinkFlag
{
SubProxy *s = (SubProxy *)subsystemArray[id];
if (s != 0 && s->IsDerivedFrom(0x50e604)) // FUN_0041a1a4
extern int BTWatcherWatchedIndex(Subsystem *sub); // heatfamily_reslice.cpp
extern void BTWatcherBindTarget(Subsystem *sub, Subsystem *target);
for (int id = 2; id < subsystemCount; ++id)
{
s->Start(); // (**(s+0x38))(s)
int watched = BTWatcherWatchedIndex(subsystemArray[id]); // -1 = not a HeatWatcher
if (watched < 0)
continue;
if (watched < subsystemCount && subsystemArray[watched] != 0)
{
BTWatcherBindTarget(subsystemArray[id], subsystemArray[watched]);
DEBUG_STREAM << "[watch] subsystem " << id << " watches "
<< watched << std::endl;
}
else
{
// The binary has no range check (CreateStreamed validated the
// name); flag data drift honestly instead of a wild read.
DEBUG_STREAM << "[watch] subsystem " << id
<< " BAD WatchedSubsystem index " << watched << std::endl;
}
}
}
for (int id = 2; id < subsystemCount; ++id) // heatable roster
@@ -1292,14 +1337,13 @@ Mech::Mech(
<< " turnVel=" << updateTurnVelocityDeadband
<< " turnAngle=" << updateTurnAngleDeadband << " (rad)" << std::endl;
Wword(0x10c) = model->timeDelay; // rec+0xA4 -> mech+0x430
// NOTE: the decomp writes word slots this[0x15d]/this[0x15e]. Routed to the
// scratch bank (unnamed pose-angle fields) -- writing through (float*)(this +
// 0x15d) would be typed-pointer arithmetic on Mech* (== this + 0x15d*sizeof(Mech)).
// The AUTHENTIC per-mech turn rates (rec+0x60/0x64, deg->rad ->
// mech+0x574/0x578) -- still absorbed pending the turn-code wiring
// (locomotion polish item: the port turns at a bring-up constant rate).
Wword(0x15d) = model->walkingTurnRate * DegreesToRadians;
Wword(0x15e) = model->runningTurnRate * DegreesToRadians;
// The AUTHENTIC per-mech turn rates (rec+0x60/0x64, deg->rad -> mech+0x574/0x578).
// The decomp writes word slots this[0x15d]/this[0x15e] (== 0x574/0x578); stored in
// real named members now (task #64b) -- read by the master-perf turn-rate lerp in
// the drive (mech4.cpp) that yaws at lerp(walkingTurnRate, runningTurnRate) by speed
// instead of the old bring-up constant kDriveTurnRate.
walkingTurnRate = model->walkingTurnRate * DegreesToRadians;
runningTurnRate = model->runningTurnRate * DegreesToRadians;
//
// SHADOW JOINT resolve (task #20; CORRECTED -- the earlier draft misread
@@ -1326,21 +1370,19 @@ Mech::Mech(
}
//
// Choose the LOD / view variant of the body model.
// CLIP-SET / VIEW gate (@part_012.c:10308-10320). CORRECTION (task #59):
// the two branch targets here are FUN_004a80d4 / FUN_004a86c8 -- which are
// the GAIT-CLIP loaders (LoadLocomotionClips / LoadLocomotionClipsExt), NOT
// a "body LOD" pair (the old LoadLowDetailBody/LoadHighDetailBody names were
// no-op stubs, so this authentic gate did NOTHING and every mech silently
// fell back to the unconditional exterior load below -> the local cockpit
// mech played the EXTERIOR clips and leaned -8deg into every walk). The
// only real side effect at THIS point is the L4VIEWEXT flag; the clip LOAD
// itself is deferred to the gait-clip site below (after animationClips[] is
// zeroed), where the same three-way selection is applied.
//
if (getenv("L4VIEWEXT") != 0) // FUN_004dee74
{
Wword(0x15f) = 1;
LoadLowDetailBody(model); // FUN_004a80d4 [mech2]
}
else if ((instanceFlags & 0xc) == 4)
{
LoadLowDetailBody(model); // FUN_004a80d4 [mech2]
}
else
{
LoadHighDetailBody(model); // FUN_004a86c8 [mech2]
}
//
// Tell every heatable subsystem to attach to the body model.
@@ -1392,6 +1434,7 @@ Mech::Mech(
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f); // collision-damage guard snapshot
ramLastVictim = 0; // ram contact-edge state
ramContactLinger = 0.0f;
lastInflictingDamage = 0.0f; // task #60: killing-blow magnitude (set on hit)
if (GroundReal() && GetCollisionVolumeCount() > 0
&& collisionTemplate != 0 && collisionVolume != 0)
{
@@ -1421,12 +1464,49 @@ Mech::Mech(
animationClips[ci] = 0;
// P3 STEP 7 (state-machine path): load the full gait clip set + speed caps into
// animationClips[5..26] via LoadLocomotionClips (mech3.cpp @004a80d4 -- real). GATED
// (BT_GAIT_CUTOVER) so the default STEP-1/2 path is untouched while the AdvanceBody
// Animation state machine is brought up (it derefs ResolveAnimationClip unconditionally
// -> will fault on any clip this mech's content lacks; observe under the gate first).
// animationClips[5..26]. GATED (BT_GAIT_CUTOVER) so the default STEP-1/2 path is
// untouched while the AdvanceBodyAnimation state machine is brought up (it derefs
// ResolveAnimationClip unconditionally -> will fault on any clip this mech's content
// lacks; observe under the gate first).
//
// AUTHENTIC clip-set gate (@part_012.c:10308-10320, task #59): the local cockpit
// MASTER gets the INTERIOR clip set (LoadLocomotionClipsExt @004a86c8, 4-char 'i'
// suffix) whose clips shake `jointshakey` and OMIT the `jointhip` walk lean; network
// REPLICANTS and the forced L4VIEWEXT external view get the EXTERIOR set
// (LoadLocomotionClips @004a80d4, 3-char) carrying the authored -8deg lean. This is
// the SINGLE loader call the decomp ctor makes (the earlier no-op body-LOD gate was
// this same decomp branch mis-reconstructed -- see the L4VIEWEXT block above).
//
// PORT NOTE (task #59): the authentic discriminator is the replicant COPY bit
// (instanceFlags & 0xC == 4), which the 1995 game sets when it constructs a
// network replicant. The port does NOT set that bit at construction (all MP
// mechs are built as local masters; replication rides the mech registry +
// update-records, not the copy-performance path) -- so BOTH the local mech
// and the peer's replica currently take the ELSE (interior) branch. That is
// correct for the LOCAL cockpit (level, the reported bug) but means the peer's
// mech does not carry the exterior -8deg lean in the OTHER pod's view yet;
// that lights up automatically once the replicant copy bit is set at
// construction (tracked in open-questions -- do NOT fake it with a viewpoint
// test here: the clips load once at ctor, before the viewpoint is assigned).
int loadedInterior = 0;
if (BTEnvOn("BT_GAIT_CUTOVER", 1)) // default ON (=0 to disable)
LoadLocomotionClips(model);
{
if (getenv("L4VIEWEXT") != 0 || (instanceFlags & 0xc) == 4)
{
LoadLocomotionClips(model); // exterior (3-char): replicant / forced-ext view
}
else
{
LoadLocomotionClipsExt(model); // interior (4-char 'i'): the local cockpit master
loadedInterior = 1;
}
}
// Register this mech's model + which clip set it currently holds, so the
// per-frame MaintainViewClipSet() (mech4 PerformAndWatch) can flip it to the
// authentic set once the viewpoint is assigned (the copy bit is never set in
// the port, so the ctor gate lands everyone on interior -- see the note above).
extern void BTStashClipState(const Mech *m, Mech__ModelResource *model, int interior);
BTStashClipState(this, model, loadedInterior);
Wword(0xe5) = 0;
Wword(0xdd) = Wword(0xde) = 1;
@@ -1594,6 +1674,8 @@ Mech::~Mech()
extern void BTDeregisterMech(Entity *m); // task #46 live-mech registry
BTDeregisterMech((Entity *)this);
extern void BTUnstashClipState(const Mech *m); // task #59 clip-set registry
BTUnstashClipState(this);
vtable = &PTR_FUN_0050cfa8;
@@ -1733,6 +1815,13 @@ void
updateVelocity.angularMotion = savedAngV; // FUN_00408440
poseSyncLatch = 1; // @0x77c -- arm the dead-reckon re-base
if (getenv("BT_WIRE"))
DEBUG_STREAM << "[rx0] ent=" << GetEntityID()
<< " uvLin=(" << updateVelocity.linearMotion.x << ","
<< updateVelocity.linearMotion.z << ")"
<< " uPos=(" << updateOrigin.linearPosition.x << ","
<< updateOrigin.linearPosition.z << ")\n" << std::flush;
if (bodyAnimationState == 2 || bodyAnimationState == 3) // @0x728
{
projectedVelocity.linearMotion = ZeroVector; // FUN_0040a7f4(0x298, zeroMotion)
@@ -1760,7 +1849,23 @@ void
case 2: // commanded-speed update
{
Mech__SpeedUpdateRecord *record = (Mech__SpeedUpdateRecord *)message;
Simulation::ReadUpdateRecord(message); // FUN_0041bd34
{
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
// pose, but that stamp shrinks the dead-reckoner's projection span
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
// peer's position target jumps backward toward the stale origin, then the
// next pose record yanks it forward: target oscillation every frame while
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
// lever sweeps every frame of a key-hold -> a type-2 speed record per
// frame), which autodrive's pinned throttle never does -- the 'skips when
// I drive, smooth when you drive' report. Preserve the clock; keep the
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
const Time savedLastUpdate = lastUpdate;
Simulation::ReadUpdateRecord(message);
if (!s_t2clock) lastUpdate = savedLastUpdate;
} // FUN_0041bd34
bodyTargetSpeed = record->speedDemand; // @0x6b4 <- rec+0x10
}
break;
@@ -1768,7 +1873,29 @@ void
case 3: // leg/body state + stability
{
Mech__StateUpdateRecord *record = (Mech__StateUpdateRecord *)message;
Simulation::ReadUpdateRecord(message);
if (getenv("BT_GAITEV"))
DEBUG_STREAM << "[t3rx] legState=" << record->legState
<< " reset=" << record->legResetLatch
<< " myLegState=" << (int)legStateAlarm.GetLevel()
<< " myLegFrm=" << legAnimation.currentFrame
<< " spd=" << record->speedDemand << "\n" << std::flush;
{
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
// pose, but that stamp shrinks the dead-reckoner's projection span
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
// peer's position target jumps backward toward the stale origin, then the
// next pose record yanks it forward: target oscillation every frame while
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
// lever sweeps every frame of a key-hold -> a type-2 speed record per
// frame), which autodrive's pinned throttle never does -- the 'skips when
// I drive, smooth when you drive' report. Preserve the clock; keep the
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
const Time savedLastUpdate = lastUpdate;
Simulation::ReadUpdateRecord(message);
if (!s_t2clock) lastUpdate = savedLastUpdate;
}
bodyResetLatch = record->legResetLatch; // @0x658 <- rec+0x10
stabilityAlarm.SetLevel(record->stability); // @0x4c4 <- rec+0x18
if (record->legState == 0)
@@ -1809,13 +1936,30 @@ void
}
}
bodyTargetSpeed = record->speedDemand; // rec+0x28
angSyncLatch = 1; // arm the peer heading re-anchor
}
break;
case 5: // knockdown
{
Mech__FallUpdateRecord *record = (Mech__FallUpdateRecord *)message;
Simulation::ReadUpdateRecord(message);
{
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
// pose, but that stamp shrinks the dead-reckoner's projection span
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
// peer's position target jumps backward toward the stale origin, then the
// next pose record yanks it forward: target oscillation every frame while
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
// lever sweeps every frame of a key-hold -> a type-2 speed record per
// frame), which autodrive's pinned throttle never does -- the 'skips when
// I drive, smooth when you drive' report. Preserve the clock; keep the
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
const Time savedLastUpdate = lastUpdate;
Simulation::ReadUpdateRecord(message);
if (!s_t2clock) lastUpdate = savedLastUpdate;
}
heatAlarm.SetLevel(record->heatLevel); // @0x450 <- rec+0x10
throttleState = record->throttleState; // @0x4a4 <- rec+0x14
fallDirection = record->fallDirection; // @0x4a8 <- rec+0x18
@@ -1830,7 +1974,23 @@ void
case 6: // death
{
Mech__FallUpdateRecord *record = (Mech__FallUpdateRecord *)message;
Simulation::ReadUpdateRecord(message);
{
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
// pose, but that stamp shrinks the dead-reckoner's projection span
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
// peer's position target jumps backward toward the stale origin, then the
// next pose record yanks it forward: target oscillation every frame while
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
// lever sweeps every frame of a key-hold -> a type-2 speed record per
// frame), which autodrive's pinned throttle never does -- the 'skips when
// I drive, smooth when you drive' report. Preserve the clock; keep the
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
const Time savedLastUpdate = lastUpdate;
Simulation::ReadUpdateRecord(message);
if (!s_t2clock) lastUpdate = savedLastUpdate;
}
heatAlarm.SetLevel(record->heatLevel);
throttleState = record->throttleState;
fallDirection = record->fallDirection;
@@ -1847,7 +2007,23 @@ void
case 7: // impact (fields only, no side effects)
{
Mech__FallUpdateRecord *record = (Mech__FallUpdateRecord *)message;
Simulation::ReadUpdateRecord(message);
{
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
// pose, but that stamp shrinks the dead-reckoner's projection span
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
// peer's position target jumps backward toward the stale origin, then the
// next pose record yanks it forward: target oscillation every frame while
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
// lever sweeps every frame of a key-hold -> a type-2 speed record per
// frame), which autodrive's pinned throttle never does -- the 'skips when
// I drive, smooth when you drive' report. Preserve the clock; keep the
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
const Time savedLastUpdate = lastUpdate;
Simulation::ReadUpdateRecord(message);
if (!s_t2clock) lastUpdate = savedLastUpdate;
}
heatAlarm.SetLevel(record->heatLevel);
throttleState = record->throttleState;
fallDirection = record->fallDirection;
@@ -1859,7 +2035,23 @@ void
case 8: // airborne/reverse cycle-rate selector
{
Mech__AirborneUpdateRecord *record = (Mech__AirborneUpdateRecord *)message;
Simulation::ReadUpdateRecord(message);
{
// CLOCK GUARD (keyboard-skip fix): the base reader stamps lastUpdate=Now()
// on EVERY record ('HACK' per SIMULATE.cpp:276). Non-pose records carry no
// pose, but that stamp shrinks the dead-reckoner's projection span
// (nextUpdate - lastUpdate) WITHOUT refreshing updateOrigin -- so the
// peer's position target jumps backward toward the stale origin, then the
// next pose record yanks it forward: target oscillation every frame while
// records churn. Keyboard driving churns them CONSTANTLY (the throttle
// lever sweeps every frame of a key-hold -> a type-2 speed record per
// frame), which autodrive's pinned throttle never does -- the 'skips when
// I drive, smooth when you drive' report. Preserve the clock; keep the
// real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B).
static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0;
const Time savedLastUpdate = lastUpdate;
Simulation::ReadUpdateRecord(message);
if (!s_t2clock) lastUpdate = savedLastUpdate;
}
airborneSelect = record->airborne; // @0x3f4 <- rec+0x10
bodyTargetSpeed = record->speedDemand; // rec+0x14
}
@@ -2021,6 +2213,24 @@ void
}
updateOrigin.angularPosition = localOrigin.angularPosition; // FUN_00409968(0x138 <- 0x10c)
updateVelocity.angularMotion = localVelocity.angularMotion; // FUN_00408440(0x2d4 <- 0x1d0)
// RE-BASE the master's peer-estimate mirror (2026-07-14, replicant-chop
// fix): projectedOrigin/projectedVelocity model what the PEER will now
// extrapolate from this record. The send gate compares local vs projected
// (the peer's drift); without the re-base the mirror went stale, the gate
// fired EVERY frame, and the per-frame records re-based the replicant into
// the stall/snap chop. (The binary maintains this in the un-decompiled
// master perf; the writer-side re-base is the coherent reconstruction.)
projectedOrigin.angularPosition = localOrigin.angularPosition;
projectedVelocity.angularMotion = localVelocity.angularMotion;
// SCALAR peer-yaw mirror re-base (see mech.hpp): what the peer will now
// extrapolate -- this yaw, at this rate, from this moment.
{
YawPitchRoll _my; _my = localOrigin.angularPosition;
angMirrorYaw = (Scalar)_my.yaw;
angMirrorRate = (Scalar)localVelocity.angularMotion.y;
angMirrorTime = lastPerformance;
angMirrorValid = 1;
}
record->speedDemand = speedDemand; // rec+0x28
bodyTargetSpeed = speedDemand;
}
+25 -1
View File
@@ -618,6 +618,26 @@ public:
Scalar updatePositionDeadband; // binary @0x768 -- type-0 pose trigger
Scalar updateTurnVelocityDeadband; // binary @0x76c -- type-4 yaw-rate trigger
Scalar updateTurnAngleDeadband; // binary @0x770 -- type-4 orientation trigger (rad)
// SCALAR peer-yaw mirror (port addition, replicant-spin work): the master's
// estimate of the yaw the PEER is currently rendering -- re-based on every
// type-4 send (writer case 4), advanced analytically as base + rate*elapsed.
// Replaces the quaternion projectedOrigin mirror for the ANGLE deadband: that
// one is recomputed by the master's own reckoner each frame from lastUpdate
// timing it does not control, and its false pi-drift waves FLOODED type-4
// resyncs (measured maxAng~=pi bursts). Scalars, wrap-safe, self-timed.
// Master send-mirror gait travel (BT_MASTER_GAITMIRROR): the invisible mj=0
// body channel's per-frame cycleDistance, stashed in the drive block and
// consumed by the send gate to advance projectedOrigin the way the PEER
// predicts (gait), instead of the constant-velocity deadReckoner. This is the
// authentic FUN_004a9b5c mirror = IntegrateMotion(mj=0) on projectedOrigin.
Scalar mirrorBodyAdv; // body cycleDistance this frame (master)
Scalar angMirrorYaw; // yaw at the last type-4 send (rad)
Scalar angMirrorRate; // yaw rate sent with it (rad/s)
Time angMirrorTime; // lastPerformance at that send
int angMirrorValid; // 0 until the first type-4 send
int angSyncLatch; // peer: type-4 arrived -> re-anchor heading (the
// angular analog of poseSyncLatch; consumed by
// the replicant's incremental heading integrator)
// AUTHENTIC GROUND MODEL ctor products (task #15, ground-model-decode;
// binary part_012.c:9938-9940 + 9974-9975). By-name access only; declared
// after the layout-locked fields so nothing shifts.
@@ -848,6 +868,8 @@ protected:
Scalar jumpWalkSpeedMax; // @0x540
Scalar jumpRunStrideLength; // @0x544
Scalar jumpWalkStrideLength; // @0x548
Scalar walkingTurnRate; // @0x574 (WalkingTurnRate, rad/s) -- the master
Scalar runningTurnRate; // @0x578 (RunningTurnRate, rad/s) perf turn-rate lerp
Scalar groundCycleRate; // @0x5b8
Scalar airborneCycleRate; // @0x5bc
// Forward-throttle scale read by the controls mapper (@004afd10:
@@ -930,6 +952,7 @@ protected:
void MeasureClipStride(int slot, Scalar *total, Scalar *lastKey); // @004a8054
void LoadLocomotionClips(Mech__ModelResource *model); // @004a80d4
void LoadLocomotionClipsExt(Mech__ModelResource *model); // @004a86c8
void MaintainViewClipSet(); // task #59 port: interior(viewpoint)/exterior(others)
static int
CreateSubsystemStream(
@@ -1008,7 +1031,8 @@ protected:
// Defined in mech4.cpp (Mech__DamageZone::vitalDamageZone is protected; Mech has access).
int FirstVitalZone() const;
int flags; // entity flags word (this+0x28 region)
int stance; // posture state
Scalar lastInflictingDamage; // task #60: killing-blow magnitude (port-only; reuses
// the retired phantom `stance` slot -- feeds the kill score)
int ammoState; // @0x44c 0 none / 1 leaking / 2 dry
AlarmIndicator graphicAlarm; // body graphic-state alarm
EntityID lastInflictingID; // @0x43c last attacker (read by DamageZone routing)
+184 -26
View File
@@ -570,6 +570,20 @@ Scalar
switch (legAnimationState) // this+0x3b0
{
case StandingAnimation: // 0
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
// reverse speed, and several stand-entry paths (turn exit, terminal
// poses) never touch the cycle -- Standing could be entered with a
// stale legCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
// LOOKS still (case 0 never advances the clip) but the stale cycle
// REPLICATES and the peer's replicant marches in place. A standing
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
if (legCycleSpeed != 0.0f)
{
legCycleSpeed = 0.0f;
ForceUpdate(8); // type-3 record: legs stopped
}
// RAW (part_012.c FUN_004a5028 case 0): standSpeed < commandedSpeed ->
// begin WALKING (state 5); 0 <= commanded < standSpeed -> stay standing;
// commanded < 0 -> stand-to-reverse (0x10). (The earlier draft had the
@@ -581,18 +595,58 @@ Scalar
else
{
distance = 0.0f;
// TURN-IN-PLACE entry (bring-up TRIGGER, real machinery): the trn
// clip/state-4 advance/exit are binary-verified, but the authentic
// entry DISPATCHER isn't yet located in the decomp -- enter from
// Standing on a live turn demand with no speed demand. The clip's
// finished-callback drops back to stand; while the demand persists
// this trigger re-arms it (a looping pivot step).
// TURN-IN-PLACE entry -- AUTHENTIC, decoded from the master-perf disasm
// (0x4aa505-0x4aa588, task #64b). The dispatcher lives in the master perf
// FUN_004a9b5c (Ghidra never decompiled it -> objdump); it arms trn from
// Standing when: the mech is TURNING (|angularVelocity| > 1e-4, ds:0x4ab16c
// -- angularVelocity = turnDemand * turnRate, so turnDemand outside a tiny
// deadband) AND 0 <= speedDemand <= standSpeed (the FULL sub-walk range,
// ds:0x4ab178=0 .. mech[0x530]) AND turnCapable(mech[0x588]) AND the
// legResetLatch(mech[0x654]) debounce is clear. CORRECTS the task-#64a
// stand-in `commandedSpeed < 0.25*standSpeed` (that near-zero gate was a
// guess; the binary allows the full [0,standSpeed] range -- trn is gated on
// TURNING, not on being slow). [T1 from disasm] The turnDemand deadband
// (0.05) is the port proxy for the |angVel|>1e-4 test. NOTE: the disasm's
// legResetLatch gate is a master-perf ONE-FRAME debounce (cleared every
// frame at 0x4a9bff, set on wind-down/turn-stop) -- it does NOT map onto
// the port's split leg-SM (wind-down + trn-entry run in different frames/
// cases and the port never per-frame-clears the latch), so gating entry on
// it here would wrongly block trn after every walk. Omitted by design;
// see locomotion.md "turn-in-place dispatcher".
// REPLICANT accommodation (regression fix, 2026-07-14): the dispatcher
// above is MASTER-perf logic; a replicant feeds this SM DERIVED signals
// (speed/turn from the dead-reckon stream, mech4.cpp:1948) and runs ONLY
// the leg channel. Two master rules break it:
// (1) the body weld -- a replicant never runs the body SM, so arming the
// body to 4 sticks bodyAnimationState there forever and BLOCKS every
// later trn entry (the peer "rotates as a statue");
// (2) the full [0,standSpeed] entry -- the derived speed sweeps that band
// on every dead-reckoned start/stop with turnDemand pinned +-1, so
// trn keeps arming mid-locomotion and speed-exiting (jerky walking).
// Replicants: no body weld / no body arm (channel is inert, mj=0) and the
// narrow near-zero entry gate (the pre-#64b accommodation). [T3]
const int trnIsRepl = (GetInstance() == ReplicantInstance);
const Scalar trnEntryMax = trnIsRepl ? standSpeed * 0.25f : standSpeed;
if (hasCrashSet != 0 && mppr != 0
&& ZeroSpeed <= commandedSpeed
&& commandedSpeed >= ZeroSpeed && commandedSpeed <= trnEntryMax
&& (mppr->turnDemand > 0.05f
|| mppr->turnDemand < -0.05f))
|| mppr->turnDemand < -0.05f)
&& (trnIsRepl || bodyAnimationState == StandingAnimation)) // weld: masters only
{
SetLegAnimation(4); // turn-in-place (trn)
// LOCKSTEP (task #64): arm BOTH channels on the same frame. Arming
// only the leg let the body enter walk on its own schedule ~7-20
// frames apart -> the two walk cycles ran permanently out of phase
// and the body's pose flashed through on leg clip-boundary frames
// (the rhythmic gait skip + reduced bob). Both SequenceControllers
// bind the same trn clip at the same frame + advance at the same
// rate (case 4 twins), so they complete + re-enter walk together --
// the same phase-weld a standstill start gets for free. This is the
// workflow plan's "master perf arms both channels" reconstruction
// (the authentic dispatcher in the un-decompiled 0x4a9b5c gap arms
// both -- the body's case-4/finish machinery is dead code otherwise).
SetLegAnimation(4); // turn-in-place (trn), channel A
if (!trnIsRepl)
SetBodyAnimation(4); // channel B, same frame [lockstep, masters]
goto advance_normally;
}
if (ZeroSpeed <= commandedSpeed)
@@ -628,26 +682,51 @@ Scalar
break;
case 4: // TURN-IN-PLACE (trn clip)
// BINARY-VERIFIED (raw FUN_004a5028 case 4, part_012.c:12013): state 4 is
// the turn-in-place animation (animationClips[4] = the "trn" clip, loaded
// under turnCapable@0x588). It EXITS to stand when a real speed is
// commanded (standSpeed < demand -- the earlier draft had this comparison
// INVERTED, the same bug class as the Standing case) or when reverse is
// commanded; otherwise it ADVANCES the turn clip while the mech pivots.
if (standSpeed < commandedSpeed)
// state 4 is the turn-in-place animation (animationClips[4] = the "trn" clip,
// loaded under turnCapable@0x588). AUTHENTIC exits, restored VERBATIM from the
// decompiled leg SM (part_012.c:12013 == FUN_004a5028 case 4 [T1]):
// * standSpeed < commandedSpeed -> SetLevel(0)+ForceUpdate(8): walk takes over
// * commandedSpeed < ZeroSpeed -> SetLevel(0)+ForceUpdate(8): reverse
// * else -> goto advance_normally: advance the pivot at idleStrideScale.
// PLUS the master-perf turn-STOP exit (disasm 0x4aa5c6-0x4aa5e6 [T1], which the
// leg SM does NOT contain -- it lives in the un-decompiled master perf that the
// port has no separate frame for, so it folds in here): when the turn stops
// (|angularVelocity| <= 1e-4, i.e. turnDemand back inside the deadband) ->
// SetLevel(0) + legResetLatch(mech[0x654])=1. Without it the trn clip would
// shuffle in place forever after the stick re-centers.
//
// The task-#64 fast-forward (4x) + 0.25*standSpeed early-release were STAND-INS
// invented to dodge a turn->walk stutter that was ACTUALLY the body channel
// leaking joints into the rendered skeleton (mj=1); root-caused + fixed by the
// body-channel mj=0 change (AdvanceBodyAnimation, mech4.cpp). With the body no
// longer writing joints the authentic mid-clip cut renders cleanly, so the
// inventions are removed. trn has zero root translation -> distance stays 0.
if (standSpeed < commandedSpeed || commandedSpeed < ZeroSpeed)
{
legStateAlarm.SetLevel(0);
ForceUpdate(8); // FUN_004a4c54(this,8): type-3 record
break;
}
distance = 0.0f;
if (commandedSpeed < ZeroSpeed)
{
legStateAlarm.SetLevel(0);
legStateAlarm.SetLevel(0); // -> Standing (leg-SM exit)
ForceUpdate(8); // type-3 record
distance = 0.0f;
break;
}
goto advance_normally;
// MASTER ONLY (regression fix): the master-perf turn-stop exit is master-perf
// logic (FUN_004a9b5c runs on MasterInstance mechs, NOT replicants). A replicant
// derives turnDemand from the REPLICATED yaw rate (mech4.cpp:1968) -- a noisy
// proxy that dips into the deadband between dead-reckon updates; running this
// exit on it kicked the peer out of trn every few frames -> the peer "rotated as
// a statue" + jerky (user-reported). Gate to masters; a replicant leaves trn via
// the speed exits + its own turnDemand-driven re-entry, exactly as it did before
// this exit was added.
if (GetInstance() != ReplicantInstance
&& (mppr == 0
|| (mppr->turnDemand <= 0.05f && mppr->turnDemand >= -0.05f)))
{
legStateAlarm.SetLevel(0); // turn stopped -> Standing (master-perf exit)
ForceUpdate(8);
legResetLatch = 1; // mech[0x654] (master perf sets it here, 0x4aa5e1)
distance = 0.0f;
break;
}
goto advance_normally; // still turning, sub-walk -> advance the pivot
case 6: case 7: // WalkToRun
//
@@ -823,6 +902,20 @@ Scalar
switch (bodyAnimationState) // this+0x728
{
case StandingAnimation: // 0
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
// reverse speed, and several stand-entry paths (turn exit, terminal
// poses) never touch the cycle -- Standing could be entered with a
// stale bodyCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
// LOOKS still (case 0 never advances the clip) but the stale cycle
// REPLICATES and the peer's replicant marches in place. A standing
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
if (bodyCycleSpeed != 0.0f)
{
bodyCycleSpeed = 0.0f;
ForceUpdate(8); // type-3 record: legs stopped
}
// RAW (FUN_004a5678 case 0): standSpeed < bodyTargetSpeed -> begin WALKING
// (5); 0 <= target < standSpeed -> stay standing; target < 0 -> reverse
// (0x10). (The earlier draft had the comparison INVERTED.)
@@ -841,7 +934,44 @@ Scalar
}
// FALLTHROUGH
case 2: case 3: case 4: case 5: case 8: case 9: case 10: case 0x0b:
case 4: // TURN-IN-PLACE, LOCKSTEP twin (task #64)
// The body channel runs trn in LOCKSTEP with the leg: armed together at
// entry (leg Standing cross-arms both), advanced at the SAME rate keyed on
// the SAME live speedDemand, so both clips complete on the same frame and
// both channels re-enter walk on the same frame. Without this the body
// entered walk ~7-20 frames apart from the leg and the two walk cycles ran
// permanently out of phase. Exits mirror the leg twin VERBATIM
// (part_012.c:12013 [T1]): speed exits + the master-perf turn-stop exit.
// The body does NOT set legResetLatch (leg-channel/master-perf state, set
// once by the leg twin). Since the body no longer writes joints (mj=0,
// AdvanceBodyAnimation) this channel only tracks state so both channels
// re-enter walk on the same frame.
{
MechControlsMapper *bm = MappingMapper();
const Scalar bspd = (bm != 0) ? bm->speedDemand : 0.0f;
if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (leg-symmetric)
{
bodyStateAlarm.SetLevel(0);
ForceUpdate(8);
distance = 0.0f;
break;
}
if (GetInstance() != ReplicantInstance // MASTER only (see leg twin)
&& (bm == 0
|| (bm->turnDemand <= 0.05f && bm->turnDemand >= -0.05f)))
{
bodyStateAlarm.SetLevel(0); // turn stopped (leg-symmetric)
ForceUpdate(8);
distance = 0.0f;
break;
}
distance = bodyAnimation.Advance( // still turning -> advance the pivot
time_slice * globalTimeScale * idleStrideScale, loop);
bodyCycleSpeed = distance / time_slice;
}
break;
case 2: case 3: case 5: case 8: case 9: case 10: case 0x0b:
case 0x0e: case 0x0f: case 0x10: case 0x11: case 0x14: case 0x15:
case 0x1c: case 0x1d: case 0x1e: case 0x1f: case 0x20:
distance = bodyAnimation.Advance( // FUN_0042790c(this+0x6bc, ...)
@@ -979,6 +1109,20 @@ Scalar
switch (bodyAnimationState)
{
case StandingAnimation: // 0
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
// reverse speed, and several stand-entry paths (turn exit, terminal
// poses) never touch the cycle -- Standing could be entered with a
// stale bodyCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
// LOOKS still (case 0 never advances the clip) but the stale cycle
// REPLICATES and the peer's replicant marches in place. A standing
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
if (bodyCycleSpeed != 0.0f)
{
bodyCycleSpeed = 0.0f;
ForceUpdate(8); // type-3 record: legs stopped
}
if (bodyTargetSpeed <= standSpeed) // 0x6b4 <= 0x530
{
distance = 0.0f;
@@ -1166,6 +1310,20 @@ Scalar
switch (legAnimationState)
{
case StandingAnimation: // 0
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
// reverse speed, and several stand-entry paths (turn exit, terminal
// poses) never touch the cycle -- Standing could be entered with a
// stale legCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
// LOOKS still (case 0 never advances the clip) but the stale cycle
// REPLICATES and the peer's replicant marches in place. A standing
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
if (legCycleSpeed != 0.0f)
{
legCycleSpeed = 0.0f;
ForceUpdate(8); // type-3 record: legs stopped
}
if (motionSource->commandedSpeed <= standSpeed) // +0x128 <= 0x530
{
distance = 0.0f;
+204 -17
View File
@@ -470,31 +470,218 @@ void
//###########################################################################
//###########################################################################
// LoadLocomotionClipsExt
// LoadLocomotionClipsExt (the INTERIOR clip set)
//
// @004a86c8
//
// Structurally identical to LoadLocomotionClips, but reads from the *second*
// gait-clip suffix table at 0050d94d -- these are 4-character codes (note the
// extra uStack_19 byte the decomp materialises in the name buffer) -- and the
// optional-set markers also carry the extra character. Used for the mech
// configuration whose clip names do not fit the 3-letter scheme. The cached
// targets (0x530/0x534/0x538/0x34c/0x52c/0x350, the optional sets, and the
// capability flags) are exactly the same as the base loader.
//
// (Body omitted here for brevity; it differs from LoadLocomotionClips only in
// the suffix-string operands and the 4-char name length. See @004a86c8 for
// the byte-exact form -- the call/measure/store sequence is line-for-line
// identical.)
// The 4-character suffix table @0050d94d is the 3-letter codes with a trailing
// 'i' (swri, wwri, wwli, ... -- byte-verified from BTL4OPT.EXE @file 0x10d74d).
// These are the INTERIOR ('i') gait clips, bound to the LOCAL cockpit MASTER
// (the ctor gate @part_012.c:10308-10320): they animate `jointshakey` (the
// cockpit shake node) and OMIT the `jointhip` walk-lean channel the exterior
// clips carry -- so the pilot's own view stays level while a walking mech
// leans into its stride for everyone ELSE (the exterior clips, network
// replicants + the L4VIEWEXT external view). The call/measure/store sequence
// and the cached targets (0x530/0x534/0x538/0x34c/0x52c/0x350, the optional
// sets, the capability flags) are LINE-FOR-LINE identical to the 3-char
// loader; only the suffix operands differ (each gains the 'i'). [T1: byte-
// exact against @004a86c8 + the suffix-table dump.]
//###########################################################################
//###########################################################################
void
Mech::LoadLocomotionClipsExt(Mech__ModelResource *model)
{
// identical control flow to LoadLocomotionClips with the 0050d94d table;
// see banner. Reconstructed as a thin alias to keep the cached-field
// contract documented in one place.
LoadLocomotionClips(model); // PLACEHOLDER for the 4-char variant @004a86c8
const char *prefix = model->animationPrefix; // param_2 + 0x40
gyroRumbleTimer = 0.0f; // this+0x5c4
// --- stand -> walk : standSpeed is that clip's final-keyframe stride ----
namedClip[ 0] = *ResolveAnimationClip(prefix, "swri"); // 0x5e0
legAnimation.SelectSequence(namedClip[0], PTR_LAB_0050d714, DAT_0050d718, DAT_0050d71c);
standSpeed = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x530
// --- forward walk/run : stride = (s6+s7)/(d6+d7) ------------------------
Scalar s0, d0, s1, d1;
namedClip[ 1] = *ResolveAnimationClip(prefix, "wwri"); // 0x5e4
MeasureClipStride(6, &s0, &d0);
namedClip[ 2] = *ResolveAnimationClip(prefix, "wwli"); // 0x5e8
MeasureClipStride(7, &s1, &d1);
walkStrideLength = (s0 + s1) / (d0 + d1); // 0x534
namedClip[ 3] = *ResolveAnimationClip(prefix, "wsri"); // 0x5ec walk->stand R
namedClip[ 4] = *ResolveAnimationClip(prefix, "wsli"); // 0x5f0 walk->stand L
// --- reverse base : reverseSpeedMax is that clip's final-keyframe stride -
namedClip[ 5] = *ResolveAnimationClip(prefix, "wrri"); // 0x5f4
legAnimation.SelectSequence(namedClip[5], PTR_LAB_0050d720, DAT_0050d724, DAT_0050d728);
reverseSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x538
namedClip[ 6] = *ResolveAnimationClip(prefix, "wrli"); // 0x5f8
// --- reverse cycle : stride = (s_c+s_d)/(d_c+d_d) -----------------------
namedClip[ 7] = *ResolveAnimationClip(prefix, "rrri"); // 0x5fc
MeasureClipStride(0x0c, &s0, &d0);
namedClip[ 8] = *ResolveAnimationClip(prefix, "rrli"); // 0x600
MeasureClipStride(0x0d, &s1, &d1);
reverseStrideLength = (s0 + s1) / (d0 + d1); // 0x34c
// REVERSE + BUMP set (same slot map as the 3-char loader; see its banner) --
animationClips[14] = *ResolveAnimationClip(prefix, "rwri"); // 0x604 state 0xE run->walk R
animationClips[15] = *ResolveAnimationClip(prefix, "rwli"); // 0x608 state 0xF run->walk L
animationClips[0x20] = *ResolveAnimationClip(prefix, "bmpi");// 0x64c bump/stagger clip
gimpBaseClip = animationClips[0x20];
animationClips[16] = *ResolveAnimationClip(prefix, "sbri"); // 0x60c state 0x10 stand->back R
legAnimation.SelectSequence(animationClips[16], PTR_LAB_0050d72c, DAT_0050d730, DAT_0050d734);
gimpSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x52c
animationClips[17] = *ResolveAnimationClip(prefix, "sbli"); // 0x610 state 0x11 stand->back L
animationClips[20] = *ResolveAnimationClip(prefix, "bsri"); // 0x61c state 0x14 back->stand R
animationClips[21] = *ResolveAnimationClip(prefix, "bsli"); // 0x620 state 0x15 back->stand L
// --- reverse cycle : stride = -(s+s)/(d+d), stored negative (carried s1/d1)
animationClips[18] = *ResolveAnimationClip(prefix, "bbri"); // 0x614 state 0x12 back cycle R
MeasureClipStride(0x12, &s0, &d0);
animationClips[19] = *ResolveAnimationClip(prefix, "bbli"); // 0x618 state 0x13 back cycle L
MeasureClipStride(0x13, &s0, &d0);
gimpStrideLength = -((s0 + s1) / (d0 + d1)); // 0x350
//
// ---- OPTIONAL clip set 1: JUMP JETS (probe "wgli") ----
//
jumpCapable = 0; // this+0x580
if (ResolveAnimationClip(prefix, "wgli") != 0)
{
jumpCapable = 1;
animationClips[22] = *ResolveAnimationClip(prefix, "wgli"); // 0x624 state 0x16
legAnimation.SelectSequence(animationClips[22], PTR_LAB_0050d738, DAT_0050d73c, DAT_0050d740);
jumpRunSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x53c
animationClips[23] = *ResolveAnimationClip(prefix, "wgri"); // 0x628 state 0x17
legAnimation.SelectSequence(animationClips[23], PTR_LAB_0050d744, DAT_0050d748, DAT_0050d74c);
jumpWalkSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x540
animationClips[24] = *ResolveAnimationClip(prefix, "ggri"); // 0x62c state 0x18
MeasureClipStride(0x18, &s0, &d0);
jumpRunStrideLength = s0 / d0; // 0x544
animationClips[25] = *ResolveAnimationClip(prefix, "ggli"); // 0x630 state 0x19
MeasureClipStride(0x19, &s0, &d0);
jumpWalkStrideLength = s0 / d0; // 0x548
animationClips[26] = *ResolveAnimationClip(prefix, "gsli"); // 0x634 state 0x1A
animationClips[27] = *ResolveAnimationClip(prefix, "gsri"); // 0x638 state 0x1B
}
//
// ---- OPTIONAL clip set 2: SQUAT (probe "squi") ----
//
hasReverseGimpSet = 0; // this+0x584 (squatCapable)
if (ResolveAnimationClip(prefix, "squi") != 0)
{
hasReverseGimpSet = 1;
animationClips[3] = *ResolveAnimationClip(prefix, "squi"); // 0x5d8
animationClips[2] = *ResolveAnimationClip(prefix, "sqdi"); // 0x5d4
}
//
// ---- OPTIONAL clip set 3: TURN-IN-PLACE (probe "trni") ----
//
hasCrashSet = 0; // this+0x588 (turnCapable)
if (ResolveAnimationClip(prefix, "trni") != 0)
{
hasCrashSet = 1;
animationClips[4] = *ResolveAnimationClip(prefix, "trni"); // 0x5dc
}
DEBUG_STREAM << "[loadclips] INTERIOR ('i') set: fScale=" << forwardThrottleScale
<< " walkStride=" << walkStrideLength << " jumpCapable=" << jumpCapable
<< "\n" << std::flush;
}
//###########################################################################
//###########################################################################
// MaintainViewClipSet (port adaptation, task #59)
//
// The authentic clip-set gate (@part_012.c:10308-10320) keys on the replicant
// COPY bit (instanceFlags & 0xC == 4), which the port never sets -- ALL MP mechs
// are built as local masters (replication rides the mech registry + update
// records, not the copy-performance path; the heat sim / scoring / torso
// watcher-connect all run on both, by design). So the ctor gate lands every
// mech on the INTERIOR set. We restore the authentic RESULT by selecting the
// set by VIEWPOINT instead, maintained per frame: the mech you PILOT (== the
// viewpoint entity) keeps the INTERIOR set (level cockpit -- you never see your
// own mech externally in the real pod); every OTHER mech gets the EXTERIOR set
// (the authored -8deg jointhip walk lean, which you DO see from your cockpit /
// the chase cam). Reloads only on a viewpoint-status CHANGE (no per-frame
// thrash), and the two sets share identical stride data so the swap is seamless.
// The model pointer is stashed at ctor (the GameModel resource persists for the
// mission). Fixed array -- mech counts are tiny, no allocator in the tick path.
//###########################################################################
//###########################################################################
namespace {
struct BTClipEntry { const Mech *mech; Mech__ModelResource *model; int interior; };
BTClipEntry s_clipReg[32];
int s_clipRegCount = 0;
}
void BTStashClipState(const Mech *m, Mech__ModelResource *model, int interior)
{
for (int i = 0; i < s_clipRegCount; ++i)
if (s_clipReg[i].mech == m)
{
s_clipReg[i].model = model;
s_clipReg[i].interior = interior;
return;
}
if (s_clipRegCount < 32)
{
s_clipReg[s_clipRegCount].mech = m;
s_clipReg[s_clipRegCount].model = model;
s_clipReg[s_clipRegCount].interior = interior;
++s_clipRegCount;
}
}
void BTUnstashClipState(const Mech *m)
{
for (int i = 0; i < s_clipRegCount; ++i)
if (s_clipReg[i].mech == m)
{
s_clipReg[i] = s_clipReg[--s_clipRegCount];
return;
}
}
void
Mech::MaintainViewClipSet()
{
if (application == 0 || !BTEnvOn("BT_GAIT_CUTOVER", 1))
return;
if (getenv("L4VIEWEXT") != 0) // forced-external debug view: leave EXTERIOR everywhere
return;
Entity *vp = application->GetViewpointEntity();
if (vp == 0) // viewpoint not assigned yet -- keep the current set
return;
BTClipEntry *e = 0;
for (int i = 0; i < s_clipRegCount; ++i)
if (s_clipReg[i].mech == this) { e = &s_clipReg[i]; break; }
if (e == 0 || e->model == 0) // not registered / no model -- nothing to flip
return;
const int wantInterior = ((Entity *)this == vp) ? 1 : 0;
if (wantInterior == e->interior) // already holding the right set
return;
if (wantInterior)
LoadLocomotionClipsExt(e->model); // interior: level cockpit (your own mech)
else
LoadLocomotionClips(e->model); // exterior: the authored walk lean (every other mech)
e->interior = wantInterior;
DEBUG_STREAM << "[clipfix] mech " << (void *)this << " -> "
<< (wantInterior ? "INTERIOR (level)" : "EXTERIOR (lean)") << "\n" << std::flush;
}
File diff suppressed because it is too large Load Diff
+36 -8
View File
@@ -51,7 +51,7 @@
// FUN_00408050 (Scalar)Random -> [0,1) FUN_0040807c(rng,n) Random(n)
// FUN_004ac07c Subsystem::ApplyDamage(damage) -> Scalar
// FUN_004b11bc Generator::ForceShortRecovery (class derivations @0050f4bc)
// FUN_0049fb54 Mech::<query torso/leg destroyed state>
// FUN_0049fb54 Mech::IsDisabled() (movementMode 2||9; NOT a torso/leg query)
// FUN_0040485c/00404720/00404088/00404190/004040d8 NotationFile readers
// FUN_00402298/004022b0/004022d0 operator new / array new / delete
// FUN_004dbb24/004db92c/004d9c38/004dbd4c DebugStream << / endl
@@ -424,7 +424,14 @@ void
}
// ---- real zone: apply to armour/structure ----
Scalar preLvl = damageLevel;
DamageZone::TakeDamage(damage); // FUN_0041e4e0
if (getenv("BT_DMG_LOG"))
DEBUG_STREAM << "[dmghit] mech=" << mech->GetEntityID()
<< " zone=" << damageZoneIndex << " vital=" << (int)vitalDamageZone
<< " type=" << (int)damage.damageType << " amt=" << damage.damageAmount
<< " burst=" << damage.burstCount
<< " lvl " << preLvl << "->" << damageLevel << "\n" << std::flush;
Mech__DamageZone *parent = parentArtifactZone.GetCurrent(); // FUN_00417ab4(this+0x62)
if (parent != 0)
@@ -448,18 +455,26 @@ void
if (damageLevel >= StructureMax)
RecurseSegmentTable(mech); // FUN_0049cad4
}
else if ((unsigned)(mech->stance - 3) < 2) // standing/walking
else if ((unsigned)(mech->MovementMode() - 3) < 2) // live sim state 3||4 (mech+0x40)
{
// task #60: the gate reads the LIVE MovementMode (mech+0x40),
// @part_012.c:6910 `*(int*)(iVar1+0x40) - 3U < 2` -- NOT the phantom
// `stance` member (no binary offset, zero writers -> perma-0, so
// this whole leg-shot-out -> fall/death branch was DEAD).
if (rightLeg != 0 && damageLevel >= StructureMax)
mech->graphicAlarm.SetLevel(9); // fall / leg gone
else if (leftLeg != 0 && damageLevel >= StructureMax)
mech->graphicAlarm.SetLevel(9);
}
else if (mech->IsAirborne() == 0) // FUN_0049fb54
else if (mech->IsDisabled() == 0) // FUN_0049fb54 (NOT disabled)
{
// Non-standing stance, still grounded: a half-destroyed leg zone
// trips the partial-failure graphic (right -> 4, left -> 3). The
// oracle compares against _DAT_0049c9a0 (0.5), NOT StructureMax.
// A live (non-disabled) mech: a half-destroyed leg zone trips the
// partial-failure graphic (right -> 4, left -> 3). The oracle
// compares against _DAT_0049c9a0 (0.5), NOT StructureMax. task #60:
// the guard is IsDisabled (@0049fb54 = movementMode 2||9), NOT the
// always-0 IsAirborne stub -- on a WRECK the binary SUPPRESSES this
// write; the stub let it corrupt graphicAlarm 9->4/3 (the task-#52
// wreck-graphic bug, now fixed at source not just masked).
if (rightLeg != 0 && damageLevel >= LegHalfStructure)
mech->graphicAlarm.SetLevel(4);
else if (leftLeg != 0 && damageLevel >= LegHalfStructure)
@@ -1092,8 +1107,21 @@ void
}
if (d != 0)
{
BTSpawnDamageEffect(owner, d->effectResource, // explosion AT the zone's
zone->segmentIndex); // segment (world position)
// THE AUTHENTIC CHAIN (@004d097c dispatcher, landed 2026-07-13):
// RendererManager::StartEntityEffect -> the Renderer message
// handler [T0: GameModel resolve + ExplosionResourceTable
// graphic-state remap] -> BTL4VideoRenderer::
// StartEntityEffectImplementation -> the effect ATTACHED to the
// zone's segment (smoke follows the walking mech) -- and the
// AudioRenderer's implementation hears the same message (zone
// sounds ride free). Explosion::Make fallback only when the
// manager isn't up (early bring-up).
extern int BTStartZoneEffect(Mech *mech, void *zone, int resource);
if (!BTStartZoneEffect(owner, zone, d->effectResource))
{
BTSpawnDamageEffect(owner, d->effectResource, // explosion AT the zone's
zone->segmentIndex); // segment (world position)
}
zone->ApplyDamageGraphicState(d->graphicState); // destroyed skin (graphic state)
// A graphic-state change means the segment's MODEL changed (intact ->
// destroyed variant, keyed by GetVideoObjectName(skl, gstate)). Fire
+6
View File
@@ -211,6 +211,12 @@
int
segmentIndex; // @0x194 GetSegmentIndex() result
public:
// The zone's segment slot -- consumed by the @004d097c per-zone effect
// dispatcher (BTL4VideoRenderer::StartEntityEffectImplementation).
int EffectSegmentIndex() const { return segmentIndex; }
protected:
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Damage Support
//
+105 -16
View File
@@ -440,6 +440,18 @@ void
Check(message);
if (message->dataContents > 0)
{
CycleControlModeNow();
}
}
//
// The mode-cycle body, shared by the console-button message handler above and
// the desktop 'M' key (mech4 key poll -> gBTModeCycle).
//
void
MechControlsMapper::CycleControlModeNow()
{
{
controlMode = (ControlMode)(controlMode + 1);
if (controlMode > VeteranMode)
@@ -448,23 +460,28 @@ void
}
NotifyOfControlModeChange(controlMode); // vtable+0x48
Mech *mech = GetMech();
int torso = *(int *)((int)mech + 0x438);
int cockpit = *(int *)((int)mech + 0x5b4);
if (controlMode == BasicMode)
// TYPED torso reconfiguration (2026-07-13): the raw block this
// replaces wrote the BINARY's offsets (torso+0x1f0/0x274/0x220...)
// straight onto OUR compiled Torso -- the databinding trap: garbage
// writes into whatever members live there in this build. The
// observable semantics via named members: Basic clears the analog
// axes and recenters (the sim's centerCommand -> Recenter); the
// assisted modes just free the torso (the sim clamps to the authored
// limits on its own).
Mech *mech = GetMech();
Torso *torso = (mech != 0) ? (Torso *)mech->GetTorsoSubsystem() : 0;
if (torso != 0)
{
*(LWord *)(torso + 0x1f0) = 0; // no free aim
*(LWord *)(torso + 0x274) = 1; // auto-center on
*(LWord *)(torso + 0x220) = *(LWord *)(torso + 0x228); // recenter yaw
*(LWord *)(torso + 0x224) = *(LWord *)(torso + 0x22c); // recenter pitch
*(LWord *)(cockpit + 0x2a0) = 1;
}
else if ((unsigned)(controlMode - 1) < 2) // Standard / Veteran
{
*(LWord *)(torso + 0x220) = *(LWord *)(torso + 0x230); // yaw -> limit
*(LWord *)(torso + 0x224) = *(LWord *)(torso + 0x234); // pitch -> limit
if (controlMode == BasicMode)
{
torso->SetAnalogTwistAxis(0.0f);
torso->SetAnalogElevationAxis(0.0f);
torso->CommandRecenter();
}
// Standard/Veteran: nothing to force -- the sim's limits govern.
}
DEBUG_STREAM << "[mode] control mode -> " << (int)controlMode
<< " (0=Basic 1=Standard 2=Veteran)" << std::endl;
}
Check_Fpu();
}
@@ -632,6 +649,31 @@ void
key_throttle = 0.08f; // near-idle half-cycle
}
}
// BT_FORCE_SWEEP=<period>: triangle-sweep the forced throttle 0.2..0.9
// continuously -- mimics a keyboard key-hold sweeping the lever every
// frame (a type-2 speed record per frame), the keyboard-skip repro.
{
static float s_swpP = -1.0f, s_swpClock = 0.0f;
if (s_swpP < 0.0f)
{
const char *fw = getenv("BT_FORCE_SWEEP");
s_swpP = fw ? (float)atof(fw) : 0.0f;
}
if (s_swpP > 0.0f)
{
s_swpClock += time_slice;
float ph = fmodf(s_swpClock, 2.0f * s_swpP) / s_swpP; // 0..2
float tri = (ph < 1.0f) ? ph : (2.0f - ph); // 0..1..0
key_throttle = 0.2f + 0.7f * tri;
// BT_FORCE_STEP=1: quantize the swept throttle to the RIO
// Ranger 0.05 grid (the authentic ADC/deadband step) -- the
// stepped-vs-continuous A/B for the speed-change glitch.
static int s_swpStep = -1;
if (s_swpStep < 0) s_swpStep = getenv("BT_FORCE_STEP") ? 1 : 0;
if (s_swpStep)
key_throttle = floorf(key_throttle / 0.05f + 0.5f) * 0.05f;
}
}
// BT_FORCE_FLIP=<t>: invert the forced throttle after t sim-seconds
// (the mid-stride direction-change repro).
static float s_hFlip = -1.0f;
@@ -655,7 +697,54 @@ void
}
throttlePosition = (key_throttle >= 0.0f) ? key_throttle : -key_throttle;
reverseThrust = (key_throttle < 0.0f) ? 1 : 0;
stickPosition.x = key_turn;
// CONTROL-MODE AXIS ROUTING (2026-07-13, the pod mapping): in
// BASIC the stick steers the legs (turn) and the torso auto-
// centers; in STANDARD/VETERAN the stick is the TORSO (free aim /
// twist) and the PEDALS steer -- so the keyboard's A/D feed the
// pedals and Q/E ('the stick') feed the twist. 'M' cycles modes
// (the same body the pod console button message drives, so the
// CONTROL MODE gauge tracks).
{
extern int gBTModeCycle;
extern float gBTTwistAxis;
extern int gBTTorsoRecenter;
if (gBTModeCycle)
{
gBTModeCycle = 0;
CycleControlModeNow();
}
if (controlMode == BasicMode)
{
stickPosition.x = key_turn;
pedalsPosition = 0.0f;
}
else
{
stickPosition.x = gBTTwistAxis; // the torso axis
pedalsPosition = key_turn; // A/D = the pedals
// 'X' recenter pulse (2026-07-13): one-frame centerCommand
// -> the sim arms recenterActive and Recenter (@004b6918)
// slews the torso home; any Q/E input cancels it sim-side.
// centerCommand is a pod BUTTON state, so the writer clears
// it while unpressed (Basic's own path re-asserts every
// frame; this branch owns it in Standard/Veteran).
{
Torso *rcTorso = (Torso *)mech->GetTorsoSubsystem();
if (rcTorso != 0)
{
if (gBTTorsoRecenter)
{
gBTTorsoRecenter = 0;
rcTorso->CommandRecenter();
}
else
{
rcTorso->ClearRecenterCommand();
}
}
}
}
}
stickPosition.y = 0.0f;
}
}
+5
View File
@@ -104,6 +104,11 @@ class Pilot;
CycleControlModeMessageHandler( // @004afbe0
ReceiverDataMessageOf<ControlsButton> *message
);
// The shared mode-cycle body (the console-button handler above + the
// desktop 'M' key both drive it).
void
CycleControlModeNow();
void
CycleDisplayModeMessageHandler( // @004afcac
ReceiverDataMessageOf<ControlsButton> *message
+25 -3
View File
@@ -435,11 +435,33 @@ namespace Vector
//
// P3 GAIT CUTOVER: backed by the real engine Quaternion (were no-op stubs).
// ReconQuatIdentity(q, &id) -> q = Quaternion::Identity
// ReconQuatIntegrate(out,in,d) -> out = in integrated by angular delta d
// (engine Quaternion::Add(source, Vector3D) == FUN_00409f58)
// ReconQuatIntegrate(out,in,d) -> out = in composed with an EXACT rotation by
// angular delta d (= the real FUN_00409f58).
// All callers pass (Quaternion*,Quaternion*) / (Quaternion*,Quaternion*,Vector3D*).
inline void ReconQuatIdentity(Quaternion *q, const Quaternion * /*id*/) { *q = Quaternion::Identity; }
inline void ReconQuatIntegrate(Quaternion *out, const Quaternion *in, const Vector3D *delta) { out->Add(*in, *delta); }
// EXACT axis-angle quaternion integration -- the real FUN_00409f58 (part_000.c:9359),
// verified from the decomp. A prior stand-in wrongly used `out->Add(*in,*delta)` (a
// small-angle VECTOR add): correct every-frame on the master (tiny angle) but it
// DIVERGES over a long dead-reckon gap -- the spinning-peer heading drifting to ~180deg
// then snapping. The binary builds the unit rotation quaternion from the rotation vector
// `delta` (angle=|delta|, axis=delta/angle) as { axis*sin(angle/2), cos(angle/2) } and
// Hamilton-multiplies it onto `in` (FUN_00409d9c: out = in (X) deltaQuat).
inline void ReconQuatIntegrate(Quaternion *out, const Quaternion *in, const Vector3D *delta)
{
const Scalar ang = delta->Length();
if (ang > 1.0e-6f)
{
const Scalar half = (Scalar)(fmodf((float)ang, 6.2831853f) * 0.5f); // reduce2pi, then halve
const Scalar s = (Scalar)(sinf((float)half) / ang); // sin(angle/2)/angle
const Quaternion dq((Scalar)(delta->x * s), (Scalar)(delta->y * s),
(Scalar)(delta->z * s), (Scalar)cosf((float)half));
out->Multiply(*in, dq); // in (X) dq
}
else
{
*out = *in;
}
}
template<class...A> inline Recon ReconQuatSlerp(A&&...) { return Recon(); }
template<class...A> inline Recon ComputeImpactDamage(A&&...) { return Recon(); }
+6 -5
View File
@@ -610,11 +610,12 @@ void
// -- the weapon-fire damage submission into the owning mech's
// SubsystemMessageManager (mech+0x434 -> AddDamageMessage @0049b6d8), NOT a
// HUD pip. The "HUD element record" reading below was the same 0x434
// misattribution the mapper suffered. The authentic weapon-side submission
// (per-weapon damageData + inflictingSubsystemID) is pending the
// damage-economy reconciliation (authored 0.25-scale amounts vs the
// bring-up kShotDamage=12); the interim submitter is the mech4 fire block
// routing through AddDamageMessage. This body stays dormant meanwhile.
// misattribution the mapper suffered. This body is the LIVE per-shot
// beam-damage path (called from Emitter::FireWeapon, emitter.cpp:289; decomp
// part_013.c:7758-7764) carrying the authentic per-weapon damageData +
// inflictingSubsystemID -- the damage-economy reconciliation (task #60)
// CONFIRMED the authored-DamageAmount economy; kShotDamage is retired to the
// kill-score and env-gated diag hooks (task #60).
//
void
MechWeapon::SendDamageMessage(Entity *target)
+13 -3
View File
@@ -73,7 +73,7 @@
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
const Point3D &targetPos, Scalar speed, Scalar damage,
const Vector3D *launch_velocity = 0, int guided = 1,
int weapon_subsys = -1);
int weapon_subsys = -1, int splash_burst = 0);
//###########################################################################
// Port-side salvo replication state (missile-visibility wave).
@@ -305,9 +305,19 @@ void MissileLauncher::FireWeapon()
// on the authored up-tilt, then the seeker loft + steering arc it onto the target.
int nmiss = (missileCount > 0 && missileCount < 40) ? missileCount : 1;
for (int i = 0; i < nmiss; ++i)
BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount,
// DAMAGE + SPLASH ONCE PER SALVO (task #62 fix): the arcade fires ONE
// cluster Missile per trigger, and its zone damage is applied EXACTLY ONCE
// -- DamageZone::TakeDamage (@0041e4e0) is `damageLevel += amount*scale`
// and IGNORES burstCount. The port draws that cluster as N visual rounds;
// damaging on EVERY round applied the hit N times = ~missileCount-x too
// lethal (the "2-shot kill" regression). Only the LEAD round (i==0)
// carries the missile's damageAmount + the cluster splash; the rest are
// VISUAL (damage 0) -- the ripple of tracers, no extra damage.
BTPushProjectile(muzzle, o, target, targetPos, speed,
(i == 0) ? damageData.damageAmount : 0.0f,
&launchVelocity, 1,
subsystemID /*messmgr explosion bundling at impact (task #7)*/);
subsystemID /*messmgr explosion bundling at impact (task #7)*/,
(i == 0) ? nmiss : 0 /*salvo-lead: cluster splash baseBurst*/);
// Salvo replication (missile-visibility wave): bump the fire counter + stamp
// the aim point; the extended update record carries both to peer nodes.
+6 -3
View File
@@ -169,9 +169,12 @@ class Missile;
protected:
// @004bcc60 (ProjectileWeapon spawn path) -- builds the streamed-entity
// spawn descriptor for a Missile (entity ClassID 0xBD0), seeds it with
// muzzleVelocity + the per-missile Damage, and hands it to the entity
// manager. Invoked missileCount times per trigger pull.
// spawn descriptor for ONE Missile (entity ClassID 0xBD0), seeds it with
// muzzleVelocity + the per-missile Damage (authored/missileCount) and the
// burstCount cluster-hit roll, and hands it to the entity manager.
// (task #60 correction: ONE spawn per trigger pull, NOT missileCount
// spawns -- burstCount carries the cluster count to the single missile;
// the PORT re-expresses this as N flying rounds, see mislanch.cpp.)
void
FireWeapon();
+71 -7
View File
@@ -75,7 +75,12 @@ static const Scalar MaxOutputVoltage = 1.0f; // _DAT_004b2154 / _DAT_004b2258
static const Scalar RatedVoltageRef = 1.0f; // _DAT_004b1d10 (myomer rated reference)
static const Scalar DegradedDriveScale = 0.5f; // _DAT_004b1d14 (heat-degradation throttle)
static const Scalar ResourceUnset = -1.0f; // _DAT_004b177c / _DAT_004b1a3c / _DAT_004b2580
static const Scalar MinVoltageScale = 1.0f; // _DAT_004b1924 (PowerWatcher threshold scale)
static const Scalar MinVoltageScale = 0.01f; // _DAT_004b1924 -- a 10-byte x87 literal
// (0a d7 a3 70 3d 0a d7 a3 f8 3f = 0.01,
// byte-read from the exe, task #57): converts
// the authored MinVoltagePercent to a fraction.
// Was 1.0f, which made minVoltage 100x too big
// and latched the watchdog brownout (level 1).
//###########################################################################
@@ -1191,8 +1196,12 @@ PowerWatcher::SharedData
Derivation*
PowerWatcher::GetClassDerivations()
{
// Real base ctor is @004aeb40 (HeatWatcher); HeatableSubsystem stands in.
static Derivation classDerivations(HeatableSubsystem::GetClassDerivations(), "PowerWatcher");
// Chains to the REAL base HeatWatcher (@004aeb40) -- task #57: the old
// HeatableSubsystem stand-in predated the base-chain re-base and broke
// IsDerivedFrom(HeatWatcher) for every PowerWatcher descendant, which
// silently skipped the Torso/Searchlight/ThermalSight in the factory
// watcher-CONNECT pass (their watchdogs stayed at 0 = never Ready).
static Derivation classDerivations(HeatWatcher::GetClassDerivations(), "PowerWatcher");
return &classDerivations;
}
@@ -1254,13 +1263,68 @@ Logical
}
//
// @004b1804 -- delegate to the base simulation.
// @004b1804 (slot 10) -- ResetToInitialState: chain to the HeatWatcher base.
// (This body was previously MISLABELED as the Simulation. The vtable
// @0050f99c slot 10 (offset 0x28) is 004b1804, exactly where every other
// subsystem carries ResetToInitialState; the registered Performance PTR
// @0050f5fc points at 004b181c -- UpdateWatch below.)
//
void
PowerWatcher::Simulation(Scalar time_slice)
PowerWatcher::ResetToInitialState(Logical /*powered*/)
{
HeatWatcher::ResetToInitialState(True); // FUN_004aea9c = HeatWatcher::ResetToInitialState
(void)time_slice;
HeatWatcher::ResetToInitialState(True); // FUN_004aea9c
}
//
// @004b181c -- the per-tick watch update: THE registered Performance
// (PTR @0050f5fc -> 004b181c), and called directly by the Torso master/copy
// simulations (@004b5cf0/@004b65f8 first line). Ghidra missed the function
// start; recovered from the raw pseudocode at part_013.c:1645:
//
// FUN_004aeac4(this); // heat mirror
// watched = watchedLink.Resolve(); // +0x114
// gen = watched->voltageSource.Resolve(); // +0x1D0
// watchdogAlarm.SetLevel(watched->elecLevel); // mirror +0x278
// if (elecLevel == 4 && gen && gen->outputVoltage(+0x1DC)
// <= minVoltage(+0x180) * gen->ratedVoltage(+0x1D8))
// watchdogAlarm.SetLevel(1); // brownout
//
// The watchdog MIRRORS the watched subsystem's electrical state -- this is
// what drives ElectricalStateLevel()==Ready on the Torso (the twist-rate
// power gate) and WatchedVoltageLevel on Searchlight/ThermalSight.
//
void
PowerWatcher::UpdateWatch()
{
WatchSimulation(0.0f); // FUN_004aeac4 -- heat-alarm mirror
PoweredSubsystem *watched = (PoweredSubsystem *)watchedLink.Resolve(); // FUN_00417ab4(this+0x114)
if (watched == 0)
{
// The binary derefs unconditionally (the link always binds on the
// master node). Null only on replicants / bring-up: hold Not-Ready.
watchdogAlarm.SetLevel(0);
return;
}
int level = watched->electricalStateAlarm.GetLevel(); // *(watched+0x278)
watchdogAlarm.SetLevel(level); // FUN_0041bbd8(this+0x184, level)
Generator *source = (Generator *)watched->ResolveVoltageSource(); // FUN_00417ab4(watched+0x1d0)
if (level == PoweredSubsystem::Ready && source != 0
&& source->MeasuredVoltage() <= minVoltage * source->RatedVoltageOf())
{
watchdogAlarm.SetLevel(1); // brownout: Ready but sagging source
}
}
//
// The Performance wrapper (PTR @0050f5fc -> @004b181c takes no dt).
//
void
PowerWatcher::Simulation(Scalar /*time_slice*/)
{
UpdateWatch();
}
//
+8 -1
View File
@@ -553,7 +553,14 @@ class Generator;
Logical
HandleMessage(int message); // @004b179c (chains to base @004aea84)
void
Simulation(Scalar time_slice); // @004b1804 (chains to base @004aea9c)
ResetToInitialState(Logical powered); // @004b1804 (slot 10; chains to base @004aea9c)
void
UpdateWatch(); // @004b181c -- watchdog mirror of the watched
// subsystem's electrical state (+ brownout);
// the Torso sims call this directly
void
Simulation(Scalar time_slice); // the registered Performance (PTR @0050f5fc
// -> @004b181c): forwards to UpdateWatch()
public:
// BASE-CHAIN RE-BASE: the shadow watchedLink was DELETED -- the binary has a
+144 -2
View File
@@ -95,9 +95,45 @@
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
const Point3D &targetPos, Scalar speed, Scalar damage,
const Vector3D *launch_velocity = 0, int guided = 1,
int weapon_subsys = -1);
int weapon_subsys = -1, int splash_burst = 0); // AC: no cluster splash (default 0)
//#############################################################################
// FIRE-VISUAL REPLICATION STATE (task #61) -- the AC twin of MissileLauncher's
// BTSalvoState (mislanch.cpp). Port-side table keyed by the weapon pointer
// (the 0x1D0 object is byte-locked): the master counts its shots + remembers
// the aim; the replicant edge-detects the counter and mirrors ONE visual round
// + DAFC muzzle flash so the enemy's cannon is visible on the peer.
//#############################################################################
namespace {
struct BTAcFireState
{
const void *owner;
int fired; // master: shots fired
int seen; // replicant: last counter applied (-1 = unsynced)
Point3D target; // master: the last shot's aim point
};
BTAcFireState gAcFireTable[64];
BTAcFireState &BTAcFireOf(const void *weapon)
{
int freeSlot = -1;
for (int i = 0; i < 64; ++i)
{
if (gAcFireTable[i].owner == weapon)
return gAcFireTable[i];
if (gAcFireTable[i].owner == 0 && freeSlot < 0)
freeSlot = i;
}
BTAcFireState &s = gAcFireTable[(freeSlot >= 0) ? freeSlot : 0];
s.owner = weapon;
s.fired = 0;
s.seen = -1;
s.target = Point3D(0.0f, 0.0f, 0.0f);
return s;
}
}
//#############################################################################
// Resolved read-only constants + unrecovered-helper stand-ins (see banner).
//
@@ -481,6 +517,16 @@ void
Logical
ProjectileWeapon::CheckForJam()
{
if (getenv("BT_JAM_LOG"))
DEBUG_STREAM << "[jam] " << GetName()
<< " T=" << currentTemperature
<< " degT=" << degradationTemperature
<< " failT=" << failureTemperature
<< " alarm=" << (int)heatAlarm.GetLevel()
<< " liveFire=" << (int)LiveFireEnabled()
<< " p=" << ((_DAT_004bc064 * currentTemperature) / failureTemperature)
<< "\n" << std::flush;
if (!LiveFireEnabled()) // *(*(this+0xd0)+400)+0x25c
return False;
@@ -681,6 +727,20 @@ void
Point3D muzzle;
GetMuzzlePoint(muzzle); // MechWeapon @004b9948
// GENUINE MUZZLE FLASH (task #61): the shipped DAFC fire-smoke blast at the
// gun-port -- BTDPL.INI psfx 6/14 "the effect used on all projectile guns".
// Fires on every AC shot (all ACs author TracerInterval=1); one ~0.2s
// auto-expiring burst. Default ON (BT_MUZZLE=0 disables for diagnostics).
{
const char *mz = getenv("BT_MUZZLE"); // default ON; =0 disables
if (mz == 0 || mz[0] != '0')
{
extern void BTFlashMuzzle(void *ownerMech, int seg_index, float mx, float my, float mz);
BTFlashMuzzle(owner, GetSegmentIndex(),
(float)muzzle.x, (float)muzzle.y, (float)muzzle.z);
}
}
// WAVE 7 Phase B: launch one flying ballistic round toward the owner's target (port
// reconstruction; the byte-exact world-entity Projectile is blocked by the 2007 engine
// Entity base mismatch -- see BTPushProjectile / mech4.cpp). Speed = |launchVelocity|.
@@ -701,11 +761,93 @@ void
0 /*aim straight at the pick*/, 0 /*BALLISTIC: no seeker on a shell*/,
subsystemID /*messmgr explosion bundling at impact (task #7)*/);
simulationFlags |= 0x1; // replication-dirty
// task #61: mark this shot for REPLICATION so the peer sees the enemy's
// cannon. ++fireCounter + ForceUpdate() -- IDENTICAL to the missile path
// (mislanch.cpp:316/326) and the emitter beam: the master's per-frame
// subsystem serialize writes this weapon's record (WriteUpdateRecord),
// Entity::UpdateMessageHandler routes it to the peer's replicant, and the
// replicant's ReadUpdateRecord mirrors the shot. (The old code set only
// `simulationFlags |= 0x1` -- the +0x28 instance flag, NOT the updateModel
// bit WriteSimulationUpdate walks -- so the AC record never serialized: the
// root cause of the invisible enemy autocannon.)
{
BTAcFireState &fs = BTAcFireOf(this);
++fs.fired;
fs.target = targetPos;
}
simulationFlags |= 0x1; // replication-dirty (state flag)
ForceUpdate(); // set the updateModel bit -> serialize the record
Check_Fpu();
}
//#############################################################################
// FIRE-VISUAL REPLICATION (task #61) -- the AC twin of MissileLauncher's salvo
// mirror. Master serializes the fire counter + aim after the MechWeapon base
// fields; the replicant edge-detects the counter and mirrors ONE visual round
// + DAFC muzzle flash from its OWN resolved muzzle -- so the enemy's autocannon
// is visible on the peer (the port's local flying round otherwise never left
// the firing node).
//#############################################################################
void
ProjectileWeapon::WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model)
{
MechWeapon::WriteUpdateRecord(message, update_model); // @004b9690 (alarm fields + subsystemID)
ProjectileWeapon__UpdateRecord *rec = (ProjectileWeapon__UpdateRecord *)message;
BTAcFireState &s = BTAcFireOf(this);
rec->recordLength = sizeof(ProjectileWeapon__UpdateRecord);
rec->fireCounter = s.fired;
rec->fireTarget = s.target;
}
void
ProjectileWeapon::ReadUpdateRecord(Simulation__UpdateRecord *message)
{
MechWeapon::ReadUpdateRecord(message); // @004b964c (alarm apply)
ProjectileWeapon__UpdateRecord *rec = (ProjectileWeapon__UpdateRecord *)message;
BTAcFireState &s = BTAcFireOf(this);
if (s.seen < 0)
{
s.seen = rec->fireCounter; // first sync: adopt silently
return;
}
if (rec->fireCounter == s.seen)
return;
s.seen = rec->fireCounter;
// Mirror ONE shot (a missed record collapses to one -- no phantom replays):
// a VISUAL-ONLY round (damage 0; the master's round delivers cross-pod
// damage) from this replicant's own muzzle toward the master's aim point,
// plus the DAFC muzzle flash.
Point3D mz;
GetMuzzlePoint(mz); // @004b9948
Scalar spd = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
+ launchVelocity.y*launchVelocity.y
+ launchVelocity.z*launchVelocity.z);
// Fly toward the master's aim if it replicated a real pick; else straight
// out the barrel (the shooter-frame launchVelocity, as the missile mirror
// does) so an untargeted shot still streaks forward, not toward the origin.
const int haveAim = (rec->fireTarget.x != 0.0f
|| rec->fireTarget.y != 0.0f
|| rec->fireTarget.z != 0.0f);
BTPushProjectile(mz, owner, 0 /*aim point only, no entity*/, rec->fireTarget,
spd, 0.0f /*VISUAL*/,
haveAim ? 0 : &launchVelocity, 0 /*ballistic*/, subsystemID);
const char *mzf = getenv("BT_MUZZLE"); // default ON; =0 disables
if (mzf == 0 || mzf[0] != '0')
{
extern void BTFlashMuzzle(void *ownerMech, int seg_index, float, float, float);
BTFlashMuzzle(owner, GetSegmentIndex(), (float)mz.x, (float)mz.y, (float)mz.z);
}
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] REPLICANT AC shot at(" << rec->fireTarget.x
<< "," << rec->fireTarget.y << "," << rec->fireTarget.z << ")\n" << std::flush;
}
//#############################################################################
// CreateStreamedSubsystem
//
+22
View File
@@ -195,6 +195,28 @@ class NotationFile;
void
PrintState(); // slot 13, @004bc6c8
// FIRE-VISUAL REPLICATION (task #61 -- the AC twin of MissileLauncher's
// salvo mirror, mislanch.hpp). The port's AC is a LOCAL flying round,
// so the enemy's cannon fire never reached the peer. Same proven path
// the missile + emitter use: FireWeapon calls ForceUpdate() (sets the
// updateModel bit), so the mech's per-frame subsystem serialize
// (mech4.cpp:3893 subsystem->PerformAndWatch(update_stream)) writes this
// record; Entity::UpdateMessageHandler routes it to the peer's replicant
// by subsystemID. The master serializes a monotonic fire counter + aim;
// the replicant edge-detects it and mirrors ONE visual round + DAFC
// muzzle flash. Fire STATE lives in a port-side table (projweap.cpp);
// the 0x1D0 object layout is byte-locked and must not grow.
struct ProjectileWeapon__UpdateRecord:
public MechWeapon::UpdateRecord
{
int fireCounter; // AC shots since spawn (edge-detected by peers)
Point3D fireTarget; // the aim point the round flew at
};
typedef ProjectileWeapon__UpdateRecord UpdateRecord;
void WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model); // slot 7
void ReadUpdateRecord(Simulation__UpdateRecord *message); // slot 6
protected:
// TASK #51 RENAME: slot 9 is TakeDamage (the old ReadUpdateRecord
// attribution was swapped; the record read is INHERITED from
+16
View File
@@ -198,6 +198,22 @@ Scalar
int ty = jt ? (int)jt->GetJointType() : 0;
if (ty < 3) // hinge
{
// task #59 discriminator (BT_HIP_LOG): log hinge writes so the
// jointhip walk-lean can be seen. EXTERIOR clips ramp jointhip
// to ~-8deg on stand->walk and hold it; INTERIOR ('i') clips
// never bind jointhip, so it stays silent. Sampled by joint
// slot so the stream stays readable.
if (jt && move_joints)
{
static const int s_hipLog = getenv("BT_HIP_LOG") ? 1 : 0;
if (s_hipLog)
{
const Radian ang = ((const Hinge *)cursor)->rotationAmount;
if (fabsf((float)ang) > 0.02f) // only the leaning ones
DEBUG_STREAM << "[hip] jointIdx=" << jointIndices[j]
<< " deg=" << ((float)ang * 57.2958f) << "\n" << std::flush;
}
}
if (jt && move_joints) jt->SetHinge(*(const Hinge *)cursor); // FUN_0041cfc8
cursor += 8;
}
+101 -14
View File
@@ -94,6 +94,9 @@ namespace {
inline Scalar
RecordField(Simulation::UpdateRecord *record, int byte_offset)
{ return *(const Scalar*)((const char*)record + byte_offset); }
inline void
WriteRecordField(Simulation::UpdateRecord *record, int byte_offset, Scalar value)
{ *(Scalar*)((char*)record + byte_offset) = value; }
}
//###########################################################################
@@ -243,19 +246,29 @@ Torso::Torso(
// rate. TorsoSimulation drives the sweep (below). Remove after verification.
if (isDamagedCopy == 0 && getenv("BT_FORCE_TORSO"))
{
// ⚠ HARNESS TRAP (task #58 post-mortem): this hook used to override the
// joint nodes UNCONDITIONALLY with the BLH demo names -- on a mech whose
// resource already resolved REAL torso joints (the MadCat's jointtorso)
// that silently redirected the whole sweep into the SHADOW hinge, and a
// probe run "proved" the cockpit camera ignored the twist (it doesn't;
// the joint chain delivers it). Now: only fill joints that did NOT
// resolve from the resource; a twist-capable mech sweeps its real ones.
horizontalEnabled = True; // @0x250
const char *mj = getenv("BT_FORCE_TORSO_JOINT");
if (mj == 0 || *mj == '\0') mj = "jointshakey2";
horizontalJointNode = ResolveJoint(mj); // torso body (ball)
horizontalShadowJointNode = ResolveJoint("jointtshadow"); // shadow twist (hingey)
horizontalLimitLeft = 0.7f; // @0x1E0 ~40 deg
horizontalLimitRight = -0.7f; // @0x1DC
if (horizontalJointNode == 0)
{
const char *mj = getenv("BT_FORCE_TORSO_JOINT");
if (mj == 0 || *mj == '\0') mj = "jointshakey2";
horizontalJointNode = ResolveJoint(mj); // torso body (ball)
horizontalShadowJointNode = ResolveJoint("jointtshadow"); // shadow twist (hingey)
horizontalLimitLeft = 0.7f; // @0x1E0 ~40 deg
horizontalLimitRight = -0.7f; // @0x1DC
}
baseTwistRate = 1.0f; // @0x23C rad/s slew
if (getenv("BT_TORSO_LOG"))
{
DEBUG_STREAM << "[torso] FORCE-ENABLE '" << mj << "' -> " << (void*)horizontalJointNode;
DEBUG_STREAM << "[torso] FORCE-ENABLE node=" << (void*)horizontalJointNode;
if (horizontalJointNode) DEBUG_STREAM << " type=" << (int)horizontalJointNode->GetJointType();
DEBUG_STREAM << " ; shadow 'jointtshadow' -> " << (void*)horizontalShadowJointNode;
DEBUG_STREAM << " ; shadow node=" << (void*)horizontalShadowJointNode;
if (horizontalShadowJointNode) DEBUG_STREAM << " type=" << (int)horizontalShadowJointNode->GetJointType();
DEBUG_STREAM << "\n" << std::flush;
}
@@ -387,14 +400,17 @@ Logical
}
//
// @004b6a78 (slot 6) [CONFIDENT] -- network/replay update record. Samples the
// @004b6a78 (slot 6) [CONFIDENT] -- the REPLICANT-side apply (engine semantics
// [T0]: ReadUpdateRecord = write the object FROM the record). Samples the
// clock (FUN_00414b60) into lastUpdateTime, biasing it forward by one interval
// when the elapsed window is below MinSlewMs, chains to Subsystem::WriteUpdateRecord
// (FUN_0041bd34), then writes twistAtUpdate / twistVelocity / twistRate from the
// record fields (record +0x10 / +0x14 / +0x18).
// when the elapsed window is below MinSlewMs, chains to the base apply
// (FUN_0041bd34 = ReadUpdateRecord, 2 args), then applies twistAtUpdate /
// twistVelocity / twistRate from the record extras (+0x10 / +0x14 / +0x18).
// (Task #57: previously mislabeled as WriteUpdateRecord -- the direction flip
// made the MASTER consume its own blank record.)
//
void
Torso::WriteUpdateRecord(UpdateRecord *message, int update_model)
Torso::ReadUpdateRecord(UpdateRecord *message)
{
lastUpdateTime = GetCurrentTime(); // @0x254
if ((Scalar)(lastUpdateTime - GetCreationTime()) / MsPerSecond < MinSlewMs)
@@ -402,13 +418,41 @@ void
lastUpdateTime += (lastUpdateTime - GetCreationTime()); // stretch tiny windows
}
Subsystem::WriteUpdateRecord(message, update_model); // FUN_0041bd34
Subsystem::ReadUpdateRecord(message); // FUN_0041bd34
twistAtUpdate = RecordField(message, 0x10); // @0x21C
twistVelocity = RecordField(message, 0x14); // @0x1E8
twistRate = RecordField(message, 0x18); // @0x238
}
//
// @004b6a1c (slot 7) [CONFIDENT, raw-disasm recovery -- Ghidra missed the
// function start] -- the MASTER-side serialize (engine semantics [T0]:
// WriteUpdateRecord = fill the record FROM the object). Chains the base
// producer (FUN_0041c500(this, record, model), 3 args), extends the record to
// 0x1C bytes and appends currentTwist / twistVelocity / twistRate at
// +0x10/+0x14/+0x18 (exactly what the replicant's @004b6a78 applies), then
// snapshots twistAtUpdate = currentTwist:
//
// 004b6a2e call 0041c500 ; base WriteUpdateRecord
// 004b6a38 mov [record], 0x1C ; recordLength = 0x1C
// 004b6a44+ record+0x10 = this+0x1D8 ; currentTwist
// 004b6a51 record+0x14 = this+0x1E8 ; twistVelocity
// 004b6a5a record+0x18 = this+0x238 ; twistRate
// 004b6a63+ this+0x21C = this+0x1D8 ; twistAtUpdate = currentTwist
//
void
Torso::WriteUpdateRecord(UpdateRecord *message, int update_model)
{
Subsystem::WriteUpdateRecord(message, update_model); // FUN_0041c500
message->recordLength = 0x1C; // base header 0x10 + 3 Scalars
WriteRecordField(message, 0x10, currentTwist); // @0x1D8
WriteRecordField(message, 0x14, twistVelocity); // @0x1E8
WriteRecordField(message, 0x18, twistRate); // @0x238
twistAtUpdate = currentTwist; // @0x21C snapshot at send
}
//#############################################################################
// Per-frame simulation
@@ -464,6 +508,34 @@ void
analogTwistAxis = ((++s_sweep / 90) & 1) ? -1.0f : 1.0f; // +/- every ~90 frames
}
// TORSO GATE PROBE (BT_TORSO_LOG): why is the twist rate zero?
if (getenv("BT_TORSO_LOG"))
{
static int s_tl = 0;
if ((s_tl++ % 120) == 0)
{
PoweredSubsystem *w = (PoweredSubsystem *)watchedLink.Resolve();
DEBUG_STREAM << "[torso] hmOff=" << (int)HeatModelOff()
<< " elec=" << (int)ElectricalStateLevel()
<< " heatState=" << (int)HeatStateLevel()
<< " rate=" << effectiveTwistRate
<< " base=" << baseTwistRate
<< " hEn=" << (int)horizontalEnabled
<< " limits=(" << horizontalLimitRight << ".." << horizontalLimitLeft << ")"
<< " axis=" << analogTwistAxis
<< " twist=" << currentTwist
<< " wIdx=" << watchedSubsystem
<< " w=" << (void*)w
<< " wElec=" << (w ? w->electricalStateAlarm.GetLevel() : -1)
<< " wSrc=" << (void*)(w ? w->ResolveVoltageSource() : 0);
Generator *g = (w ? (Generator *)w->ResolveVoltageSource() : 0);
if (g)
DEBUG_STREAM << " gOut=" << g->MeasuredVoltage()
<< " gRated=" << g->RatedVoltageOf()
<< " minV=" << minVoltage;
DEBUG_STREAM << std::endl;
}
}
Scalar twistStep = effectiveTwistRate * time_slice;
Scalar elevStep = effectiveElevationRate * time_slice;
@@ -616,6 +688,21 @@ void
// external joint pass as the master path (see TorsoSimulation for the @004b67ec
// note). Harmless in single-player bring-up (no copies); correct for MP.
UpdateJoints(); // FUN_004b67ec
// COPY-SIDE PROBE (BT_TORSO_LOG): where does the replicant's twist come from?
if (getenv("BT_TORSO_LOG"))
{
static int s_cl = 0;
if ((s_cl++ % 120) == 0)
DEBUG_STREAM << "[torso-copy] cur=" << currentTwist
<< " target=" << targetTwist
<< " atUpd=" << twistAtUpdate
<< " rate=" << twistRate
<< " vel=" << twistVelocity
<< " lastUpd=" << lastUpdateTime
<< " now=" << GetCurrentTime()
<< " copy=" << (int)isDamagedCopy << std::endl;
}
}
+18 -4
View File
@@ -156,11 +156,13 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
void SetMovedFlag() { ForceUpdate(); }
// Local helper: "is value pinned at this software limit?".
Logical AtLimit(Scalar value, Scalar limit) const { return fabsf(value - limit) <= 0.0001f; }
// CROSS-FAMILY delegate shims -- still no-ops (no layout impact; STEP-4 will
// chain them to the real PowerWatcher/HeatWatcher base once the Torso is live).
// CROSS-FAMILY delegate shims (STEP-4): UpdateWatch now chains to the real
// PowerWatcher per-tick watch body (@004b181c, task #57) -- this is what
// raises watchdogAlarm to Ready and un-gates the twist rate. The other
// two remain no-ops (no layout impact).
void WatcherResetToInitialState() {}
Logical WatcherHandleDeathMessage(Message & /*m*/) { return False; }
void WatcherUpdateWatch() {}
void WatcherUpdateWatch() { UpdateWatch(); } // FUN_004b181c (body: powersub.cpp)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Test Class Support
@@ -189,6 +191,8 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
// Controls (@0x1F0 twist, @0x1F4 elevation); proportional, no button ramp.
void SetAnalogTwistAxis(Scalar v) { analogTwistAxis = v; }
void SetAnalogElevationAxis(Scalar v) { analogElevationAxis = v; }
void CommandRecenter() { centerCommand = 1; } // @0x208 (Basic-mode re-center)
void ClearRecenterCommand() { centerCommand = 0; } // button released (writer-owned state)
Logical GetHorizontalEnabled() const { return horizontalEnabled; } // @0x250 (mapper free-aim gate @004afd10)
// Reachable horizontal (yaw) half-arc the guns can be brought to bear by
@@ -224,7 +228,17 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
// Subsystem virtual overrides (slots on vtable @0051103c)
//
public:
void WriteUpdateRecord(UpdateRecord *message, int update_model); // slot 6, @004b6a78
// Update-record pair (task #57 direction fix): the ENGINE's semantics
// [T0 SIMULATE.cpp] are Write = PRODUCE the outgoing record (master),
// Read = APPLY the incoming record (replicant). @004b6a78 (slot 6,
// base chain 41bd34 = ReadUpdateRecord) CONSUMES record+0x10/14/18 --
// it is the READ side, previously mislabeled Write; the real WRITE
// side is @004b6a1c (slot 7, base chain 41c500, 3 args), which Ghidra
// missed -- recovered from raw disasm. With the old flip the master
// never serialized twist and clobbered its own fields from the
// uninitialized stream buffer (the replicant's 0xCDCDCDCD twist pin).
void ReadUpdateRecord(UpdateRecord *message); // slot 6, @004b6a78 (apply)
void WriteUpdateRecord(UpdateRecord *message, int update_model); // slot 7, @004b6a1c (serialize)
Logical HandleDeathMessage(Message &message); // slot 9, @004b5be0 -> @004b179c
void ResetToInitialState(); // slot 10, @004b5bf8
+5 -2
View File
@@ -9,8 +9,11 @@ the BLH (3 lasers + 2 PPCs + 2 MissileLaunchers), AddWeapon @004cdac0 store map
(part_014.c:4827-4837; both state attrs are named "SimulationState", strings @51d526/51d577).
Draw hook = `BTDrawReticle(device)` after the 3D scene, cockpit view only. The binary Execute
@004cdcf0 is an UN-EXPORTED gap → Draw dynamics are best-effort [T3] (caret slide from live
target range; pip A lit on TargetWithinRange else ring B). Step 6 (blx_cop canopy shell) and the
PNAME1-8.bgf 3D pip meshes remain DEFERRED — tracked in context/open-questions.md.
target range; pip A lit on TargetWithinRange else ring B). **[SUPERSEDED: Execute @004cdcf0 was
RECOVERED task #37 (capstone disasm → reference/decomp/reticle_execute_004cdcf0.disasm.txt) —
Draw dynamics are now [T1]; pip lighting is FIRE-CYCLE state, not TargetWithinRange; crosshair
positioning re-corrected task #58 — see context/gauges-hud.md.]** Step 6 (blx_cop canopy shell)
and the PNAME1-8.bgf 3D pip meshes remain DEFERRED — tracked in context/open-questions.md.
## The recovered dpl2d recorder API (libDPL 2D display lists) [T1]
+37
View File
@@ -0,0 +1,37 @@
import struct
data = open(r'C:/git/bt411/content/BTL4.RES','rb').read()
n = len(data)
def f32(o): return struct.unpack_from('<f', data, o)[0]
def i32(o): return struct.unpack_from('<i', data, o)[0]
def cstr(o, m=32):
s = data[o:o+m].split(b'\0')[0]
try: return s.decode('ascii')
except: return None
# find AFC100 subsystem records (name @+0, classID @+0x20)
hits=[]
i=0
while i < n-0x28:
if data[i:i+7]==b'AFC100\0':
cid=struct.unpack_from('<I',data,i+0x20)[0]
sz=struct.unpack_from('<I',data,i+0x24)[0]
if 0xBB0<=cid<=0xBE5 and 0xE0<=sz<=0x300:
hits.append((i,cid,sz))
i+=1
print("AFC100 records:", [(hex(p),hex(c),hex(s)) for p,c,s in hits])
if hits:
p,cid,sz=hits[0]
print(f"\n=== AFC100 @ {hex(p)} classID={hex(cid)} size={hex(sz)} ===")
# dump the offsets the loader reads (per heat.cpp / mechweap.hpp comments)
fields = {
0xE4:"startingTemp", 0xE8:"degradationTemp", 0xEC:"failureTemp",
0xF0:"thermalConductance", 0xF4:"thermalMass",
0x1A4:"heatCostToFire", 0x1CC:"minJamChance",
}
for off,name in sorted(fields.items()):
print(f" +{hex(off):>6} {name:20} f32={f32(p+off):<16g} i32={i32(p+off)}")
print("\n --- full float scan (offset: value) for plausible fields 0xE0..0x1E0 ---")
for off in range(0xE0, 0x1E0, 4):
v=f32(p+off)
if abs(v)>1e-3 and abs(v)<1e12:
print(f" +{hex(off):>6}: {v:g}")
+9 -1
View File
@@ -122,7 +122,7 @@ def serve_pod(hostport, egg_bytes):
try:
data = s.recv(4096)
if not data:
print(f"[{name}] pod closed the console socket")
print(f"[{name}] pod closed the console socket", flush=True)
return
# Expect the AcknowledgeEggFile (and possibly other console traffic).
if len(data) >= 24:
@@ -134,6 +134,14 @@ def serve_pod(hostport, egg_bytes):
print(f"[{name}] pod->console {len(data)} bytes")
except socket.timeout:
continue
except OSError as e:
# Previously UNHANDLED: a reset/abort here killed the thread silently
# (buffered stdout lost) and the console process exited -- leaving the
# pods holding dead console sockets. Log it and keep the thread alive
# (idle) so the process + diagnosis survive.
print(f"[{name}] console socket error: {e!r} -- idling", flush=True)
while True:
time.sleep(60)
def main():