# Gyro enable path + EyepointRotation attribute + WriteEyeJoint/WriteMechJoint per-frame dispatch (BTL4OPT.EXE vs port)

## exact_semantics
ALL claims below read from the bytes (reference/decomp/section_dump.txt disassembled with capstone) and/or the Ghidra pseudocode; addresses are binary VAs.

(1) PORT ENABLE PATH (current state)
- game/reconstructed/mech.cpp:178 defines the inert stub `RECON_SUBSYS(Gyro)` (ReconSubsystemStub : MechSubsystem). The factory case GyroClassID 0xBC4 at mech.cpp:1019-1032 constructs it: `new (Memory::Allocate(0x3d0)) Gyro(this, id, seg)` and stores `gyroSubsystem = subsystemArray[id]`. The stub never calls SetPerformance, so activePerformance stays the base DoNothingOnce -> inert. The real bridge `CreateGyroSubsystem(Mech*,int,void*)` already exists (gyro.cpp:785-789, alloc 0x3D0, real Gyroscope ctor); the extern hookup comment is mech.cpp:216-218. Re-enable = swap the stub `new` for `CreateGyroSubsystem(this, id, seg)`.
- Tick cadence (already plumbed, verified in-source): mech4.cpp Mech::PerformAndWatch (line 1282; roster walk ~3065-3109) calls each subsystem's PerformAndWatch -> engine Simulation::PerformAndWatch (SIMULATE.cpp:448) -> Perform -> (this->*activePerformance)(slice). The real Gyroscope ctor installs GyroscopeSimulation (member-fn-ptr triple PTR @0050fe08 = 0x004b275c, verified in the byte image at 0x50fe08: "5c274b00") ONLY under the master gate `(owner+0x28 & 0xC)==0 && (byte(owner+0x29) & 1)` i.e. simulationFlags &0xC==0 && &0x100 (ctor @004b3778, part_013.c:2807-2811). If a live master mech lacks flag 0x100 the gyro constructs but never simulates.

(2) EYEPOINTROTATION -- WHO WRITES IT (binary)
- Binary databinding: the Mech attribute descriptor table entry {id 0x1C, namePtr 0x50c21e "EyepointRotation", offset-tag 0x361} at data 0x50bf10 => the attribute pointer resolves to mech+0x360 (offsets stored +1; neighbors confirm: CurrentSpeed 0x349->0x348=legCycleSpeed, MaxRunSpeed 0x34D->0x34C, TargetReticle 0x36D->0x36C). Matches the port's table order (mech.cpp:654).
- PER-FRAME WRITER #1 (every frame): the Mech's master per-frame performance FUN_004a9b5c (an UNEXPORTED decomp-gap function 0x4a9b5c-0x4ab188, single prologue; it IS the Mech Performance -- data pointer to it at 0x50c0f4, no E8 callers) calls at 0x4aafab: FUN_004b66b4(torso=mech+0x438, &mech+0x360), gated `mech+0x438 != 0 && mech+0x650(deathAnimationLatched) == 0`. FUN_004b66b4 (part_013.c:4773-4822) does `*param_2 = *(torso+0x1E4)` -- i.e. EyepointRotation.x (pitch) = torso's integrated pitch EVERY frame -- then, if torso+0x250, pushes torso+0x1D8 into two torso joints (torso+0x278/+0x27C) via the standard joint setters (FUN_0041d0a8 hinge / FUN_0041cfa0+FUN_0041d020 ball).
- WRITER #2 (on state change): the controls-mapper glance/look state machine FUN_004afd10 (bt/mechmppr.cpp region; called from the mapper performance FUN_004d196c, part_014.c:6762). part_013.c:396-467: a look-state at mapper+0x198 is derived from request counters mapper+0x134/0x138/0x13C/0x140 (look left/right/back/down pod buttons); on change it builds an EulerAngles (case 1: yaw=mech+0x564; case 2: yaw=mech+0x568; case 3: yaw=PI(0x40490fdb), pitch=mech+0x570; case 4: pitch=mech+0x56C; case 0: zero + mech+0x378=1) and stores it via FUN_00408e90 (3-float copy) into mech+0x360 (line 465).
- The GYRO NEVER touches EyepointRotation. The eye renderable updater FUN_00457b48 (part_007.c:9360-9390) caches the attribute at renderable+0x4C and rebuilds its matrix only when |delta|>0.0001 (FUN_004091f4), so with zero glance it is inert -- correct. The three FUN_0041bfc0("EyepointRotation") sites (part_008.c:3270 eye-renderable ctor wire-up, part_008.c:3317 SetupCull, part_014.c:5560 = btl4vid) are all READERS.

