Files
BT411/scratchpad/gyro_synthesis.md
T
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

17 KiB
Raw Blame History

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, 0x4a9b5c0x4ab188), 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 0x4aaf180x4aaf3c); 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:27802953), both integrators (:23622437, :24432549), and both writers (:25582624) 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 0x1D80x3CF 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
0x25C0x28B 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)
0x32C0x33C 5 damage multipliers res+0x1B8..0x1C8 (:2836-2840)
0x3400x38F 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)
0x3AC0x3B8 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 0x2540x2B3 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 (0x4b29800x4b2d8b, 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' -> <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.
  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).