Files
BT411/scratchpad/fanout_synthesis.md
T
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

20 KiB
Raw Blame History

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:662int 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 the lastInflictingID write 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)

  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.