(3) WriteEyeJoint / WriteMechJoint DISPATCH (binary, byte-verified)
- E8-scan of the whole 0x401000-0x535fff image: FUN_004b2eac (thunk->WriteEyeJoint @004b33e0) is called ONCE, at 0x4aaf74; FUN_004b34ec (WriteMechJoint) ONCE, at 0x4aaf83 -- BOTH inside the Mech master performance FUN_004a9b5c (the decomp-export gap; that is why the recon found "no direct caller"). GyroscopeSimulation @004b275c does NOT call them (it ends with IntegrateEye @0x4b2962 + IntegrateBody @0x4b296e only, part_013.c:2291-2293).
- Exact tail sequence (disasm 0x4aade8-0x4aaf89): compute an overspeed ratio from (speed-mech+0x348)/speed, clamp to cap [0x4ab17c], multiply by mech+0x78C, accumulate into mech+0x3F0; if legAnimationState(mech+0x3B0)==4 add mech+0x798; clamp mech+0x3F0 to the cap; THEN: if (gyro=mech+0x528) gyro->swayBias(+0x3A8) = mech+0x3F0; if (gyro && mech+0x650==0) { if (mech+0x3B0 != 0 && mech+0x57C == 0) FUN_004b2eac(gyro, time_slice); FUN_004b34ec(gyro); } followed by the FUN_004b66b4 torso->EyepointRotation call above. (mech+0x57C is only READ here in the whole export -- an eject/suppress flag, 0 in normal play. mech+0x3F0 is zeroed at reset, part_012.c:9420.)
- What the writers write (part_013.c:2554-2624): WriteEyeJoint uses eyeJointNode(this+0x3C8): node type(*(node+0x10))<3 -> scalar channel *= swayAngle(this+0x3BC), written via FUN_0041d0a8 if |change|>1e-4; type 4/5 -> euler vector (FUN_0041cfa0) scaled by swayAngle (FUN_004092fc), written via FUN_0041d020 if changed. WriteMechJoint uses mechJointNode(this+0x3CC), ONLY if type==5 (BallTranslation): TRANSLATION <- this+0x1DC (the eye position integrator -- this is the steady eye offset + hit bounce) via FUN_0041d11c when changed (compare FUN_004084fc against *(node+0xC)); ROTATION <- this+0x2B4 (body euler integrator) via FUN_0041d020. Joint binding is NOT crossed in the binary: ctor @004b3778 resolves resource EyeJoint(+0x178) -> this+0x3C8 and MechJoint(+0x198) -> this+0x3CC (part_013.c:2886-2920, via GetSegment FUN_00424b60 then segment+0xC0 joint index through the skeleton accessor vtbl+0x34). With content EyeJoint=jointlocal / MechJoint=jointeye, the eye offset lands on 'jointeye' -- the port's revert-era "garbage into jointlocal" implies the port crossed them or the ctor mapping fed garbage.
- UpdateAnimationNoise FUN_004b357c: ZERO callers by E8-scan and no data pointer -> dead code in the shipped binary.
- Damage kick chain (the hit bounce): Mech damage hub (another export gap ~0x49ffd0-0x4a0b5b) at 0x4a02fb calls the unexported gyro damage-response FUN_004b2980 (gap 0x4b297c-0x4b2d8c; args: gyro=mech+0x528 + direction + damage-record fields, 13-dword stack cleanup). It normalizes the hit direction (random dir if zero), reads *(gyro+0x258) (a Scalar* re-pointed post-stream to &torso+0x1D8 by Mech::CreateStreamedSubsystems tail, bt_mech.cpp.c:508-510: `if (torso && gyro) *(gyro+600)= torso*+0x1d8`; ctor default &gyro+0x254=0) into gyro+0x2AC, builds a frame matrix at gyro+0x25C, applies the per-damage-type response curves (gyro+0x340..0x380) and calls ApplyDamageImpulse @0x4b2d00/0x4b2d4b, ApplyDamageTorque @0x4b2d23, ApplyVerticalImpulse @0x4b2d6e. The mech performance ALSO kicks the gyro directly (ground/collision crunch) at 0x4aa254/0x4aa81e (torque) and 0x4aa288/0x4aa342/0x4aa86c (impulse); one more impulse call at 0x4bc194 (unexported region after 0x4bc0fd). The port's dormant "gyro crunch feed" hook is mech4.cpp:3277.

