Files
BT412/scratchpad/fanout_synthesis.md
arcattackandClaude Fable 5 8ed6184d65 Combat: AUTHENTIC weapon groups -- streamed per-mech button bindings + the real fire chain (task #5)
The recovered system: fire channels = LBE4ControlsManager buttonGroups
(0x40/0x45/0x46/0x47); default groups = the per-mech type-6 controls-map
resource in BTL4.RES, installed by the T0 CreateStreamedMappings the port
already called -- it needed only the TriggerState attribute (id 0x13 PINNED
to the binary value; fireImpulse@0x31C is the binary's TriggerState) and an
input feed.  Keyboard/harness now push press/release edges into the button
groups; the gBT*Trigger bypasses, per-type keyboard split and 1,0 pulse
hack are retired -- weapons sharing a button fire TOGETHER (madcat Trigger
= 4 weapons).  Myomers @4b9550/@4b95b8 misattribution corrected (they are
MechWeapon ConfigureMappables/ChooseButton).  Verified 2-node: kill through
the authentic chain (12 hits vs ~36 pre-groups).  Config-mode session
(regrouping UI) = the remaining stage, KB-scoped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:06:29 -05:00

319 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
All investigator claims re-verified byte-exact against `section_dump.txt` (my own capstone scripts: `C:\git\bt411\scratchpad\dis_4b2980.py`, `C:\git\bt411\scratchpad\dis_range.py`). Constants confirmed from the dump: `0x4b2d80=0.0f, 0x4b2d84=0.5f, 0x4b2d88=1.3f (0x3FA66666)`, `0x4ab170=0.5f, 0x4ab174=-1.1f, 0x4ab178=0.0f`, `0x4bc3f4=3.0f, 0x4bc3f8=0.0625f`. No disagreements between investigators survived re-reading; one nuance sharpened below (the two random-sign idioms differ: fan-out `jb` = "first roll < 0.5 → negative value roll"; gait jolt = "value roll first, negate if SECOND roll > 0.5 strict").
# IMPLEMENTATION PLAN — damage→gyro fan-out (FUN_004b2980) + the direct crunch kicks
## 0. Prerequisite header fixes (do these first)
**(F1) `C:\git\bt411\game\reconstructed\gyro.hpp:323-325`** — the binary reads `gyro+0x390..0x398` as ONE `Vector3D` (disasm @4b2d2b: `lea eax,[ebx+0x390]` then loads `[eax]/[eax+4]/[eax+8]` as the 3rd call's direction). Replace the three scalars:
```cpp
// @0x390 (this[0xE4..0xE6]) -- the VIBRATION AXIS: ctor init (0,1,0) =
// straight up. Read as one Vector3D by ApplyDamageResponse @4b2d2b
// (the second ApplyDamageImpulse = the vibration shake). [T1]
// (Was mis-split as scalars animationOffset/animationScale/animationPhase.)
Vector3D vibrationDirection; // @0x390 init (0.0f, 1.0f, 0.0f)
```
and `gyro.cpp:309-311`
```cpp
vibrationDirection = Vector3D(0.0f, 1.0f, 0.0f); // @0x390 (0,1,0 = up)
```
Add to the lock block (gyro.cpp:~180): `static_assert(offsetof(Gyroscope, vibrationDirection) == 0x390, "vibrationDirection @0x390");` (no other code reads the old names — grep-verified).
**(F2) `C:\git\bt411\game\reconstructed\mech.hpp:662`** — `int clipLoadGuard; // @0x5c4` is used by the binary as a FLOAT rumble-period timer (`fld [ebx+0x5c4]; fcomp 0.0` @4aa2eb, `fsubr` @4aa359, stores `0x3ecccccd` @4aa347). Re-type/rename:
```cpp
Scalar gyroRumbleTimer; // @0x5c4 binary: float rumble-period countdown
// (engaged-gait rumble @4aa2eb-4aa35f);
// reset to 0 at clip load (mech3.cpp:330)
```
Update the only other writer, `mech3.cpp:330`: `gyroRumbleTimer = 0.0f;` (bit-identical to the old int 0 write).
## 1. `Gyroscope::ApplyDamageResponse` — the byte-exact FUN_004b2980 body
**Decl** (`gyro.hpp`, after `ApplyVerticalImpulse` at :214; add `class Damage;` fwd-decl at the top if not in scope):
```cpp
// @004b2980 -- the damage->gyro fan-out. The binary passes the whole
// Damage BY VALUE (12 dwords, caller add esp,0x34) but never mutates it
// and never reads surfaceNormal/impactPoint -- const-ref is byte-equal.
void ApplyDamageResponse(const Damage &damage);
```
**Body** (`gyro.cpp`, place directly after `ApplyVerticalImpulse` at :729). Engine ops are the authentic implementations: `Close_Enough(Vector3D,Vector3D,e)` == FUN_004084fc, `Vector3D::Normalize` == FUN_004087f4, `Quaternion::operator=(EulerAngles)` == FUN_00409a00, `AffineMatrix::operator=(Origin)` == FUN_0040ab44 (placement `{pos@+0, quat@+0xC}` IS the engine `Origin` layout, ORIGIN.h:15-16), the mechrecon `FUN_00408744` row-dot (verified in IntegrateMotion) == the dir rotate. `AffineMatrix` is exactly `Scalar entries[12]` (AFFNMTRX.h:21) and `ReconMatrix` wraps only it, so the casts below are layout-exact (lock them):
```cpp
// TU-local layout locks for the casts in ApplyDamageResponse:
static_assert(sizeof(AffineMatrix) == 12 * sizeof(Scalar), "AffineMatrix == 12 Scalars");
static_assert(sizeof(ReconMatrix) == sizeof(AffineMatrix), "ReconMatrix is a bare AffineMatrix");
//
// @004b2980 [CONFIDENT -- re-disassembled byte-exact] -- the damage->gyro
// fan-out: normalize the hit direction (RANDOM horizontal if ~zero), rotate it
// by the yaw-only torso-twist frame, scale by the per-damage-type
// multiplier/response curves, clamp at 1.3, fire the four Apply* kicks.
//
void
Gyroscope::ApplyDamageResponse(const Damage &damage)
{
// @4b298c: zero damage no-ops (const 0.0f @0x4b2d80)
if (damage.damageAmount == 0.0f)
return;
// @4b299e: Collision(0) NEVER bounces via this path (the jump-table case 0
// that zeroes the responses @4b2b24 is dead code behind this early return)
if (damage.damageType == Damage::CollisionDamageType)
return;
Scalar trans = 0.0f; // ebp-0x10
Scalar pitchRoll = 0.0f; // ebp-0x0C
Scalar yaw = 0.0f; // ebp-0x08
Scalar vibration = 0.0f; // ebp-0x04
// @4b29bd: FUN_004084fc(damageForce, ZeroVector@0x4e0f74, 1e-4f@0x38d1b717).
// A ~zero force rolls a RANDOM horizontal direction: per component, a SIGN
// roll first (jb @4b29eb: first roll < 0.5f@0x4b2d84 -> the value roll is
// negated), y = 0. RNG = FUN_00408050 (stream 0x521f5c) == RandomUnit().
Vector3D dir; // ebp-0x1C
if (Close_Enough(damage.damageForce, Vector3D(0.0f, 0.0f, 0.0f), 1e-4f))
{
dir.x = (RandomUnit() >= 0.5f) ? RandomUnit() : -RandomUnit(); // @4b29d7
dir.z = (RandomUnit() >= 0.5f) ? RandomUnit() : -RandomUnit(); // @4b2a0d
dir.y = 0.0f; // @4b2a43
}
else
{
dir = damage.damageForce; // @4b2a4a FUN_00408440
}
dir.Normalize(dir); // @4b2a62 FUN_004087f4 (unguarded in the binary too)
// @4b2a67-4b2ac2: yaw-only body frame from the torso twist.
// placeRot = (0, *externalPitchPtr, 0) gyro+0x2A8/0x2AC/0x2B0
// placeQuat = QuatFromRotation(placeRot) FUN_00409a00
// placePos = Zero @4b2aa8
// workMatrix = MatrixFromPlacement FUN_0040ab44 (== Origin assign)
placeRot.y = *externalPitchPtr; // *(gyro+0x258) = torso currentTwist
placeRot.x = 0.0f;
placeRot.z = 0.0f;
Origin frame;
frame.angularPosition = EulerAngles(
Radian(placeRot.x), Radian(placeRot.y), Radian(placeRot.z)); // FUN_00409a00
placeQuat[0] = frame.angularPosition.x; // member-state fidelity @0x298
placeQuat[1] = frame.angularPosition.y;
placeQuat[2] = frame.angularPosition.z;
placeQuat[3] = frame.angularPosition.w;
placePos = Vector3D(0.0f, 0.0f, 0.0f);
frame.linearPosition = Point3D(placePos.x, placePos.y, placePos.z);
*(AffineMatrix *)workMatrix = frame; // @4b2ac2 FUN_0040ab44
// @4b2ac7-4b2af5: rotate the WORLD hit direction into the torso/body frame
// (FUN_00408744 row-dot: out[i] = sum_j v[j]*M(i,j)) + re-normalize.
Vector3D tmp = dir; // ebp-0x28
FUN_00408744(&dir, (const Scalar *)&tmp, (ReconMatrix *)workMatrix);
dir.Normalize(dir); // @4b2aed
// @4b2afd: per-type scaling (jump table @0x4b2b10; type > 4 leaves all four
// zero). Each term computed INDEPENDENTLY as amount / multiplier * response
// (same expression shape as the binary's fld/fdiv/fmul per term).
switch (damage.damageType)
{
case Damage::BallisticDamageType: // @4b2b3d mult@0x330 resp@0x350
trans = damage.damageAmount / damageMultiplier[1] * damageResponse[1].trans;
pitchRoll = damage.damageAmount / damageMultiplier[1] * damageResponse[1].pitchRoll;
yaw = damage.damageAmount / damageMultiplier[1] * damageResponse[1].yaw;
vibration = damage.damageAmount / damageMultiplier[1] * damageResponse[1].vibration;
break;
case Damage::ExplosiveDamageType: // @4b2b8a the ONLY case reading burstCount (fild first)
trans = (Scalar)damage.burstCount * damage.damageAmount / damageMultiplier[2] * damageResponse[2].trans;
pitchRoll = (Scalar)damage.burstCount * damage.damageAmount / damageMultiplier[2] * damageResponse[2].pitchRoll;
yaw = (Scalar)damage.burstCount * damage.damageAmount / damageMultiplier[2] * damageResponse[2].yaw;
vibration = (Scalar)damage.burstCount * damage.damageAmount / damageMultiplier[2] * damageResponse[2].vibration;
break;
case Damage::LaserDamageType: // @4b2be3 mult@0x338 resp@0x370
trans = damage.damageAmount / damageMultiplier[3] * damageResponse[3].trans;
pitchRoll = damage.damageAmount / damageMultiplier[3] * damageResponse[3].pitchRoll;
yaw = damage.damageAmount / damageMultiplier[3] * damageResponse[3].yaw;
vibration = damage.damageAmount / damageMultiplier[3] * damageResponse[3].vibration;
break;
case Damage::EnergyDamageType: // @4b2c2d mult@0x33C resp@0x380
trans = damage.damageAmount / damageMultiplier[4] * damageResponse[4].trans;
pitchRoll = damage.damageAmount / damageMultiplier[4] * damageResponse[4].pitchRoll;
yaw = damage.damageAmount / damageMultiplier[4] * damageResponse[4].yaw;
vibration = damage.damageAmount / damageMultiplier[4] * damageResponse[4].vibration;
break;
default:
break;
}
// @4b2c75-4b2ce2: upper-clamp each at 1.3f (0x3FA66666 @0x4b2d88); NO lower clamp.
if (trans > 1.3f) trans = 1.3f;
if (pitchRoll > 1.3f) pitchRoll = 1.3f;
if (yaw > 1.3f) yaw = 1.3f;
if (vibration > 1.3f) vibration = 1.3f;
// The four kicks. Call 3 is the vibration SHAKE along the fixed member
// axis vibrationDirection@0x390 (up); call 4 feeds the response row's
// third field ("yaw") into the pitch-only vertical impulse.
ApplyDamageImpulse (dir.x, dir.y, dir.z, trans); // @4b2d00
ApplyDamageTorque (dir.x, dir.y, dir.z, pitchRoll); // @4b2d23
ApplyDamageImpulse (vibrationDirection.x, vibrationDirection.y,
vibrationDirection.z, vibration); // @4b2d4b
ApplyVerticalImpulse(dir.x, dir.y, dir.z, yaw); // @4b2d6e
}
```
(If `Origin` isn't pulled in via `bt.hpp`, add `#include <ORIGIN.hpp>` to gyro.cpp's include block. `RandomUnit` comes via mech.hpp→mechrecon.hpp:104, backed by the engine `RandomGenerator` == FUN_00408050's contract.)
**Bridges** (gyro.cpp, after `GyroBindExternalPitch` at :908 — the databinding-trap pattern; Gyroscope is complete only in this TU):
```cpp
//===========================================================================//
// Damage->gyro bridges (binary call sites: hub @4a02fb; performance crunches
// @4aa254/@4aa288/@4aa342/@4aa81e/@4aa86c; weapon recoil @4bc194).
//===========================================================================//
void GyroApplyDamage(Subsystem *gyro, const Damage &damage)
{
if (gyro != 0)
static_cast<Gyroscope *>(gyro)->ApplyDamageResponse(damage);
}
void GyroApplyDamageImpulse(Subsystem *gyro, Scalar x, Scalar y, Scalar z, Scalar magnitude)
{
if (gyro != 0)
static_cast<Gyroscope *>(gyro)->ApplyDamageImpulse(x, y, z, magnitude);
}
void GyroApplyDamageTorque(Subsystem *gyro, Scalar x, Scalar y, Scalar z, Scalar magnitude)
{
if (gyro != 0)
static_cast<Gyroscope *>(gyro)->ApplyDamageTorque(x, y, z, magnitude);
}
```
## 2. Port call sites
### (a) Weapon-damage path — `mech.cpp` TakeDamageMessageHandler
- **extern** at `mech.cpp:219` (next to the existing gyro externs :217-218): `extern void GyroApplyDamage(Subsystem *, const Damage &);`
- **insert at mech.cpp:~596** — after the `Check(message)`/MP-diag block, BEFORE the `lastInflictingID` write at :602 (binary order: gyro @4a0264 precedes the EntityID assign @4a0327, the threat feed @4a039c and zone resolution @4a037a):
```cpp
// Binary @0x4a0264-0x4a0300 (hub FUN_004a0230): cockpit hit-BOUNCE. Feed
// the gyro the raw Damage record FIRST -- before inflictor bookkeeping,
// threat feed and zone resolution (an invalid-zone hit still bounces).
// Sole gate: non-null gyro (mech+0x528). The fan-out itself no-ops
// CollisionDamageType(0) and damageAmount==0 (@4b298c/@4b299e); on a
// replicant the binary only WARNS ("Replicant Mech recieving
// takedamagemessage!", MECH.CPP:986) and proceeds -- no replicant gate.
if (gyroSubsystem != 0)
GyroApplyDamage(gyroSubsystem, message->damageData);
```
This covers aimed AND unaimed hits and the collision TakeDamage messages (type 0 correctly no-ops inside).
### (b) Crushable-icon crunch — `mech4.cpp:3310` (replace the dormant comment inside the `dmg.damageAmount == 0.00123f` branch, after `dmg.damageAmount = 0.0f;` at :3309, before the GroundLog)
```cpp
// Binary @4aa7ce-4aa871: the gyro CRUNCH. n = Normalize(dmg.damageForce)
// (FUN_004087f4 @4aa7ea; damageForce = the delta-v across the bounce,
// written by engine Mover::ProcessCollisionList, MOVER.cpp:1299 -- no
// new plumbing), then torque along n @ 0.4 (@4aa81e) + an upward
// impulse @ 0.2 (@4aa86c). Normalize is UNGUARDED in the binary too.
if (gyroSubsystem != 0)
{
Vector3D n;
n.Normalize(dmg.damageForce);
GyroApplyDamageTorque (gyroSubsystem, n.x, n.y, n.z, 0.4f); // @4aa81e 0x3ecccccd
GyroApplyDamageImpulse(gyroSubsystem, 0.0f, 1.0f, 0.0f, 0.2f); // @4aa86c 0x3e4ccccd
}
```
externs near mech4.cpp's existing gyro extern (:3166 style, or file-scope): `extern void GyroApplyDamageImpulse(Subsystem *, Scalar, Scalar, Scalar, Scalar);` / `...GyroApplyDamageTorque(...)`. **Do NOT add any kick to the blocking-hit branch (mech4.cpp:3315+)** — verified no gyro calls in @4aa88a-4aab5f.
### (c) Alternate-gait engage jolt + engaged-gait rumble — `mech4.cpp`, immediately before the channel-advance pair at :2056-2058 (`adv = AdvanceBodyAnimation(dt, 1); legAdv = AdvanceLegAnimation(dt);` — the binary runs @4aa158-4aa365 directly before its IsDisabled + AdvanceLegAnimation sequence @4aa365-4aa399):
```cpp
// ===== Binary @4aa158-4aa2be: alternate-gait toggle + ENGAGE JOLT =====
// [T3 NAMING: the gate is (cycling && speedDemand < 0) -- by the port's
// own sign convention 0x3f4 reads as a REVERSE-gait select; the
// airborneSelect/airborneCycleRate names are kept but suspect.]
if (airborneCycleRate != -1.1f) // sentinel @0x4ab174 (0xbf8ccccd)
{
if (legCycleSpeed > 0.0f // @4aa16d
&& s_realControls && controlsMapper != 0
&& controlsMapper->speedDemand < 0.0f) // @4aa182
{
if (airborneSelect == 0) // rising edge @4aa197
{
airborneSelect = 1;
RequestActionFlags(0x100); // @4aa1b5 FUN_004a4c54
forwardCycleRate = airborneCycleRate; // @4aa1bd
if (gyroSubsystem != 0) // @4aa1c9
{
Scalar r = RandomUnit(); // @4aa1dc value roll
if (RandomUnit() > 0.5f) // @4aa1f6 sign roll (STRICT >, 0.5 @0x4ab170)
r = -r;
// dir = (0,0,r): Z-axis, random magnitude+sign; SAME vec both calls
GyroApplyDamageTorque (gyroSubsystem, 0.0f, 0.0f, r, 0.4f); // @4aa254
GyroApplyDamageImpulse(gyroSubsystem, 0.0f, 0.0f, r, 0.4f); // @4aa288
}
}
}
else if (airborneSelect != 0) // falling edge @4aa292
{
airborneSelect = 0;
RequestActionFlags(0x100);
forwardCycleRate = groundCycleRate; // @4aa2b2
}
}
// ===== Binary @4aa2be-4aa365: engaged-gait RUMBLE (0.4s period) =====
if (airborneSelect != 0
&& legCycleSpeed > walkStrideLength // @4aa2cc vs @0x534
&& gyroSubsystem != 0)
{
if (gyroRumbleTimer > 0.0f) // @4aa2eb
gyroRumbleTimer -= dt; // @4aa356 fsubr
else
{
GyroApplyDamageImpulse(gyroSubsystem, 0.0f, 1.0f, 0.0f, 0.2f); // @4aa342 up, 0x3e4ccccd
gyroRumbleTimer = 0.4f; // @4aa347 0x3ecccccd
}
}
```
Note: this block is also the port's first authentic WRITER of `airborneSelect` (previously never set). All gate members exist in mech.hpp (:641-657, :695).
### (d) Firing recoil kick — `projweap.cpp:603` `ProjectileWeapon::FireWeapon`, insert after `Check(this)` at :605, BEFORE `ConsumeRound()` at :608 (binary: heat add @4bc11e → kick @4bc136-4bc199 → tracer/spawn; no early-out precedes the kick):
```cpp
// Binary @4bc136-4bc19c: firing RECOIL kick, before the ammo pull. Gate:
// per-shot damage > 3.0f (@0x4bc3f4) && owner mech has a gyro. Direction
// (0, 0.6, -1.5) (0x3f19999a/0xbfc00000), magnitude = damageAmount/16
// (@0x4bc3f8). (The binary's preceding heat add [+0x3d8]->[+0x1c8] gated
// by HeatModelActive @4ad7d4 is a separate pre-existing gap -- not this hook.)
if (damageData.damageAmount > 3.0f && owner != 0)
{
extern Subsystem *BTMechGyro(void *mech);
extern void GyroApplyDamageImpulse(Subsystem *, Scalar, Scalar, Scalar, Scalar);
Subsystem *g = BTMechGyro(owner); // mech+0x528 via bridge
if (g != 0)
GyroApplyDamageImpulse(g, 0.0f, 0.6f, -1.5f,
damageData.damageAmount * 0.0625f);
}
```
New accessor in **mech.cpp** (projweap.cpp does not include mech.hpp — databinding rule):
```cpp
// Gyro accessor for TUs without a complete Mech (projweap.cpp recoil @4bc194).
Subsystem *BTMechGyro(void *mech)
{
return mech != 0 ? ((Mech *)mech)->gyroSubsystem : 0;
}
```
**Do NOT touch `MissileLauncher::FireWeapon`** (mislanch.cpp:217 — its own override, does not chain to @4bc104; binary-verified no gyro kick @4bcc60).
## 3. Argument plumbing (what each site supplies today / gaps)
| Site | type | amount | direction | burstCount | status |
|---|---|---|---|---|---|
| mech.cpp handler | `message->damageData.damageType` | `.damageAmount` | `.damageForce` | `.burstCount` | fields all exist (ENTITY3.h `damageData` @msg+0x2C) |
| crushable crunch | n/a (direct kicks) | fixed 0.4/0.2 | `dmg.damageForce` = Δv from `ProcessCollisionList` (mech4.cpp:3301 → MOVER.cpp:1299) | n/a | zero new plumbing |
| gait jolt/rumble | n/a | fixed 0.4/0.2 | (0,0,±rand) / (0,1,0) | n/a | zero new plumbing |
| recoil | n/a | `damageData.damageAmount * 0.0625f` (mechweap.hpp:326 @0x3A8) | fixed (0,0.6,1.5) | n/a | zero new plumbing |
**GAP (flag, not a blocker):** our current weapon senders — beam @mech4.cpp:3029-3040 (`kShotDamage`, type Explosive), projectile impact @mech4.cpp:871-878, collision @mech4.cpp:3477 — never set `Damage::damageForce`; the `Damage()` ctor zeroes it (DAMAGE.cpp:38), so every hit takes the fan-out's authentic random-horizontal fallback (@4b29d7). Works and is binary-legal, but for directional fidelity the beam/projectile senders should later fill `dmg.damageForce` with the world flight direction (beam: `impact - muzzle`; projectile: `p.vel`). Also note both senders currently mark beam hits `ExplosiveDamageType` (so `burstCount=1` multiplies benignly); when weapon types are made authentic (Laser/Ballistic), the fan-out picks the matching response row automatically. Second GAP already noted in (d): the FireWeapon heat add is missing (pre-existing, separate).
## 4. Verification checklist (one run, `BT_GYRO_LOG=1`)
1. **Build**: `static_assert` locks compile (0x390 vec, AffineMatrix/ReconMatrix sizes); grep the link log for new unresolved externals (`/FORCE` hides them — `GyroApplyDamage*`, `BTMechGyro` must resolve).
2. **Hit bounce (message path)**: fire at the player mech (or use the enemy shooting back). In `btl4.log` expect, in order, on each hit: the existing BT_GYRO_LOG frame lines showing `eyeForce` spike to nonzero right after the hit → `eyePosition` oscillation over the next ~1s (spring integrator) → decay back to (0,0,0); and `bodyForce`/`bodyOrientation` doing the same (torque + vertical). Add a one-line log in `ApplyDamageResponse` while verifying: `[gyro-dmg] type= amount= burst= dir=(..) t/p/y/v=..` and confirm t/p/y/v are ≤ 1.3 and Explosive scales with burstCount.
3. **Visible jolt**: inside view (cockpit) — the eyepoint/canopy visibly kicks on every laser/missile hit and settles; direction varies (random fallback) run to run.
4. **Collision correctness**: walk into a wall — NO message-path bounce (type 0 early-out; log shows the fan-out entry absent), but walking through a crushable icon logs `[ground] CRUNCH` AND a gyro kick (torque along Δv + 0.2 up).
5. **Recoil**: fire a projectile weapon with damage > 3 — a small forward-down kick per shot (magnitude = damage/16); missiles produce none.
6. **Rumble**: engage the reverse/alternate gait (throttle reverse while cycling) — one Z jolt on the edge, then a 0.4s-period up-shake while `legCycleSpeed > walkStrideLength`; disengage restores `forwardCycleRate = groundCycleRate`.
7. **Regression**: normal combat run un-regressed (no NaN in joints — the eye/mech joint writers already guard); `-net` smoke: replicant-received damage on the master still bounces (binary warns only).
8. **KB**: record the fan-out semantics + the 0x390 vibrationDirection correction and the 0x5c4 re-type in `context/decomp-reference.md` / the gyro section of the task-#56 topic, tiers [T1].
Files touched: `game/reconstructed/gyro.hpp`, `gyro.cpp`, `mech.hpp`, `mech.cpp`, `mech3.cpp` (one line), `mech4.cpp`, `projweap.cpp`. Scratch tooling: `C:\git\bt411\scratchpad\dis_4b2980.py`, `C:\git\bt411\scratchpad\dis_range.py`.