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>
20 KiB
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:
// @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 →
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:
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):
// @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):
// 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):
//===========================================================================//
// 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 thelastInflictingIDwrite at :602 (binary order: gyro @4a0264 precedes the EntityID assign @4a0327, the threat feed @4a039c and zone resolution @4a037a):
// 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)
// 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):
// ===== 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):
// 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):
// 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)
- Build:
static_assertlocks compile (0x390 vec, AffineMatrix/ReconMatrix sizes); grep the link log for new unresolved externals (/FORCEhides them —GyroApplyDamage*,BTMechGyromust resolve). - Hit bounce (message path): fire at the player mech (or use the enemy shooting back). In
btl4.logexpect, in order, on each hit: the existing BT_GYRO_LOG frame lines showingeyeForcespike to nonzero right after the hit →eyePositionoscillation over the next ~1s (spring integrator) → decay back to (0,0,0); andbodyForce/bodyOrientationdoing the same (torque + vertical). Add a one-line log inApplyDamageResponsewhile 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. - Visible jolt: inside view (cockpit) — the eyepoint/canopy visibly kicks on every laser/missile hit and settles; direction varies (random fallback) run to run.
- 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] CRUNCHAND a gyro kick (torque along Δv + 0.2 up). - Recoil: fire a projectile weapon with damage > 3 — a small forward-down kick per shot (magnitude = damage/16); missiles produce none.
- 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 restoresforwardCycleRate = groundCycleRate. - Regression: normal combat run un-regressed (no NaN in joints — the eye/mech joint writers already guard);
-netsmoke: replicant-received damage on the master still bounces (binary warns only). - 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.