## member_map
- gyro+0x1D8 exageration <- res+0xF8 (ctor part_013.c:2812)
- gyro+0x1DC eye POSITION integrator accumulator (NOT springConstant): zeroed in ctor (2935, FUN_00408440 <- DAT_004e0f74); written to mechJointNode as TRANSLATION by WriteMechJoint (2612-2615); kicked by ApplyDamageImpulse via eyeAccel
- gyro+0x1E8 springConstant Vector3D <- res+0x118 (2821) -- the recon labels this eyeVelocity: WRONG
- gyro+0x1F4 dampingConstant Vector3D <- res+0x124 (2823) -- used by IntegrateEyeJoint at 2387 (param_1+500=0x1F4); recon's 'eyeSpringConstant' label WRONG
- gyro+0x200 eyeLimitLow = posSpring passed through identity matrix (FUN_0040aadc+FUN_00408744, 2876-2879) == copy of res posSpring
- gyro+0x20C eyeLimitHigh = negSpring through identity (2880-2883)
- gyro+0x218 posSpring <- res+0x130 (2825)
- gyro+0x224 negSpring <- res+0x13C (2827)
- gyro+0x230 eyeAccel accumulator: zeroed (2936); damage impulses add here (FUN_004b2d8c)
- gyro+0x23C eyeForce/velocity accumulator: zeroed (2937)
- gyro+0x248 eyeWork scratch: zeroed (2938)
- gyro+0x254 Scalar zero (2931: param_1[0x95]=0)
- gyro+0x258 Scalar* external pitch pointer: ctor sets &this+0x254 (2933); Mech post-stream re-points it to &torso+0x1D8 (bt_mech.cpp.c:508-510); READ by the damage-response @0x4b2980 (disasm 0x4b2a67: mov edx,[ebx+0x258]; gyro+0x2AC = *edx)
- gyro+0x25C work matrix built from gyro+0x28C (2934, FUN_0040ab44; rebuilt in damage response 0x4b2ac2)
- gyro+0x28C Vector3D zero (2936-region line 2936? verified 0x8c*4=0x230... correction: param_1+0x8c=0x230; 0x28C is param_1+0xA3: FUN_00408440(param_1+0xa3,zero) line 2929)
- gyro+0x298 quat/euler <- zero-quat DAT_004e0f8c (2928, FUN_00409a00)
- gyro+0x2A8..0x2B0 euler scratch used by damage response (zeroed there; +0x2AC = external pitch)
- gyro+0x2B4 body euler integrator accumulator: zeroed (2939, FUN_00408e90 = 3-float copy, no clobber); written to mechJointNode as ROTATION by WriteMechJoint (2617-2620); ApplyDamageTorque/VerticalImpulse kick bodyAccel
- gyro+0x2C0 rotationSpringConstant <- res+0x148 (2830)
- gyro+0x2CC rotationDampingConstant <- res+0x154 (2833)
- gyro+0x2D8 bodyLimitLow = rotationPosSpring * 2.0f (2884, FUN_004092fc)
- gyro+0x2E4 bodyLimitHigh = rotationNegSpring * 2.0f (2885)
- gyro+0x2F0 rotationPosSpring <- res+0x160 (2834, FUN_00408e90 3-float)
- gyro+0x2FC rotationNegSpring <- res+0x16C (2835)
- gyro+0x308 bodyAccel (zero, 2940); gyro+0x314 bodyForce (zero, 2941); gyro+0x320 bodyWork (zero, 2942)
- gyro+0x32C..0x33C five damage multipliers <- res+0x1B8..0x1C8 (2836-2840)
- gyro+0x340/0x350/0x360/0x370/0x380 five 4-float damage-response records <- res+0x1CC/0x1DC/0x1EC/0x1FC/0x20C (2841-2875)
- gyro+0x390 animationOffset=0; +0x394 animationScale=1.0f; +0x398 animationPhase=0 (2921-2923)
- gyro+0x39C maxAnimationNoise <- res+0xFC; +0x3A0 min <- res+0x100; +0x3A4 rotationPerSecond <- res+0x104 (2813-2815)
- gyro+0x3A8 swayBias=0 in ctor (2927); OVERWRITTEN EVERY FRAME by the mech performance: gyro+0x3A8 = mech+0x3F0 (disasm 0x4aaf24-0x4aaf3c)
- gyro+0x3AC..0x3B8 percentageOnNormal/Destruction/Degradation/Failure <- res+0x108..0x114 (2816-2820)
- gyro+0x3BC swayAngle=0; +0x3C0 sway timer=0; +0x3C4 sway flag=0 (2924-2926)
- gyro+0x3C8 eyeJointNode <- resolve(res+0x178 'EyeJoint') (2887-2915); gyro+0x3CC mechJointNode <- resolve(res+0x198 'MechJoint') (2895-2920) -- NOT crossed in the binary
- gyro this[7..9] Performance = PTR@0050fe08 = FUN_004b275c, installed only if (owner+0x28 & 0xC)==0 && (owner+0x29 byte & 1) [flags&0x100] (2807-2811)
- mech+0x360 EyepointRotation (attr id 0x1C, descriptor @0x50bf10, stored offset-tag 0x361); mech+0x528 = gyroSubsystem (param_1[0x14a], factory case 0xbc4 bt_mech.cpp.c:355-365); mech+0x438 = torsoSubsystem (param_1[0x10e]); mech+0x3B0 legAnimationState; mech+0x650 deathAnimationLatched; mech+0x3F0 sway accumulator (speed model, cap DAT_0x4ab17c, scale mech+0x78C, gait-4 term mech+0x798); mech+0x57C eye-write suppress flag (only read here in the export)

