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>
17 KiB
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), gatedtype==5only:SetTranslation(node, eyePosition@0x1DC)via FUN_0041d11c if it differs >1e-4 fromvalue+0; thenSetRotation(node, bodyOrientation@0x2B4)via FUN_0041d020 if it differs >1e-4 fromvalue+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); }, thenEyepointRotation.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:
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):
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)
- Build: all new static_asserts pass; grep the link log for unresolved externals (
/FORCEtrap). - Ctor log:
eye 'jointlocal' -> <ptr> type=5 ; mech 'jointeye' -> <ptr> 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. - No-NaN: the periodic WriteMechJoint log must show finite values from frame 0 (previously −4.31e8/NaN). Add a one-shot
isfinitecheck on eyePosition/bodyOrientation in the log block; assert-log if violated. - 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
jointeyebaseOffset by that midpoint — screenshot before/after vs the reference footage (the "eye too far back" delta should close if the midpoint is forward/up). - 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). - Sway sanity: with legAnimState!=0, WriteEyeJoint scales
jointlocalrotation 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). - Regression: full combat loop un-regressed (death collapse, respawn warp, -net drive);
btl4.logclean 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).