# 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 += springK*disp2; work(0x248)=accel; vel(0x23C) += work*dt; accel = dampK(0x1F4)*vel (componentwise, OVERWRITES 0x230); work=accel; vel += work*dt; 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.x*disp.z, k.y*disp.y, k.z*disp.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(=2*rotPosSpring)/0x2E4(=2*rotNegSpring) (derived in ctor :2884-2885 via FUN_004092fc *2.0).
Hit impulses feed the SAME accumulators: FUN_004b2d8c adds -dir*mag*exageration(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)=2*rotationPosSpring, bodyLowerClamp(0x2E4)=2*rotationNegSpring (: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.x*disp.z, k.y*disp.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.x*dN.z, k.y*dN.y, k.z*dN.x );  fP = ( k.x*dP.z, k.y*dP.y, k.z*dP.x );   // k = rotationSpringConstant, BOTH terms
   bodyAccel += fN; bodyAccel += fP;
   bodyWork = bodyAccel; bodyVelocity += bodyWork*dt;
   bodyAccel.Multiply(rotationDampingConstant, bodyVelocity);    // componentwise, uncrossed
   bodyWork = bodyAccel; bodyVelocity += bodyWork*dt;
   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).