## recon_mismatches
- game/reconstructed/gyro.cpp:404-414 -> the claim 'WriteEyeJoint/WriteMechJoint have NO direct caller -- engine generic joint pass' is FALSE. They are called from the Mech master per-frame performance FUN_004a9b5c (decomp-export gap 0x4a9b5c-0x4ab188) at 0x4aaf74 (FUN_004b2eac(gyro, time_slice)) and 0x4aaf83 (FUN_004b34ec(gyro)); GyroscopeSimulation must NOT call them. Move the calls to the mech per-frame path with the binary gates.
- game/reconstructed/gyro.cpp:404-414 -> missing gates: both writers require gyro!=0 && mech->deathAnimationLatched(+0x650)==0; WriteEyeJoint additionally requires mech->legAnimationState(+0x3B0)!=0 && mech+0x57C==0. The recon runs both unconditionally every gyro tick.
- game/reconstructed/gyro.cpp (no line) -> missing per-frame swayBias feed: immediately before the writer calls the binary does gyro->swayBias(0x3A8) = mech+0x3F0 (overspeed sway accumulator built at 0x4aade8-0x4aaf14). Recon leaves swayBias at its ctor 0.
- game/reconstructed/gyro.cpp:235 -> 'eyeOrientation = r->springConstant' is WRONG twice: res+0x118 goes to this+0x1E8 (springConstant, ctor 2821), and this+0x1DC is an integrator accumulator zero-initialized at ctor end (2935). gyro.hpp:268-274 labels are shifted: 0x1E8 is springConstant (not eyeVelocity), 0x1F4 is dampingConstant (not 'eyeSpringConstant work'), and the recon never copies dampingConstant at all.
- game/reconstructed/gyro.cpp: ctor never initializes eyeLimitLow@0x200/eyeLimitHigh@0x20C (= posSpring/negSpring copies, 2876-2883), bodyLimitLow@0x2D8/bodyLimitHigh@0x2E4 (= 2.0f * rotationPos/NegSpring, 2884-2885), nor zeroes 0x1DC/0x230/0x23C/0x248/0x28C/0x298/0x2B4/0x308/0x314/0x320 (2928-2942) -- the exact source of the 0xCDCDCDCD/-4.31e8/NaN garbage seen live.
- game/reconstructed/gyro.cpp:110-111 + 536-562 (WriteMechJoint) -> naming is inverted but behavior matches: FUN_0041d11c is SetTranslation and the binary writes this+0x1DC (eye position) as the mechJointNode TRANSLATION and this+0x2B4 (body euler) as its ROTATION; the recon's SetNodeRotation/SetNodeVector labels obscure that this+0x1DC is a TRANSLATION (position offset), i.e. the steady eye offset is a position spring, not angles.
- game/reconstructed/mech.cpp:1458 comment + task hypothesis -> the binary DOES update EyepointRotation per frame: EyepointRotation.x = torso+0x1E4 via FUN_004b66b4(torso, &mech+0x360) called at 0x4aafab every frame (gate torso && !death); and the full euler is set on glance-state changes by FUN_004afd10 (mechmppr; part_013.c:396-467, angles mech+0x564/0x568/0x56C/0x570, yaw=PI for look-back), called from the mapper performance FUN_004d196c (part_014.c:6762). The GYRO never writes the attribute -- its output goes only through the two joints.
- game/reconstructed/gyro.cpp / gyro.hpp -> missing member: gyro+0x258 (Scalar* external torso-pitch pointer; ctor default &this+0x254=0). Mech::CreateStreamedSubsystems tail (bt_mech.cpp.c:508-510) re-points it to &torso+0x1D8 when both subsystems exist; consumed by the unexported damage-response FUN_004b2980 (disasm 0x4b2a67). The port's stream tail lacks this link.
- game/reconstructed/gyro.cpp:571-598 UpdateAnimationNoise -> dead code in the shipped binary: zero E8 callers and no stored pointer anywhere in the 0x401000-0x535fff image; do not wire it.
- game/reconstructed/gyro.cpp:604-647 impulse hooks -> callers exist but are in export gaps: the Mech damage hub calls the gyro damage-response FUN_004b2980 @0x4a02fb (which fans out to ApplyDamageImpulse/Torque/VerticalImpulse at 0x4b2d00/0x4b2d23/0x4b2d4b/0x4b2d6e), and the mech performance itself calls torque/impulse at 0x4aa254/0x4aa288/0x4aa342/0x4aa81e/0x4aa86c (ground/collision crunch; mech4.cpp:3277 dormant hook) plus one at 0x4bc194. FUN_004b2980 (0x4b2980-0x4b2d8b) is unreconstructed -- without it there is no hit-bounce even with correct integrators.

