All verification complete. The spring values live in the mech .MDL config streamed from archives (compressed — the "PosSpringX" tokens exist only in the EXE parser string pool), so the numeric equilibrium must be captured at runtime via the ctor log. Here is the synthesized plan. --- # GYRO RECONSTRUCTION — DEFINITIVE IMPLEMENTATION PLAN All contested claims re-verified against `reference/decomp/all/part_013.c` and raw bytes in `reference/decomp/section_dump.txt`. **Resolved disagreement:** Report 3's "WriteEyeJoint/WriteMechJoint have no caller / engine vtable pass" is **WRONG**. Byte-verified at `section_dump.txt` line `4aaf70`: `e8 33 7f 00 00` @0x4aaf74 = `call 0x4B2EAC` (WriteEyeJoint thunk, args `push [ebp+0xC]` dt + gyro) and `e8 64 85 00 00` @0x4aaf83 = `call 0x4B34EC` (WriteMechJoint, gyro only). Both live in the Mech master per-frame performance FUN_004a9b5c (a decomp-export gap, 0x4a9b5c–0x4ab188), with byte-readable gates: `[ebx+0x528]` gyro≠0, `[ebx+0x650]`(deathLatch)==0; WriteEyeJoint additionally `[ebx+0x3B0]`(legAnimState)≠0 && `[ebx+0x57C]`==0; preceded by `gyro+0x3A8 = mech+0x3F0` (swayBias feed, bytes at 0x4aaf18–0x4aaf3c); followed by `call 0x4B66B4(torso=[ebx+0x438], &mech+0x360)` @0x4aafab — the per-frame EyepointRotation.pitch = torso pitch writer. Everything else in the four reports is mutually consistent and I re-confirmed the ctor (part_013.c:2780–2953), both integrators (:2362–2437, :2443–2549), and both writers (:2558–2624) line-by-line. UpdateAnimationNoise @004b357c: no caller found anywhere — dead code, do not wire [T1]. ## 1. CORRECTED MEMBER MAP (ctor @004b3778; `param_1[N]` = byte 4N; every byte 0x1D8–0x3CF is ctor-initialized — the binary leaves NO fill) | Offset | Name (corrected) | Init (ctor line) | |---|---|---| | 0x01C/20/24 | Performance triple → GyroscopeSimulation @004b275c (PTR@0050fe08) | only if `(owner+0x28 & 0xC)==0 && (owner+0x29 & 1)` i.e. master && flags&0x100 (:2807-2811) | | 0x1D8 | exageration | res+0xF8 (:2812) | | 0x1DC | **eyePosition** (eye TRANSLATION integrator state) | **ZERO** (:2935) — NOT springConstant | | 0x1E8 | **springConstant** vec3 | res+0x118 (:2821) | | 0x1F4 | **dampingConstant** vec3 | res+0x124 (:2823) | | 0x200 | eyeClampUpper (min-clamped against) | = posSpring via identity 3×4 transform = copy (:2876-2879) | | 0x20C | eyeClampLower (max-clamped against) | = negSpring, same (:2880-2883) | | 0x218 / 0x224 | posSpring / negSpring | res+0x130 / +0x13C (:2825/:2827) | | 0x230 | eyeForce accumulator (NOT cleared per frame; damage-impulse target @004b2d8c) | ZERO (:2936) | | 0x23C | eyeVelocity | ZERO (:2937) | | 0x248 | eyeWork scratch | ZERO (:2938) | | 0x254 | Scalar = 0 (:2931) | | 0x258 | **Scalar\* externalPitchPtr** = `&this+0x254` (:2933); Mech post-stream re-points to `&torso+0x1D8` (bt_mech.cpp.c:508-510); read by damage-response FUN_004b2980 | | 0x25C–0x28B | 3×4 work matrix: identity (:2793), rebuilt from placement (:2934) | | 0x28C / 0x298 / 0x2A8 | placePos=0 (:2929) / identity quat {0,0,0,1} (:2928) / zero rot (:2930) | | 0x2B4 | **bodyOrientation** (body ROTATION state) | ZERO (:2939) | | 0x2C0 / 0x2CC | rotationSpringConstant / rotationDampingConstant (Roll,Yaw,Pitch order) | res+0x148 / +0x154 (:2830/:2833) | | 0x2D8 / 0x2E4 | bodyClampUpper = 2.0×rotationPosSpring / bodyClampLower = 2.0×rotationNegSpring | (:2884/:2885, FUN_004092fc) | | 0x2F0 / 0x2FC | rotationPosSpring / rotationNegSpring (Pitch,Yaw,Roll, radians) | res+0x160 / +0x16C (:2834/:2835) | | 0x308 / 0x314 / 0x320 | bodyForce (impulse target) / bodyVelocity / bodyWork | ZERO (:2940-2942) | | 0x32C–0x33C | 5 damage multipliers | res+0x1B8..0x1C8 (:2836-2840) | | 0x340–0x38F | 5× DamageResponse{trans,pitchRoll,yaw,vibration} | res+0x1CC/1DC/1EC/1FC/20C (:2841-2875) | | 0x390/0x394/0x398 | animOffset=0 / animScale=1.0f / animPhase=0 (:2921-2923) | | 0x39C / 0x3A0 | maxAnimationNoise ← res+0xFC / minAnimationNoise ← res+0x100 (:2814/:2813 — MAX at 0x39C confirmed) | | 0x3A4 | rotationPerSecond ← res+0x104 (:2815) | | 0x3A8 | swayBias = 0 (:2927); **overwritten every frame = mech+0x3F0** (bytes 0x4aaf18-3c) | | 0x3AC–0x3B8 | percentageOnNormal/Destruction/Degradation/Failure ← res+0x108..0x114 | | 0x3BC / 0x3C0 / 0x3C4 | swayAngle=0 / noiseTimer=0 / noiseActive=0 (:2924-2926) | | 0x3C8 | eyeJointNode ← resolve(res+0x178 "EyeJoint" = `jointlocal`) — name→segment (FUN_00424b60)→seg+0xC0 joint id→skeleton vfunc+0x34 (:2904-2915) | | 0x3CC | mechJointNode ← resolve(res+0x198 "MechJoint" = `jointeye`) (:2916-2920). **NOT crossed.** | Node object: node+0xC = value block ptr, +0x10 = type, +0x14 = dirty, +0x18 = owner (+0xFC stamped on write). Type 5 value block = {vec3 translation @+0, vec3 rotation euler @+0xC}. Helpers: FUN_0041cfa0=GetRotationPtr (type5→value+0xC), FUN_0041d020=SetRotation, FUN_0041d0a8=SetScalar, FUN_0041d11c=SetTranslation. FUN_004086d0 = **component-wise multiply, NOT cross** (part_000.c:8294). FUN_00408744 = 3×4 row-matrix × vec (identity → pass-through), NOT a clamp. ## 2. FAITHFUL INTEGRATORS **IntegrateEyeJoint (FUN_004b2ec0, byte-exact; CompMul = per-component multiply):** ``` d1 = eyePosition - negSpring // STATE minus target (:2376) d2 = eyePosition - posSpring // (:2377) eyeForce += CompMul(springConstant, d1) // do NOT clear eyeForce — it carries last eyeForce += CompMul(springConstant, d2) // frame's damping term + hit impulses eyeWork = eyeForce; eyeVelocity += eyeWork * dt eyeForce = CompMul(dampingConstant, eyeVelocity) // OVERWRITE (:2387, +0x1F4) eyeWork = eyeForce; eyeVelocity += eyeWork * dt eyePosition += eyeVelocity // NO dt — identity-matrix pass-through (:2391-2393) per-axis: eyePosition = min(eyePosition, eyeClampUpper) // 0x200 block eyePosition = max(eyePosition, eyeClampLower) // 0x20C block ``` **IntegrateBody (FUN_004b30ec, with the two x/z crossings, verified :2479-2484 and :2504-2506):** ``` dN = bodyOrientation - rotationNegSpring; dP = bodyOrientation - rotationPosSpring k = rotationSpringConstant // BOTH terms use k, never damping bodyForce.x += k.x*dN.z; bodyForce.y += k.y*dN.y; bodyForce.z += k.z*dN.x bodyForce.x += k.x*dP.z; bodyForce.y += k.y*dP.y; bodyForce.z += k.z*dP.x bodyWork = bodyForce; bodyVelocity += bodyWork * dt bodyForce = CompMul(rotationDampingConstant, bodyVelocity) // uncrossed (:2498) bodyWork = bodyForce; bodyVelocity += bodyWork * dt bodyOrientation.x += bodyVelocity.z; .y += bodyVelocity.y; .z += bodyVelocity.x clamps in y,x,z order: min vs bodyClampUpper(0x2DC/0x2D8/0x2E0), max vs bodyClampLower(0x2E8/0x2E4/0x2EC) ``` **Equilibrium:** at rest, per-axis force ∝ 2k·(x − (pos+neg)/2), so with restoring (negative) spring constants **eyePosition settles at (posSpring+negSpring)/2 per axis** — a steady TRANSLATION written into `jointeye` (which parents `siteeyepoint`, rest offset tran=(-0.0039, 3.153, -1.503) per AVA.SKL:715-734). This is the authentic steady eye offset the port lacks (our stubbed gyro leaves the joint at raw baseOffset). The offset axis/magnitude depends on the .MDL PosSpring/NegSpring values, which live in the archived model configs (only the parser token strings are in BTL4OPT.EXE — not statically extractable here); capture them at runtime by logging `posSpring`/`negSpring` in the ctor under BT_GYRO_LOG. If pos = −neg the midpoint is 0 and the steady offset comes only from transient dynamics; expect a nonzero Y (down-forward settle) if asymmetric. ## 3. THE WRITERS - **WriteMechJoint (FUN_004b34ec)** → `mechJointNode` (= `jointeye`), gated `type==5` only: `SetTranslation(node, eyePosition@0x1DC)` via FUN_0041d11c if it differs >1e-4 from `value+0`; then `SetRotation(node, bodyOrientation@0x2B4)` via FUN_0041d020 if it differs >1e-4 from `value+0xC`. One node, both channels. No null check in the binary (keep the port's guard). - **WriteEyeJoint (FUN_004b33e0)** → `eyeJointNode` (= `jointlocal`, the ROOT balltranslate joint): **multiplicative sway attenuation of the joint's CURRENT animation value** — type<3: scalar `*= swayAngle(0x3BC)`; type 4/5: rotation euler `*= swayAngle` (all 3 components). It does NOT write the spring output. Because it multiplies the current value, it must run AFTER the animation pass re-writes the joint each frame, else the rotation decays geometrically to zero. - **Dispatch (byte-verified, the resolved disagreement):** both are called from the Mech master performance tail each frame — NOT from GyroscopeSimulation — with gates: `if (gyro) gyro->swayBias = mech->swayAccum /*mech+0x3F0*/; if (gyro && !deathLatched) { if (legAnimState != 0 && mech+0x57C == 0) WriteEyeJoint(gyro, dt); WriteMechJoint(gyro); }`, then `EyepointRotation.x = torso->pitch /*torso+0x1E4*/` via the FUN_004b66b4 analog (gate: torso && !deathLatched). The gyro never touches the EyepointRotation attribute; glance-look writes to it come separately from the mapper state machine FUN_004afd10 (yaw from mech+0x564/0x568, yaw=π+pitch mech+0x570 look-back, pitch mech+0x56C look-down). ## 4. PATCH LIST (ordered) **A. `game/reconstructed/gyro.hpp:266-308`** — replace the own-field block with the binary-exact layout of §1: insert the missing 0x254–0x2B3 block (`Scalar spare0; Scalar* externalPitchPtr; Scalar workMatrix[12]; Vector3D placePos; Scalar placeQuat[4]; Vector3D placeRot;`), reorder/rename: `eyePosition@0x1DC, springConstant@0x1E8, dampingConstant@0x1F4, eyeClampUpper@0x200, eyeClampLower@0x20C, posSpring@0x218, negSpring@0x224, eyeForce@0x230, eyeVelocity@0x23C, eyeWork@0x248, [0x254 block], bodyOrientation@0x2B4, rotationSpringConstant@0x2C0, rotationDampingConstant@0x2CC, bodyClampUpper@0x2D8, bodyClampLower@0x2E4, rotationPosSpring@0x2F0, rotationNegSpring@0x2FC, bodyForce@0x308, bodyVelocity@0x314, bodyWork@0x320, Scalar damageMultiplier[5]@0x32C, Gyroscope__DamageResponse damageResponse[5]@0x340`, then the existing 0x390+ scalars (correct as-is). Delete old `eyeVelocity@0x1E8 / eyeLimitLow / eyeLimitHigh / eyeSpringConstant / eyeAccel / bodyPosSpring / bodyNegSpring / bodyAccel` names. **B. `gyro.cpp:151-155` (GyroLayoutCheck)** — add static_asserts: `offsetof` of eyePosition==0x1DC, springConstant==0x1E8, dampingConstant==0x1F4, eyeClampUpper==0x200, eyeClampLower==0x20C, posSpring==0x218, negSpring==0x224, eyeForce==0x230, bodyOrientation==0x2B4, bodyClampUpper==0x2D8, rotationPosSpring==0x2F0, bodyForce==0x308, damageMultiplier==0x32C, damageResponse==0x340, swayAngle==0x3BC, eyeJointNode==0x3C8, mechJointNode==0x3CC, and `sizeof(Gyroscope)==0x3D0` (tighten from `<=`). **C. `gyro.cpp:235-247` (ctor)** — delete `eyeOrientation = r->springConstant` (the confirmed poison line); add, mirroring :2812-2942: ```cpp springConstant = r->springConstant; dampingConstant = r->dampingConstant; posSpring = r->posSpring; negSpring = r->negSpring; eyeClampUpper = posSpring; eyeClampLower = negSpring; // :2876-2883 rotationSpringConstant = r->rotationSpringConstant; rotationDampingConstant = r->rotationDampingConstant; rotationPosSpring = r->rotationPosSpring; rotationNegSpring = r->rotationNegSpring; bodyClampUpper = rotationPosSpring * 2.0f; bodyClampLower = rotationNegSpring * 2.0f; // :2884-2885 damageMultiplier[0..4] = r->collision..energyDamageMultiplier; // :2836-2840 damageResponse[0..4] = r->collision..energyDamageResponse; // :2841-2875 eyePosition = eyeForce = eyeVelocity = eyeWork = Vector3D(0,0,0); // :2935-2938 bodyOrientation = bodyForce = bodyVelocity = bodyWork = Vector3D(0,0,0); // :2939-2942 spare0 = 0; externalPitchPtr = &spare0; // :2931-2933 workMatrix = identity; placePos = 0; placeQuat = {0,0,0,1}; placeRot = 0; // :2793,:2928-2934 ``` Also add to the BT_GYRO_LOG block: print posSpring/negSpring/springConstant/dampingConstant (captures the equilibrium data + confirms constants are negative/restoring). **D. `gyro.cpp:425-459` (IntegrateEyeJoint)** — replace body with §2 exactly. Kills: flipped subtract (428-429), wrong constant + Cross (431-432, 441), uninitialized clamps (453-458 now use eyeClampUpper/Lower). **E. `gyro.cpp:467-496` (IntegrateBody)** — replace body with §2 exactly. Kills: rotationDampingConstant on the second spring term (474), missing x/z crossings (both places), Cross at 482. **F. `gyro.cpp:404-414` (GyroscopeSimulation)** — delete the `WriteEyeJoint(); WriteMechJoint();` calls + the wrong "no direct caller" comment. The binary Performance ends after the two integrators (:2291-2293). **G. `gyro.cpp:311-331` (ResetToInitialState)** — replace line 324 `bodyPosSpring = zero` with `bodyOrientation = zero` (@004b2678:2194 targets 0x2B4); add `eyeWork = zero; bodyWork = zero` (:2193/:2197). Rename eyeAccel/eyeForce per the new names. **H. `gyro.cpp` (new public bridges)** — `void SetSwayBias(Scalar v) { swayBias = v; }`, `void BindExternalPitch(Scalar *p) { externalPitchPtr = p; }`, and expose `WriteEyeJoint/WriteMechJoint` (or a `WriteJoints(dt, legAnimState)` bridge) for the mech-side dispatch. **I. `game/reconstructed/mech4.cpp` (master performance tail, near the dormant crunch hook at :3277)** — add the binary dispatch (bytes 0x4aaf18-0x4aafab): ```cpp if (gyroSubsystem) { GyroSetSwayBias(gyroSubsystem, 0.0f /* mech+0x3F0 overspeed sway model — 0 until reconstructed */); if (!deathAnimationLatched) { if (legAnimationState != 0 /* && mech+0x57C==0 (always 0 in normal play) */) GyroWriteEyeJoint(gyroSubsystem); GyroWriteMechJoint(gyroSubsystem); } } // then (separate fidelity item): if (torso && !deathAnimationLatched) eyepointRotation.x = torso pitch (FUN_004b66b4) ``` Ordering: this must run AFTER the animation pass writes the joints (WriteEyeJoint is multiplicative). **J. `game/reconstructed/mech.cpp:1029-1030` (enable)** — swap the stub: `subsystemArray[id] = CreateGyroSubsystem(this, id, seg);` (extern per mech.cpp:216-218); keep line 1031. Update the stale WAVE-5 comment. Remove/park `RECON_SUBSYS(Gyro)` at mech.cpp:178 if now unreferenced. **K. `mech.cpp:~1150` (post-stream tail)** — enable the commented gyro↔torso link as `GyroBindExternalPitch(gyroSubsystem, TorsoPitchAddr(sinkSourceSubsystem))` (binary: `*(gyro+0x258) = torso+0x1D8`, bt_mech.cpp.c:508-510). Needed by the damage-response path. **L. Deferred (flag, don't fake):** the damage hub → gyro fan-out FUN_004b2980 (0x4b2980–0x4b2d8b, called @0x4a02fb) is unreconstructed — without it there's no weapon-hit bounce even with perfect integrators (the ApplyDamageImpulse/Torque/Vertical bodies at gyro.cpp:609-647 are faithful targets for it). The mech+0x3F0 overspeed sway model (bytes 0x4aade8-0x4aaf14) is also unreconstructed; feed 0 meanwhile. UpdateAnimationNoise: dead code — leave unwired. Keep the ctor Performance gate as-is but LOG owner->simulationFlags: if live masters lack flag 0x100 the gyro silently never simulates. ## 5. VERIFICATION (one run, BT_GYRO_LOG=1) 1. **Build:** all new static_asserts pass; grep the link log for unresolved externals (`/FORCE` trap). 2. **Ctor log:** `eye 'jointlocal' -> type=5 ; mech 'jointeye' -> type=5`; both non-null, mech node type MUST be 5; posSpring/negSpring/springConstant/dampingConstant printed finite, spring/damping constants negative (restoring). Confirm "Performance installed" (simulationFlags gate) fires for the player mech. 3. **No-NaN:** the periodic WriteMechJoint log must show finite values from frame 0 (previously −4.31e8/NaN). Add a one-shot `isfinite` check on eyePosition/bodyOrientation in the log block; assert-log if violated. 4. **Steady offset:** eye=(x,y,z) in the log converges within ~1-2 s to (posSpring+negSpring)/2 per axis (compute from the ctor-logged values) and stays; body=(≈0,≈0,≈0) at rest. Visually: cockpit eyepoint shifts from the raw `jointeye` baseOffset by that midpoint — screenshot before/after vs the reference footage (the "eye too far back" delta should close if the midpoint is forward/up). 5. **Bounce (interim, until FUN_004b2980 lands):** trigger `ApplyDamageImpulse(dir, mag)` from a test hook on a weapon hit — eye log shows a spike decaying back to the midpoint over ~1 s (damped oscillation), cockpit frame visibly jolts relative to the world. No residual drift after repeated hits (eyeForce carry-over is by-design, not a leak — verify it re-converges). 6. **Sway sanity:** with legAnimState!=0, WriteEyeJoint scales `jointlocal` rotation by swayAngle each frame post-anim; confirm walk-cycle sway is attenuated (healthy gyro: swayAngle→percentageOnNormal) and no geometric decay to zero pose (would indicate dispatch running before the animation pass). 7. **Regression:** full combat loop un-regressed (death collapse, respawn warp, -net drive); `btl4.log` clean of gyro asserts after a sustained session. Key files: `C:\git\bt411\game\reconstructed\gyro.hpp`, `C:\git\bt411\game\reconstructed\gyro.cpp`, `C:\git\bt411\game\reconstructed\mech.cpp` (:178, :1019-1032, :~1150), `C:\git\bt411\game\reconstructed\mech4.cpp` (:3277 region); ground truth `C:\git\bt411\reference\decomp\all\part_013.c` (:2362-2437 eye, :2443-2549 body, :2558-2624 writers, :2717-2953 ctor), `C:\git\bt411\reference\decomp\section_dump.txt` (bytes 4aaf10-4aafb0 = dispatch + gates + EyepointRotation writer).