Files
BT412/scratchpad/gyro_finding_2.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

21 KiB

Gyroscope joint writers (WriteEyeJoint @004b33e0 / thunk @004b2eac, WriteMechJoint @004b34ec), node helper API @0041cfa0/0041d020/0041d0a8/0041d11c, joint-name resolution in ctor @004b3778, skeleton wiring (jointlocal/jointeye/siteeyepoint)

exact_semantics

ALL claims below read directly from reference/decomp/all/part_013.c (gyro), part_002.c (node helpers), part_003.c (segment find), part_000.c (vec math), plus content/VIDEO/*.SKL. [T1] unless flagged.

=== NODE OBJECT (the thing at gyro+0x3C8/0x3CC) === node+0x0C = pointer to the joint's VALUE block; node+0x10 = joint TYPE enum; node+0x14 = per-node dirty flag; node+0x18 = owner object whose +0xFC is stamped 1 on any write (skeleton-pose-dirty). Type semantics (from the four helpers, part_002.c:6163-6266):

  • types 0,1,2 (hinge x/y/z): value block holds a SCALAR at value+4.
  • type 4 (ball): value block IS a 3-float rotation euler at value+0.
  • type 5 (balltranslate): value block = { vec3 TRANSLATION @ value+0x00..0x08, vec3 ROTATION euler @ value+0x0C..0x14 }.

Helpers:

  • FUN_0041cfa0(node) 'GetRotationPtr' (part_002.c:6165): type 4 -> returns value; type 5 -> returns value+0xC (the ROTATION euler); any other type -> value. Pure getter, no write.
  • FUN_0041d020(node, vec3) 'SetRotation' (6202): type 4 -> if vec differs from value (FUN_004091f4, eps 0.0001 per-component abs) copy vec into value (FUN_00408e90 = plain 3-float copy); type 5 -> same but against/into value+0xC; other types -> NO-OP. On an actual write: node+0x14=1 and owner(node+0x18)+0xFC=1.
  • FUN_0041d0a8(node, &scalar) 'SetScalar' (6235): if |value[+4] - scalar| > _DAT_0041d118 (eps), write value+4 = scalar, set both dirty flags. No type branch.
  • FUN_0041d11c(node, vec3) 'SetTranslation' (6253): sets node+0x14=1 UNCONDITIONALLY, then if vec differs from value+0 (FUN_004084fc eps 0.0001) copy 3 floats to value+0 (FUN_00408440), set node+0x14=1 and owner+0xFC=1 again. No type branch (caller guards type==5). Math helpers verified (part_000.c): FUN_00408440/FUN_00408e90 = copy vec3; FUN_004085ec = add; FUN_00408644 = sub; FUN_004086ac = scale-by-scalar; FUN_004086d0 = COMPONENTWISE multiply (NOT cross); FUN_00408744 = vec3 x 3x4-matrix rows [0..2],[4..6],[8..10]; FUN_0040aadc = 3x4 identity; FUN_004092fc = scale-by-scalar; FUN_004091f4/FUN_004084fc = per-component |a-b|<=eps compare (return 1 = equal); FUN_00409390 = lerp(a,b,t).

=== WriteEyeJoint @004b33e0 (part_013.c:2558-2599; @004b2eac is a bare thunk to it) === float p = *(this+0x3BC); // current animation percentage (see Execute below) node = *(this+0x3C8); // EYE joint node (resolved from the 'EyeJoint' config name) type = *(node+0x10); if (type < 3) { // hinge: scalar channel cur = *( *(node+0xC) + 4 ); newv = p * cur; // MULTIPLICATIVE scale of the joint's CURRENT (animation-driven) value if (fabs(cur - newv) > _DAT_004b34e8) // change gate (recon says 0.0001 [T3 for the exact constant]) FUN_0041d0a8(node, &newv); // SetScalar (re-gated + dirty flags inside) } else if (type == 4 || type == 5) { // ball / balltranslate: ROTATION euler only rot = FUN_0041cfa0(node); // type5 -> value+0xC (rotation), translation untouched out = rot * p; // FUN_004092fc vec scale (all 3 components by the same scalar) if (!FUN_004091f4(rot, out, 0.0001)) // write only if changed FUN_0041d020(node, out); // SetRotation } // type 3: nothing. So WriteEyeJoint does NOT write the spring integrator output at all: it ATTENUATES the eye joint's animation rotation by the scalar percentage 0x3BC. Because it is multiplicative on the joint's current value, it MUST run after the animation pass re-writes the joint each frame, else the rotation decays geometrically to 0.

=== WriteMechJoint @004b34ec (part_013.c:2605-2624) === node = (this+0x3CC); // MECH joint node (resolved from the 'MechJoint' config name) if ((node+0x10) != 5) return; // ONLY BallTranslation // 1) TRANSLATION <- eye-position spring state: if (!FUN_004084fc(*(node+0xC), this+0x1DC, 0.0001)) FUN_0041d11c(node, this+0x1DC); // SetTranslation( eyePosition @0x1DC..0x1E4 ) // 2) ROTATION <- body-orientation spring state: rot = FUN_0041cfa0(node); // value+0xC if (!FUN_004091f4(rot, this+0x2B4, 0.0001)) FUN_0041d020(node, this+0x2B4); // SetRotation( bodyOrientation @0x2B4..0x2BC ) So ONE node gets BOTH channels: translation = the eye positional spring integrator (0x1DC, integrated by FUN_004b2ec0), rotation = the body rotational spring integrator (0x2B4, integrated by FUN_004b30ec).

=== NAME RESOLUTION -- the 'crossover' resolved (ctor @004b3778, part_013.c:2886-2920; parser @004b3eb4:4171-4303) === Resource string +0x178 is filled from the config token "EyeJoint" (s_EyeJoint_005109fa, part_013.c:4173, copy loop :4226); +0x198 from "MechJoint" (s_MechJoint_00510a3b :4244, copy :4297). Both are verified to exist in the model's skeleton file (FUN_00403f84 :4334/:4346) or the load fails. In the ctor: name@+0x178 -> FUN_00424b60(mech, name) (part_003.c:4452 = walk the mech segment list at mech+0x300 comparing segment+0x11C name) -> segment; jointID = *(segment+0xC0); node = (jointSubsystem(mech+0x31C via FUN_00417ab4)+0xE4)->vtbl+0x34(jointID); stored at this[0xF2] = byte 0x3C8. Name@+0x198 identically -> this[0xF3] = byte 0x3CC. THEREFORE: 0x3C8 (WriteEyeJoint's target) = the joint the config names 'EyeJoint' = 'jointlocal'; 0x3CC (WriteMechJoint's target) = 'MechJoint' = 'jointeye'. NOT crossed -- the names only look crossed:

  • 'jointlocal' (AVA.SKL:41-53): parent=ROOT, Type=balltranslate, the WHOLE-BODY root joint (children jointhip->jointtorso->everything, both thighs). WriteEyeJoint scales ITS animation ROTATION (walk-cycle sway) by percentage 0x3BC -- a healthy gyro cancels body sway from the pose; a destroyed gyro lets it through. Its translation is never touched by the gyro.
  • 'jointeye' (AVA.SKL:715-725): parent=jointtorso, Type=balltranslate, rest offset tran=(-0.0039, 3.153, -1.503), owns site=siteeyepoint (AVA.SKL:727-734, zero offset, 'moved by AddEyeJoint'). WriteMechJoint drives ITS translation (cockpit bounce + steady eye offset) and rotation (body tip). The eyepoint camera site rides this joint directly.

=== WHAT FEEDS THE WRITERS (context, verified) === Execute = FUN_004b275c (part_013.c:2209-2293), the registered Performance: after the PowerWatcher watch update it slews 0x3BC at rotationPerSecond(0x3A4)dt toward target = (impaired ? percentageOnDestruction(0x3B0) : percentageOnNormal(0x3AC)) + bias(0x3A8), impaired = (this+0x40==1 || this+0x198!=4 || this+0x140==2); no-overshoot clamp, then clamp into [min 0x3A0, max 0x39C]; then calls FUN_004b2ec0 (eye integrator) and FUN_004b30ec (body integrator). The two WRITERS have NO caller anywhere in the decomp -- they are separate vtable slots (PTR_FUN_00510abc) dispatched by the engine's per-frame joint-write pass, i.e. they run in the phase where joint channels are composed, AFTER animation writes the joints. Eye integrator FUN_004b2ec0 (2362-2437): disp1 = pos(0x1DC) - negSpring(0x224); disp2 = pos - posSpring(0x218); accel(0x230) += springK(0x1E8)disp1 (componentwise); accel += springKdisp2; work(0x248)=accel; vel(0x23C) += workdt; accel = dampK(0x1F4)vel (componentwise, OVERWRITES 0x230); work=accel; vel += workdt; pos += vel (through an identity matrix, i.e. pass-through, NO dt); per-axis clamp pos = min(pos, 0x200/0x204/0x208) then max(pos, 0x20C/0x210/0x214). Equilibrium: pos* = (posSpring+negSpring)/2 (stable iff springK<0 in config) -- THE steady eye offset. Ctor derives the clamp limits: 0x200 = posSpring, 0x20C = negSpring (identity-matrix transform, part_013.c:2876-2883). Body integrator FUN_004b30ec (2443-2549): same shape on ori(0x2B4) vs rotNegSpring(0x2FC)/rotPosSpring(0x2F0) with springK = rotationSpringConstant(0x2C0) for BOTH terms, but the force components are X/Z-CROSSED: force = (k.xdisp.z, k.ydisp.y, k.zdisp.x) (:2479-2484); damping = rotDampK(0x2CC) componentwise uncrossed; and the integration back is crossed again: ori.x += vel.z, ori.y += vel.y, ori.z += vel.x (:2504-2506); clamps against 0x2D8(=2rotPosSpring)/0x2E4(=2rotNegSpring) (derived in ctor :2884-2885 via FUN_004092fc 2.0). Hit impulses feed the SAME accumulators: FUN_004b2d8c adds -dirmagexageration(0x1D8) into eyeAccel 0x230 (translation bounce -> jointeye translation); FUN_004b2de4/FUN_004b2e50 into bodyAccel 0x308 (rotation kick -> jointeye rotation). UpdateAnimationNoise @004b357c (2632-2713) is a SEPARATE 2.0s-window lerp-to-zero on the EYE node (0x3C8) using timer 0x3C0/flag 0x3C4 -- it lerps the node's channel toward a zero pose via FUN_00409390(cur, zero, dt/timeLeft), all types handled (scalar via FUN_00408dd4+FUN_0041d0a8, ball/balltranslate rotation via FUN_0041d020). Returns timer-expired. The gyro NEVER writes any entity attribute: the 'EyepointRotation' attribute is not touched by any of these functions -- the eye effect reaches the renderable purely through the skeleton (siteeyepoint rides jointeye).

member_map

  • 0x1D8 exageration <- resource+0xF8 'Exageration' (impulse scale, ctor part_013.c:2812)
  • 0x1DC..0x1E4 eyePosition (BallTranslation TRANSLATION state written to jointeye; ZEROED in ctor :2935 via FUN_00408440(this+0x77,&DAT_004e0f74) and in reset @004b2678:2190) -- NOT a spring-constant slot
  • 0x1E8..0x1F0 springConstant vec <- +0x118 'SpringConstantX/Y/Z' (ctor :2819-2821)
  • 0x1F4..0x1FC dampingConstant vec <- +0x124 'DampingConstantX/Y/Z' (ctor :2822-2823)
  • 0x200..0x208 eyeUpperClamp = posSpring via identity transform (ctor :2876-2879)
  • 0x20C..0x214 eyeLowerClamp = negSpring via identity transform (ctor :2880-2883)
  • 0x218..0x220 posSpring <- +0x130 'PosSpringX/Y/Z' (ctor :2824-2825)
  • 0x224..0x22C negSpring <- +0x13C 'NegSpringX/Y/Z' (ctor :2826-2827)
  • 0x230..0x238 eyeAccel/force accumulator (impulse target of @004b2d8c; zeroed ctor :2936, reset :2191)
  • 0x23C..0x244 eyeVelocity (zeroed ctor :2937, reset :2192)
  • 0x248..0x250 eyeWork scratch (zeroed ctor :2938, reset :2193)
  • 0x254 int=0; 0x258 = &this->0x254; 0x25C..0x28B matrix built by FUN_0040ab44 from 0x28C; 0x28C zero vec; 0x298 FUN_00409a00(zero); 0x2A8 zero rot (ctor :2928-2934)
  • 0x2B4..0x2BC bodyOrientation (rotation state written to jointeye; zeroed ctor :2939 via FUN_00408e90(this+0xAD,&DAT_004e0f8c), reset :2194)
  • 0x2C0..0x2C8 rotationSpringConstant [Roll@x,Yaw@y,Pitch@z] <- +0x148 (parse: Roll->+0x148 :3548, Yaw->+0x14C :3532, Pitch->+0x150 :3516; ctor :2828-2830)
  • 0x2CC..0x2D4 rotationDampingConstant [Roll,Yaw,Pitch] <- +0x154 (ctor :2831-2833)
  • 0x2D8..0x2E0 bodyUpperClamp = 2.0 * rotationPosSpring (ctor :2884)
  • 0x2E4..0x2EC bodyLowerClamp = 2.0 * rotationNegSpring (ctor :2885)
  • 0x2F0..0x2F8 rotationPosSpring [Pitch@x,Yaw@y,Roll@z] * PI/180 <- +0x160 (parse :3684-3748; ctor :2834)
  • 0x2FC..0x304 rotationNegSpring [Pitch,Yaw,Roll] * PI/180 <- +0x16C (ctor :2835)
  • 0x308..0x310 bodyAccel (impulse target of @004b2de4/@004b2e50; 0x30C is its .y component; zeroed ctor :2940, reset :2195)
  • 0x314..0x31C bodyVelocity (zeroed ctor :2941, reset :2196)
  • 0x320..0x328 bodyWork scratch (zeroed ctor :2942, reset :2197)
  • 0x32C..0x33C five damage multipliers Collision/Ballistic/Explosive/Laser/Energy <- +0x1B8..0x1C8 (ctor :2836-2840)
  • 0x340..0x38F five 4-float response quads {Trans,PitchRoll,Yaw,Vibration} <- +0x1CC..0x21B (ctor copy loops :2841-2875)
  • 0x390 = 0.0f, 0x394 = 1.0f, 0x398 = 0.0f (ctor :2921-2923)
  • 0x39C maxAnimationNoise <- +0xFC; 0x3A0 minAnimationNoise <- +0x100 (clamp band for 0x3BC)
  • 0x3A4 rotationPerSecond <- +0x104 (slew rate of 0x3BC)
  • 0x3A8 runtime bias added to the percentage target (init 0 ctor :2927, reset :2188; set elsewhere by damage/degradation paths)
  • 0x3AC percentageOnNormal <- +0x108; 0x3B0 percentageOnDestruction <- +0x10C; 0x3B4 percentageOnDegradation <- +0x110; 0x3B8 percentageOnFailure <- +0x114
  • 0x3BC current animation percentage ('swayAngle') -- THE value WriteEyeJoint multiplies into the eye joint (init 0 :2924, slewed by Execute @004b275c)
  • 0x3C0 noise-window timer, 0x3C4 noise-active flag (UpdateAnimationNoise @004b357c; init 0 :2925-2926)
  • 0x3C8 eyeJointNode = node for config 'EyeJoint' name (= 'jointlocal', the ROOT balltranslate joint) -- ctor :2904-2915 (this[0xF2])
  • 0x3CC mechJointNode = node for config 'MechJoint' name (= 'jointeye', balltranslate under jointtorso carrying siteeyepoint) -- ctor :2916-2920 (this[0xF3])

recon_mismatches

  • gyro.cpp:235 -> WRONG: 'eyeOrientation = r->springConstant // @0x1DC'. Binary copies springConstant into 0x1E8 and dampingConstant into 0x1F4 (ctor :2819-2823); 0x1DC is the eye-position STATE and is ZEROED (ctor :2935). This single line both leaves 0x1E8/0x1F4 uninitialised and poisons the state WriteMechJoint writes as jointeye's translation.
  • gyro.cpp:226-257 (ctor) -> MISSING inits (the 0xCDCDCDCD source): eyeUpperClamp(0x200)=posSpring, eyeLowerClamp(0x20C)=negSpring (:2876-2883); bodyUpperClamp(0x2D8)=2rotationPosSpring, bodyLowerClamp(0x2E4)=2rotationNegSpring (:2884-2885); zero of eyePosition(0x1DC), eyeAccel(0x230), eyeVelocity(0x23C), eyeWork(0x248), bodyOrientation(0x2B4), bodyAccel(0x308), bodyVelocity(0x314), bodyWork(0x320) (:2935-2942); scalars 0x390=0/0x394=1.0/0x398=0 (:2921-2923); damage multiplier/quad copies 0x32C..0x38F (:2836-2875).
  • gyro.cpp:428-429 (IntegrateEyeJoint) -> subtraction order FLIPPED: binary computes state-minus-target: disp = eyePosition - negSpring and eyePosition - posSpring (FUN_004b2ec0 :2376-2377); recon computes target-minus-state. With the (negative) config constants this inverts the force sign (divergence instead of restoring spring).
  • gyro.cpp:431-432,441 -> the second displacement term also uses springConstant(0x1E8) (:2378-2379), and the velocity-damping step is dampingConstant(0x1F4) COMPONENTWISE-multiplied by velocity, OVERWRITING the accumulator (FUN_004086d0 :2387): recon uses eyeSpringConstant and Vector3D::Cross -- wrong member AND wrong operation (FUN_004086d0 is componentwise multiply, verified part_000.c:8294-8301, not a cross product).
  • gyro.cpp:449-450 -> 'delta = eyeForce' is per-frame position increment WITHOUT dt (binary :2391-2393, identity-matrix pass-through then pos += vel) -- recon matches by accident but labels 0x23C 'eyeForce'; it is the velocity.
  • gyro.cpp:470-485 (IntegrateBody) -> three mismatches vs FUN_004b30ec: (1) BOTH spring terms use rotationSpringConstant(0x2C0), not damping on one (:2479-2484); (2) the spring force components are X/Z-CROSSED: force=(k.xdisp.z, k.ydisp.y, k.z*disp.x) -- recon multiplies straight; (3) the integration back into bodyOrientation is crossed again: ori.x+=vel.z, ori.z+=vel.x (:2504-2506) -- recon adds straight; and the damping step again uses componentwise multiply, not Cross (:2498).
  • gyro.cpp:453-458,490-495 -> clamp limits (eyeLimitLow/High, bodyLimitLow/High) are read but NEVER initialised anywhere in the recon (see ctor gap) -> direct 0xCDCDCDCD reads; also the names are inverted (0x200/0x2D8 are the UPPER bounds min-clamped against; 0x20C/0x2E4 the LOWER bounds max-clamped against).
  • gyro.cpp:324 (ResetToInitialState) -> assigns 'bodyPosSpring = zero': the binary zeroes this+0xAD*4 = 0x2B4 = bodyOrientation (FUN_004b2678 :2194), NOT the 0x2F0 spring target; recon also omits zeroing eyeWork(0x248) and bodyWork(0x320) (:2193, :2197).
  • gyro.cpp:407-414 comment/placement -> calling WriteEyeJoint/WriteMechJoint at the end of the Performance is only faithful if the Performance ticks AFTER the animation pass writes 'jointlocal' each frame: WriteEyeJoint is MULTIPLICATIVE on the joint's current rotation (out = rot * percentage, :2590-2596), so ticking before the animation write (or when nothing re-writes the joint) decays the rotation geometrically to zero. In the binary the writers are separate vtable slots dispatched by the engine's joint-write phase, not called from @004b275c.
  • gyro.cpp:84-111 helper naming -> functional mapping is right but two names are swapped/misleading: 'SetNodeRotation' (line 110) actually implements FUN_0041d11c = SetTranslation, and 'NodeVector/SetNodeVector' operate on the ROTATION euler (FUN_0041cfa0 returns value+0xC for type 5). Cosmetic, but it caused the 'crossed joints' confusion: the wiring is NOT crossed -- EyeJoint='jointlocal' (root; anim-sway attenuation via rotation scale), MechJoint='jointeye' (eye translation offset/bounce + body-tip rotation; siteeyepoint parent, AVA.SKL:715-734).
  • docs/PROGRESS_LOG.md ~2222 claim 'WriteMechJoint ... written to jointlocal (the ROOT joint)' -> per the binary WriteMechJoint targets the MechJoint node = 'jointeye'; the garbage observed was the uninitialised integrator state (0x1DC/0x2B4 fed from 0xCDCDCDCD accumulators/limits), not a wrong-joint write (unless the recon's own ResolveJoint call swapped the two resource strings -- gyro.cpp:261-262 order is correct).

fix

In game/reconstructed/gyro.cpp (+ gyro.hpp members):

  1. Ctor (@004b3778 parity): replace line 235 with the two real copies and add the derived limits + zero-init block: springConstant = r->springConstant; // @0x1E8 <- +0x118 dampingConstant = r->dampingConstant; // @0x1F4 <- +0x124 posSpring = r->posSpring; // @0x218 negSpring = r->negSpring; // @0x224 eyeUpperClamp = posSpring; // @0x200 (identity transform in the binary) eyeLowerClamp = negSpring; // @0x20C rotationSpringConstant = r->rotationSpringConstant; // @0x2C0 rotationDampingConstant = r->rotationDampingConstant; // @0x2CC rotationPosSpring = r->rotationPosSpring; // @0x2F0 (already deg->rad from parse) rotationNegSpring = r->rotationNegSpring; // @0x2FC bodyUpperClamp.Multiply(rotationPosSpring, 2.0f); // @0x2D8 bodyLowerClamp.Multiply(rotationNegSpring, 2.0f); // @0x2E4 eyePosition = eyeAccel = eyeVelocity = eyeWork = Vector3D(0,0,0); // 0x1DC/0x230/0x23C/0x248 bodyOrientation = bodyAccel = bodyVelocity = bodyWork = Vector3D(0,0,0); // 0x2B4/0x308/0x314/0x320 (+ the 0x390=0 / 0x394=1.0 / 0x398=0 scalars and the 0x32C..0x38F damage-response copies if those members exist.)

  2. IntegrateEyeJoint (@004b2ec0 exact): Vector3D d1; d1.Subtract(eyePosition, negSpring); Vector3D d2; d2.Subtract(eyePosition, posSpring); Vector3D f; f.Multiply(springConstant, d1); eyeAccel += f; // componentwise f.Multiply(springConstant, d2); eyeAccel += f; eyeWork = eyeAccel; f.Multiply(eyeWork, dt); eyeVelocity += f; eyeAccel.Multiply(dampingConstant, eyeVelocity); // componentwise, OVERWRITES eyeWork = eyeAccel; f.Multiply(eyeWork, dt); eyeVelocity += f; eyePosition += eyeVelocity; // no dt (identity-matrix pass-through) per-axis: eyePosition = Min(eyePosition, eyeUpperClamp); eyePosition = Max(eyePosition, eyeLowerClamp);

  3. IntegrateBody (@004b30ec exact, with the X/Z crossings): dN = bodyOrientation - rotationNegSpring; dP = bodyOrientation - rotationPosSpring; fN = ( k.xdN.z, k.ydN.y, k.zdN.x ); fP = ( k.xdP.z, k.ydP.y, k.zdP.x ); // k = rotationSpringConstant, BOTH terms bodyAccel += fN; bodyAccel += fP; bodyWork = bodyAccel; bodyVelocity += bodyWorkdt; bodyAccel.Multiply(rotationDampingConstant, bodyVelocity); // componentwise, uncrossed bodyWork = bodyAccel; bodyVelocity += bodyWorkdt; bodyOrientation.x += bodyVelocity.z; bodyOrientation.y += bodyVelocity.y; bodyOrientation.z += bodyVelocity.x; per-axis Min against bodyUpperClamp(0x2D8) then Max against bodyLowerClamp(0x2E4).

  4. ResetToInitialState (@004b2678 exact): zero swayAngle/0x3C0-timer/bias/active + the EIGHT vectors eyePosition, eyeAccel, eyeVelocity, eyeWork, bodyOrientation, bodyAccel, bodyVelocity, bodyWork. Delete the 'bodyPosSpring = zero' line.

  5. Writers: keep the WriteEyeJoint/WriteMechJoint bodies (they already match @004b33e0/@004b34ec functionally) but (a) rename the helpers so SetTranslation/SetRotation are honest, and (b) move their dispatch out of GyroscopeSimulation into the mech's joint-update phase AFTER the animation channels are applied each frame (WriteEyeJoint is multiplicative on jointlocal's animation rotation; run-before-anim = no-op, run-without-anim-rewrite = geometric decay to zero). WriteMechJoint's cadence is insensitive, but keep both in the post-anim phase for parity.

  6. Un-stub: re-enable the gyro (revert the inert stub), keeping BT_GYRO_LOG on for the first run; expected: jointeye translation settles at (PosSpring+NegSpring)/2 (the authentic steady eye offset -- verify the .MDL config constants come through NEGATIVE, which is what makes the springs restoring), and ApplyDamageImpulse/Torque produce the decaying cockpit bounce. No EyepointRotation attribute work is needed for this system -- the binary's gyro affects the eye only through the skeleton (siteeyepoint rides jointeye).