## fix
CONCRETE RE-ENABLE PLAN (in order; every step byte-grounded above):
1. Fix the ctor (gyro.cpp @004b3778 replica): copy springConstant->this+0x1E8 (res+0x118), dampingConstant->this+0x1F4 (res+0x124); eyeLimitLow(0x200)=posSpring, eyeLimitHigh(0x20C)=negSpring (identity-transform = plain copy); bodyLimitLow(0x2D8)=2.0f*rotationPosSpring, bodyLimitHigh(0x2E4)=2.0f*rotationNegSpring; copy the 5 damage multipliers (0x32C..) + 5 response records (0x340..0x380); ZERO every accumulator the binary zeroes: 0x1DC (eye position), 0x230, 0x23C, 0x248, 0x28C, 0x298(zero-quat), 0x2B4, 0x308, 0x314, 0x320, plus 0x254=0, 0x258=&this+0x254, 0x25C=matrix(zero), scalars 0x390=0/0x394=1.0f/0x398=0/0x3A8=0/0x3BC=0/0x3C0=0/0x3C4=0. Rename gyro.hpp members to match (springConstant@0x1E8, dampingConstant@0x1F4, eyeOffset@0x1DC, torsoPitchPtr@0x258, ...) and static_assert-lock 0x1D8,0x1DC,0x1E8,0x1F4,0x200,0x20C,0x218,0x224,0x230,0x23C,0x248,0x254,0x258,0x2B4,0x2C0,0x2CC,0x2D8,0x2E4,0x2F0,0x2FC,0x308,0x314,0x320,0x3A8,0x3BC,0x3C8,0x3CC and sizeof<=0x3D0.
2. Remove the WriteEyeJoint()/WriteMechJoint() calls from GyroscopeSimulation (gyro.cpp:413-414). GyroscopeSimulation ends with IntegrateEyeJoint + IntegrateBody, exactly like @004b275c.
3. Add the writer dispatch to the Mech per-frame path (mech4.cpp master-perf tail, after the sway/ground block, before the torso EyepointRotation update), via a bridge fn in the gyro TU (databinding rule): if (gyroSubsystem) { gyro->SetSwayBias(mech sway accumulator /*mech+0x3F0 model; 0 until that model is reconstructed*/); if (!deathAnimationLatched) { if (legAnimationState != 0 && /*mech+0x57C*/ 0 == 0) gyro->WriteEyeJoint(); gyro->WriteMechJoint(); } }.
4. Swap the factory: mech.cpp:1029-1030 -> `subsystemArray[id] = CreateGyroSubsystem(this, id, seg);` (extern already documented at mech.cpp:216-218); keep `gyroSubsystem = subsystemArray[id];`.
5. Add the post-stream link (port analog of bt_mech.cpp.c:508-510, right after the subsystem stream loop): if (torsoSubsystem && gyroSubsystem) gyro->BindExternalPitch(&torso->pitch /*torso+0x1D8*/) via a bridge setter.
6. Verify joints under BT_GYRO_LOG: eyeJointNode from r->eyeJoint ('jointlocal'), mechJointNode from r->mechJoint ('jointeye'), mechJointNode type must be 5 (BallTranslation); WriteMechJoint writes TRANSLATION=this+0x1DC and ROTATION=this+0x2B4 -- if the port previously wrote 'jointlocal' from WriteMechJoint the two ResolveJoint results were crossed somewhere; assert the names before the writes.
7. Confirm the Performance actually installs: log owner->simulationFlags at gyro ctor; the gate is (flags & 0xC)==0 && (flags & 0x100)!=0 -- if 0x100 is not set on live masters in the port, GyroscopeSimulation never runs (silent inert gyro).
8. SAFE-ENABLE CHECKLIST (all must hold before un-stubbing): [a] every this+0x1D8..0x3CC field above named+initialized (no MSVC 0xCD fill reachable by the integrators/writers); [b] layout static_asserts pass; [c] both joints resolve non-NULL and correct types (eye any, mech type 5) else the writers skip (keep NULL guards); [d] integrator fields mapped per the corrected member list (the eye integrator reads 0x1DC/0x218/0x224 displacements, multiplies by 0x1E8 and 0x1F4, accumulates 0x230->0x23C, clamps into [0x200,0x20C] -- fix per the integrator-angle report); [e] first-frame values finite in BT_GYRO_LOG (eyeOffset converges to the posSpring/negSpring equilibrium = the authentic steady eye offset; body euler ~0 at rest); [f] no NaN after sustained run + combat; [g] hit-bounce requires additionally reconstructing FUN_004b2980 (0x4b2980-0x4b2d8b) and calling it from the damage hub -- until then enable only the steady-offset half and keep the mech4.cpp:3277 crunch feed dormant or route it through ApplyDamageImpulse/Torque directly (those bodies are already faithful).
9. Leave EyepointRotation alone for the gyro work; for full authenticity implement separately: per-frame eyepointRotation.pitch = torso pitch (FUN_004b66b4 analog, gated torso && !death) and the mapper glance states (FUN_004afd10 cases, angles from mech+0x564..0x570).
