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 ` 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(gyro)->ApplyDamageResponse(damage); } void GyroApplyDamageImpulse(Subsystem *gyro, Scalar x, Scalar y, Scalar z, Scalar magnitude) { if (gyro != 0) static_cast(gyro)->ApplyDamageImpulse(x, y, z, magnitude); } void GyroApplyDamageTorque(Subsystem *gyro, Scalar x, Scalar y, Scalar z, Scalar magnitude) { if (gyro != 0) static_cast(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`.