From 7c2363d89f372b1c2c9637925ea899b54d7f789f Mon Sep 17 00:00:00 2001 From: Cyd Date: Mon, 3 Aug 2026 13:19:10 -0500 Subject: [PATCH] BT410 5.3.96: the LIMP is real -- all twelve mech2 functions reconstructed, and the drag leg shows up on the wire The "gimp" family had two conflicting donor readings -- limp vs jump-jet -- flagged in 5.3.94 rather than guessed at. The binary settles it: LIMP. The mode test in every gimp function is mech+0x40 in {3,4}, the same values the damage model documents as "limp gait graphic (left 3 / right 4)"; there is no jump-jet control anywhere in the pod cockpit; and the donor's port routing ("graphicAlarm 3/4") was the same field under a different name. Its "run-jump clip" mech3 annotations were the misreading that started the murk. RECONSTRUCTED, completing all 12 of mech2's census functions: GimpLegClipFinished @004a7970 GimpBodyClipFinished @004a6344 AdvanceLegAnimationGimp @004a71f4 AdvanceBodyAnimationGimp @004a5bf8 + the limp branch atop both normal *ClipFinished + the limp pick in Simulate (replaces the normal advancers while limping) HOW A LIMP WORKS, now from the bytes rather than description: It replaces ONE stride. Limping left, the right stride (6) hands off to the left limp figure (0x16 -> the self-cycling 0x18) while the other leg keeps its normal clips. The asymmetry IS the limp. Both machines CLAMP THEIR DEMAND while in a cycle -- the leg machine writes the mapper's own speedDemand cell down to the damaged side's cap (new MechControlsMapper::SetSpeedDemand, matching the binary's direct mapper+0x128 write), the body machine clamps bodyTargetSpeed, both floor at zero. A limping mech cannot command more than its figure carries, nor reverse out of a forward cycle. The limp advancers keep states 0x16-0x1b LIVE -- the normal advancers treat those as the reset group, which is exactly why the limp flavours must be selected while limping or the figure is neutralized mid-cycle. No death latch, no wind-down: the movement modes are exclusive. ALSO EXPLAINED IN PASSING: Ghidra's 3760-byte FUN_004a6344 -- the census's largest function -- is really THREE functions. The two normal ClipFinished callbacks (@004a6928/@004a6d8c) are reached only via data pointers, so the decompiler folded them into the gimp-body machine's extent. All movement-mode reads route through Mech::MovementMode() (mech+0x40 == the simulation state), which honours a BT_FORCE_LIMP=3|4 dev hook so the gait could be verified before the damage model's limp hook exists. VERIFIED, two runs on the rig: NO-REGRESSION: the normal mission's speed sequence is BIT-IDENTICAL to 5.3.95 (7.31972, 26.6726, 22.1601, ...). The branch costs nothing. FORCED LEFT LIMP (new pod_render_limp.conf): the mapper still demands 26.9; the hull lurches at 10-23. And on the wire the healthy walk's tight pose-count pairs (650/649 ... 434/421) BREAK to a 3x asymmetry -- the drag-leg joint at 18 poses against its partner's 55. The limp is visible in the data exactly the way it will be visible on screen. STILL OPEN in this family: the damage hook (leg zone >= 0.5 -> mode 3/4, a MECHDMG increment -- nothing sets the mode in real play yet), and IntegrateMotion's remaining pieces. Co-Authored-By: Claude Fable 5 --- emulator/render-bridge/pod_render_limp.conf | 87 ++ restoration/source410/BT/MECH.CPP | 38 +- restoration/source410/BT/MECH.HPP | 42 + restoration/source410/BT/MECH2.CPP | 861 +++++++++++++++++++- restoration/source410/BT/MECH2.NOTES.md | 58 +- restoration/source410/BT/MECHMPPR.HPP | 10 + 6 files changed, 1087 insertions(+), 9 deletions(-) create mode 100644 emulator/render-bridge/pod_render_limp.conf diff --git a/emulator/render-bridge/pod_render_limp.conf b/emulator/render-bridge/pod_render_limp.conf new file mode 100644 index 00000000..2ff378fa --- /dev/null +++ b/emulator/render-bridge/pod_render_limp.conf @@ -0,0 +1,87 @@ +# pod_render_norio.conf -- pod_render_rec with the RIO serial port OFF. +# +# The emulator log shows a steady stream of serial1 RX OVERRUN errors on +# the RIO pipe, and controls post their events at HighEventPriority +# UNCONDITIONALLY (CONTROLS.HPP:250) -- so a chattering RIO port would +# flood a priority the background pump always serves first, starving the +# priority-0 renderer events the load gate waits on. This conf tests that +# by removing the port entirely. Everything else is identical to the rec +# conf, so a launch here and a hang there isolates the RIO. +# +[sdl] +output=opengl +# higher,higher not highest: HIGH_PRIORITY_CLASS starved the host desktop; +# with the retry patches a rare dropout self-recovers (see gauge_rio.conf). +priority=higher,higher +[dosbox] +memsize=32 +machine=svga_s3 +[cpu] +core=dynamic +cputype=pentium +cycles=max +[sblaster] +sbtype=sb16 +sbbase=220 +irq=5 +dma=1 +hdma=5 +[mixer] +# match the EMU8000s' native rate (no resample) and buffer ~60ms so brief +# emulation-thread stalls (RIO retry recovery) don't audibly chop +rate=44100 +blocksize=1024 +prebuffer=60 +[serial] +# RIO on COM1 with the low-latency options (rxpollus/rxburst) so the board's +# few-ms ACK deadline is met; plasma display on COM2 (real pod has both). +# VWE fork namedpipe backend (com0com/realport retired -- COM1/COM2 gone): +# DOSBox = pipe client (retry), vRIO/vPLASMA apps = servers; an unconnected +# pipe behaves as an unplugged cable so the mission still runs. serialnamedpipe.h +serial1=disabled +serial2=namedpipe pipe:vplasma +# live UNBUFFERED game output: DOS char devices are not buffered, so +# redirecting stdout to COM3 lands every line immediately. A normal +# '> file' redirect stays 0 bytes until the process exits, which hides +# all progress on a run that does NOT crash. +serial3=file file:C:\VWE\TeslaRel410\emulator\render-bridge\podlog.txt +[autoexec] +mount c "C:\VWE\TeslaRel410\ALPHA_1" +c: +cd \REL410\BT +set VIDEOFORMAT=svga +rem production pod card init (PARAMETR.BAT:181-186): DIAGNOSE + AWEUTIL per +rem card -- AWEUTIL /S does the EMU8000 bring-up and DRAM detect the HMI SOS +rem driver relies on; skipping it left the cards uninitialized (silent). +rem aweutil /s SKIPPED for now: it verifies the AWE32 GM ROM, which the +rem emulated cards lack (hangs in a retry loop) -- restore once the ROM is +rem dumped from a real card. diagnose /s kept (passes, sets mixer config). +set BLASTER=A220 I5 D1 H5 P330 T6 +c:\sb16\diagnose /s +set BLASTER=A240 I7 D3 H6 P300 T6 +c:\sb16\diagnose /s +set BLASTER=A220 I5 D1 H5 P330 T6 +set TEMP=c:\ +rem arena1 city mission (TESTARN.EGG: map=arena1, time=day) with the RIO +rem attached; stdout redirected so mission-load progress survives kills. +set BT_JOINTS=1 +set BT_FORCE_LIMP=3 +set BT_MECH_LOG=1 +set BT_MAP_LOG=1 +set BT_MER_LOG=1 +set BT_VID_LOG=1 +set BT_LAUNCH_LOG=1 +set BT_STACK_LOG=1 +set BT_FORCE_THROTTLE=0.6 +set BT_FORCE_TURN=0.25 +set HEAPSIZE=15000000 +set L4GAUGE=640x480x16 +call setenv.bat r s n p +32rtm.exe -x +BTL4REC.EXE -egg testarn.egg > COM3 + +echo GAME-RC=%errorlevel% >> RC.TXT +32rtm.exe -u +echo ALPHA1-RUN-DONE +pause + diff --git a/restoration/source410/BT/MECH.CPP b/restoration/source410/BT/MECH.CPP index b7dc6081..a47861e9 100644 --- a/restoration/source410/BT/MECH.CPP +++ b/restoration/source410/BT/MECH.CPP @@ -249,6 +249,14 @@ Mech::Mech( deathAnimationLatched = 0; legResetLatch = 0; bodyResetLatch = 0; + limpModeOverride = 0; + { + const char *fl = getenv("BT_FORCE_LIMP"); + if (fl != NULL && (*fl == '3' || *fl == '4')) + { + limpModeOverride = *fl - '0'; + } + } { int i; for (i = 0; i < AnimationSlotCount; ++i) @@ -1242,9 +1250,35 @@ void // dead-reckon latency fold, and the turn-in-place dispatcher (mech4). //----------------------------------------------------------------------- // - AdvanceLegAnimation(time_slice); + // + // The limp pick (IntegrateMotion @004ab1c8): movement modes 3/4 are the + // left/right leg limps, and while limping (and the model HAS limp clips) + // the Gimp advancers replace the normal pair -- they must, because the + // normal ones treat the limp states as their reset group. + // + // BT_FORCE_LIMP=3|4 is a DEV hook that forces the pick without touching + // the simulation state, so the limp gait can be verified before the + // damage model's limp hook (leg zone >= 0.5 -> mode 3/4) is + // reconstructed. + // + int limping; { - Scalar stride = AdvanceBodyAnimation(time_slice, 0); + int mode = MovementMode(); + limping = (mode == 3 || mode == 4) && hasGimpClips; + } + + if (limping) + { + AdvanceLegAnimationGimp(time_slice); + } + else + { + AdvanceLegAnimation(time_slice); + } + { + Scalar stride = limping + ? AdvanceBodyAnimationGimp(time_slice, 0) + : AdvanceBodyAnimation(time_slice, 0); if (animationClips[5] != ResourceDescription::NullResourceID) { diff --git a/restoration/source410/BT/MECH.HPP b/restoration/source410/BT/MECH.HPP index e0318c34..326e40da 100644 --- a/restoration/source410/BT/MECH.HPP +++ b/restoration/source410/BT/MECH.HPP @@ -422,6 +422,30 @@ Scalar AdvanceBodyAnimation(Scalar time_slice, int move_joints); + // + //-------------------------------------------------------------------- + // The LIMP flavours (mech2.cpp). Selected instead of the pair above + // while the movement mode is 3 (left-leg limp) or 4 (right) and the + // model carries the limp clip set. SETTLED from the binary after two + // conflicting donor readings: the mode test is mech+0x40 -- the same + // field the damage model documents as "limp gait graphic (left 3 / + // right 4)" -- and NOT a jump-jet system. + // + // The normal advancers treat states 0x16-0x1b as the RESET group, so + // the limp flavours must be selected while limping or the limp figure + // gets its skeleton neutralized mid-cycle. + //-------------------------------------------------------------------- + // + Scalar + AdvanceLegAnimationGimp(Scalar time_slice); + Scalar + AdvanceBodyAnimationGimp(Scalar time_slice, int move_joints); + + Scalar + GimpLegClipFinished(Scalar carryover); + Scalar + GimpBodyClipFinished(Scalar carryover, int move_joints); + // // The respawn heal-and-move (binary @0049fb74): reposition the SAME // entity at the drop-zone origin, kill all motion, clear the death @@ -712,6 +736,24 @@ { Check(this); } + + // + // The movement mode (binary mech+0x40 == the simulation state): + // 3/4 = left/right leg limp, 5-8 = the falls/deaths, 2||9 = the + // death transition. Every gait read goes through here so the + // BT_FORCE_LIMP bring-up hook (set once in the ctor) can exercise + // the limp machinery before the damage model's limp hook exists. + // + int + MovementMode() const + { + Check(this); + return (limpModeOverride != 0) + ? limpModeOverride + : (int)((Mech *)this)->GetSimulationState(); + } + + int limpModeOverride; // DEV: 3/4 from BT_FORCE_LIMP; 0 = off AverageOf telemetryFilter[5]; CString resourceNameA; CString resourceNameB; diff --git a/restoration/source410/BT/MECH2.CPP b/restoration/source410/BT/MECH2.CPP index 2ee37325..84fb57da 100644 --- a/restoration/source410/BT/MECH2.CPP +++ b/restoration/source410/BT/MECH2.CPP @@ -150,6 +150,21 @@ Scalar { Check(mech); + // + // A LIMPING mech's transitions run the limp machine instead (movement + // mode 3 = left-leg limp, 4 = right; the damage model's own "limp gait + // graphic (left 3 / right 4)"). Guarded on the clip set existing -- + // LoadLocomotionClips leaves hasGimpClips 0 for a model without it. + // + { + int + mode = mech->MovementMode(); + if ((mode == 3 || mode == 4) && mech->hasGimpClips) + { + return mech->GimpLegClipFinished(carryover); + } + } + // // The binary reads subsystemArray[0] -- the roster's controls-mapper slot. // A mech without one (a replicant) reads zero and idles, which is right. @@ -339,6 +354,15 @@ Scalar { Check(mech); + { + int + mode = mech->MovementMode(); + if ((mode == 3 || mode == 4) && mech->hasGimpClips) + { + return mech->GimpBodyClipFinished(carryover, move_joints); + } + } + Scalar cycle_rate = mech->forwardCycleRate, time_scale = mech->globalTimeScale, @@ -522,7 +546,7 @@ Scalar // if (!deathAnimationLatched) { - switch (GetSimulationState()) + switch (MovementMode()) { case 5: SetLegAnimation(0x1c); deathAnimationLatched = 1; break; case 6: SetLegAnimation(0x1d); deathAnimationLatched = 1; break; @@ -778,7 +802,7 @@ Scalar if (!deathAnimationLatched) { - switch (GetSimulationState()) + switch (MovementMode()) { case 5: SetBodyAnimation(0x1c); deathAnimationLatched = 1; break; case 6: SetBodyAnimation(0x1d); deathAnimationLatched = 1; break; @@ -930,6 +954,839 @@ Scalar return distance; } +// +//############################################################################# +// THE LIMP MACHINES (@004a7970 leg / @004a6344 body) -- the transition +// tables that run while movement mode is 3 (left-leg limp) or 4 (right). +// +// The limp replaces ONE stride: limping left, the right stride (6) hands off +// to the left limp figure (0x16 -> the 0x18 cycle); limping right, the left +// stride (7) hands off to the right figure (0x17 -> 0x19). The other leg's +// clips keep their normal alternation, which is what makes it read as a limp +// rather than a different gait. +// +// Both machines CLAMP THEIR DEMAND while in any cycle -- the leg machine +// writes the mapper's own speedDemand cell down to the damaged side's cap, +// the body machine clamps bodyTargetSpeed -- so a limping mech cannot +// command more speed than its limp figure carries, and cannot command a +// reverse out of a forward cycle (the clamp floors at zero). +//############################################################################# +// +Scalar + Mech::GimpLegClipFinished(Scalar carryover) +{ + Check(this); + + MechControlsMapper + *mapper = (MechControlsMapper *)subsystemArray[0]; + Check_Pointer(mapper); + + int + mode = MovementMode(), + state = (int)legStateAlarm.GetLevel(); + + // + // The demand clamp (see the block comment above). + // + if ( + state == 6 || state == 7 || state == 0xc || state == 0xd || + state == 0x12 || state == 0x13 + ) + { + Scalar + cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax, + demand = mapper->GetSpeedDemand(); + if (demand > cap) + { + demand = cap; + } + if (demand < 0.0f) + { + demand = 0.0f; + } + mapper->SetSpeedDemand(demand); + } + + Scalar + demand = mapper->GetSpeedDemand(), + cycle_rate = forwardCycleRate, + time_scale = globalTimeScale, + cycle = legCycleSpeed; + int + plain_next; + + switch (state) + { + case 2: + legStateAlarm.SetLevel(1); + return 0.0f; + + case 3: case 8: case 9: case 0x14: case 0x15: case 0x1a: case 0x1b: + case 4: + case 0x20: + legStateAlarm.SetLevel(0); + return 0.0f; + + // + // The right-stride walk family. Continuing hands off to the LEFT + // stride when the RIGHT leg is the good one (mode 4), and to the left + // limp figure when the left leg is the bad one (mode 3). + // + case 5: case 6: case 0xe: + if ( + demand < standSpeed && + (cycle - cycle_rate * carryover) < standSpeed + ) + { + plain_next = 9; + break; + } + if ( + demand > walkStrideLength && + (cycle + cycle_rate * carryover) > walkStrideLength + ) + { + plain_next = 0xb; + break; + } + SetLegAnimation((mode == 4) ? 7 : 0x16); + return legAnimation.Advance( + carryover * cycle * time_scale / walkStrideLength, 1); + + case 7: case 0xf: + if ( + demand < standSpeed && + (cycle - cycle_rate * carryover) < standSpeed + ) + { + plain_next = 8; + break; + } + if ( + demand > walkStrideLength && + (cycle + cycle_rate * carryover) > walkStrideLength + ) + { + plain_next = 10; + break; + } + SetLegAnimation((mode == 3) ? 6 : 0x17); + return legAnimation.Advance( + carryover * cycle * time_scale / walkStrideLength, 1); + + case 10: case 0xc: + if ( + demand < reverseSpeedMax && + (cycle - cycle_rate * carryover) < reverseSpeedMax + ) + { + plain_next = 0xf; + break; + } + SetLegAnimation(0xd); + return legAnimation.Advance( + carryover * cycle * time_scale / reverseStrideLength, 1); + + case 0xb: case 0xd: + if ( + demand < reverseSpeedMax && + (cycle - cycle_rate * carryover) < reverseSpeedMax + ) + { + plain_next = 0xe; + break; + } + SetLegAnimation(0xc); + return legAnimation.Advance( + carryover * cycle * time_scale / reverseStrideLength, 1); + + case 0x10: case 0x12: + if ( + demand > gimpSpeedMax && + (gimpCycleRate * carryover + cycle) > gimpSpeedMax + ) + { + plain_next = 0x15; + break; + } + SetLegAnimation(0x13); + { + Scalar + cycle_time = carryover * cycle * time_scale / gimpStrideLength; + if (cycle_time <= 0.0f) + { + cycle_time = -cycle_time; + } + return legAnimation.Advance(cycle_time, 1); + } + + case 0x11: case 0x13: + if ( + demand > gimpSpeedMax && + (gimpCycleRate * carryover + cycle) > gimpSpeedMax + ) + { + plain_next = 0x14; + break; + } + SetLegAnimation(0x12); + { + Scalar + cycle_time = carryover * cycle * time_scale / gimpStrideLength; + if (cycle_time <= 0.0f) + { + cycle_time = -cycle_time; + } + return legAnimation.Advance(cycle_time, 1); + } + + // + // The limp figures themselves: keep cycling while the demand holds, + // exit toward standing when it drops. + // + case 0x16: case 0x18: + if ( + demand < gimpLeftSpeedMax && + (cycle - cycle_rate * carryover) < gimpLeftSpeedMax + ) + { + plain_next = 0x1a; + break; + } + SetLegAnimation(0x18); + return legAnimation.Advance( + carryover * cycle * time_scale / gimpLeftStrideLength, 1); + + case 0x17: case 0x19: + if ( + demand < gimpRightSpeedMax && + (cycle - cycle_rate * carryover) < gimpRightSpeedMax + ) + { + plain_next = 0x1b; + break; + } + SetLegAnimation(0x19); + return legAnimation.Advance( + carryover * cycle * time_scale / gimpRightStrideLength, 1); + + default: + return 0.0f; + } + + // + // The plain tail every non-cycle exit lands in. + // + SetLegAnimation(plain_next); + return legAnimation.Advance(carryover * time_scale, 1); +} + +Scalar + Mech::GimpBodyClipFinished(Scalar carryover, int move_joints) +{ + Check(this); + + int + mode = MovementMode(), + state = (int)bodyStateAlarm.GetLevel(); + + if ( + state == 6 || state == 7 || state == 0xc || state == 0xd || + state == 0x12 || state == 0x13 + ) + { + Scalar + cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax; + if (bodyTargetSpeed > cap) + { + bodyTargetSpeed = cap; + } + if (bodyTargetSpeed < 0.0f) + { + bodyTargetSpeed = 0.0f; + } + } + + Scalar + demand = bodyTargetSpeed, + cycle_rate = forwardCycleRate, + time_scale = globalTimeScale, + cycle = bodyCycleSpeed; + int + plain_next; + + switch (state) + { + case 2: + bodyStateAlarm.SetLevel(1); + return 0.0f; + + case 3: case 8: case 9: case 0x14: case 0x15: case 0x1a: case 0x1b: + case 4: + case 0x20: + bodyStateAlarm.SetLevel(0); + return 0.0f; + + case 5: case 6: case 0xe: + if ( + demand < standSpeed && + (cycle - cycle_rate * carryover) < standSpeed + ) + { + plain_next = 9; + break; + } + if ( + demand > walkStrideLength && + (cycle + cycle_rate * carryover) > walkStrideLength + ) + { + plain_next = 0xb; + break; + } + SetBodyAnimation((mode == 4) ? 7 : 0x16); + return bodyAnimation.Advance( + carryover * cycle * time_scale / walkStrideLength, move_joints); + + case 7: case 0xf: + if ( + demand < standSpeed && + (cycle - cycle_rate * carryover) < standSpeed + ) + { + plain_next = 8; + break; + } + if ( + demand > walkStrideLength && + (cycle + cycle_rate * carryover) > walkStrideLength + ) + { + plain_next = 10; + break; + } + SetBodyAnimation((mode == 3) ? 6 : 0x17); + return bodyAnimation.Advance( + carryover * cycle * time_scale / walkStrideLength, move_joints); + + case 10: case 0xc: + if ( + demand < reverseSpeedMax && + (cycle - cycle_rate * carryover) < reverseSpeedMax + ) + { + plain_next = 0xf; + break; + } + SetBodyAnimation(0xd); + return bodyAnimation.Advance( + carryover * cycle * time_scale / reverseStrideLength, move_joints); + + case 0xb: case 0xd: + if ( + demand < reverseSpeedMax && + (cycle - cycle_rate * carryover) < reverseSpeedMax + ) + { + plain_next = 0xe; + break; + } + SetBodyAnimation(0xc); + return bodyAnimation.Advance( + carryover * cycle * time_scale / reverseStrideLength, move_joints); + + case 0x10: case 0x12: + if ( + demand > gimpSpeedMax && + (gimpCycleRate * carryover + cycle) > gimpSpeedMax + ) + { + plain_next = 0x15; + break; + } + SetBodyAnimation(0x13); + { + Scalar + cycle_time = carryover * cycle * time_scale / gimpStrideLength; + if (cycle_time <= 0.0f) + { + cycle_time = -cycle_time; + } + return bodyAnimation.Advance(cycle_time, move_joints); + } + + case 0x11: case 0x13: + if ( + demand > gimpSpeedMax && + (gimpCycleRate * carryover + cycle) > gimpSpeedMax + ) + { + plain_next = 0x14; + break; + } + SetBodyAnimation(0x12); + { + Scalar + cycle_time = carryover * cycle * time_scale / gimpStrideLength; + if (cycle_time <= 0.0f) + { + cycle_time = -cycle_time; + } + return bodyAnimation.Advance(cycle_time, move_joints); + } + + case 0x16: case 0x18: + if ( + demand < gimpLeftSpeedMax && + (cycle - cycle_rate * carryover) < gimpLeftSpeedMax + ) + { + plain_next = 0x1a; + break; + } + SetBodyAnimation(0x18); + return bodyAnimation.Advance( + carryover * cycle * time_scale / gimpLeftStrideLength, move_joints); + + case 0x17: case 0x19: + if ( + demand < gimpRightSpeedMax && + (cycle - cycle_rate * carryover) < gimpRightSpeedMax + ) + { + plain_next = 0x1b; + break; + } + SetBodyAnimation(0x19); + return bodyAnimation.Advance( + carryover * cycle * time_scale / gimpRightStrideLength, move_joints); + + default: + return 0.0f; + } + + SetBodyAnimation(plain_next); + return bodyAnimation.Advance(carryover * time_scale, move_joints); +} + +// +//############################################################################# +// THE LIMP ADVANCERS (@004a71f4 leg / @004a5bf8 body). Selected instead of +// the normal pair while limping, and structurally different in exactly the +// ways a limp needs: +// +// * States 0x16-0x1b are LIVE here -- the limp entries advance in the +// plain group and the 0x18/0x19 figures get their own slewed cycles with +// the damaged side's caps. (The NORMAL advancers treat those states as +// the reset group, which is why these flavours must be selected while +// limping -- the normal one would neutralize the figure mid-cycle.) +// +// * No death latch and no wind-down: the movement modes are exclusive, so +// a limping mech is by definition not falling. +// +// * The same demand clamp as the transition machines, applied per frame. +//############################################################################# +// +Scalar + Mech::AdvanceLegAnimationGimp(Scalar time_slice) +{ + Check(this); + + MechControlsMapper + *mapper = (MechControlsMapper *)subsystemArray[0]; + Check_Pointer(mapper); + + int + mode = MovementMode(), + state = (int)legStateAlarm.GetLevel(); + + if ( + state == 6 || state == 7 || state == 0xc || state == 0xd || + state == 0x12 || state == 0x13 + ) + { + Scalar + cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax, + clamped = mapper->GetSpeedDemand(); + if (clamped > cap) + { + clamped = cap; + } + if (clamped < 0.0f) + { + clamped = 0.0f; + } + mapper->SetSpeedDemand(clamped); + } + + Scalar + demand = mapper->GetSpeedDemand(), + distance = 0.0f; + + switch (legStateAlarm.GetLevel()) + { + case 0: + if (demand <= standSpeed) + { + break; + } + SetLegAnimation(5); + // fall through + + case 2: case 3: case 5: case 8: case 9: case 10: case 0xb: + case 0xe: case 0xf: case 0x10: case 0x11: case 0x14: case 0x15: + case 0x16: case 0x17: case 0x1a: case 0x1b: case 0x20: + advance_clip: + distance = legAnimation.Advance( + time_slice * globalTimeScale * idleStrideScale, 1); + legCycleSpeed = distance / time_slice; + break; + + case 1: + break; + + case 4: + if (standSpeed < demand) + { + legStateAlarm.SetLevel(0); + ForceUpdate(8); + break; + } + goto advance_clip; + + case 6: case 7: + if (demand > legCycleSpeed) + { + legCycleSpeed += forwardCycleRate * time_slice; + if (legCycleSpeed > demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed > walkStrideLength) + { + legCycleSpeed = walkStrideLength; + } + } + else if (demand < legCycleSpeed) + { + legCycleSpeed -= forwardCycleRate * time_slice; + if (legCycleSpeed < demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed < standSpeed) + { + legCycleSpeed = standSpeed; + } + } + distance = legAnimation.Advance( + time_slice * (legCycleSpeed / walkStrideLength) * globalTimeScale, + 1); + break; + + case 0xc: case 0xd: + if (demand > legCycleSpeed) + { + legCycleSpeed += forwardCycleRate * time_slice; + if (legCycleSpeed > demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed > runSpeedMax) + { + legCycleSpeed = runSpeedMax; + } + } + else if (demand < legCycleSpeed) + { + legCycleSpeed -= forwardCycleRate * time_slice; + if (legCycleSpeed < demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed < reverseSpeedMax) + { + legCycleSpeed = reverseSpeedMax; + } + } + distance = legAnimation.Advance( + time_slice * (legCycleSpeed / reverseStrideLength) * globalTimeScale, + 1); + break; + + case 0x12: case 0x13: + if (demand > legCycleSpeed) + { + legCycleSpeed += gimpCycleRate * time_slice; + if (legCycleSpeed > demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed > gimpSpeedMax) + { + legCycleSpeed = gimpSpeedMax; + } + } + else if (demand < legCycleSpeed) + { + legCycleSpeed -= gimpCycleRate * time_slice; + if (legCycleSpeed < demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed < gimpStrideLength) + { + legCycleSpeed = gimpStrideLength; + } + } + { + Scalar + ratio = legCycleSpeed / gimpStrideLength; + if (ratio <= 0.0f) + { + ratio = -ratio; + } + distance = legAnimation.Advance( + ratio * time_slice * globalTimeScale, 1); + } + break; + + // + // The limp figures: slewed like a walk, inside the damaged side's caps. + // + case 0x18: case 0x19: + { + Scalar + speed_cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax, + stride = (mode == 3) ? gimpLeftStrideLength : gimpRightStrideLength; + + if (demand > legCycleSpeed) + { + legCycleSpeed += forwardCycleRate * time_slice; + if (legCycleSpeed > demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed > stride) + { + legCycleSpeed = stride; + } + } + else if (demand < legCycleSpeed) + { + legCycleSpeed -= forwardCycleRate * time_slice; + if (legCycleSpeed < demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed < speed_cap) + { + legCycleSpeed = speed_cap; + } + } + distance = legAnimation.Advance( + time_slice * (legCycleSpeed / stride) * globalTimeScale, 1); + } + break; + + default: + Fail("Unsupported mech animation"); + } + + return distance; +} + +Scalar + Mech::AdvanceBodyAnimationGimp(Scalar time_slice, int move_joints) +{ + Check(this); + + int + mode = MovementMode(), + state = (int)bodyStateAlarm.GetLevel(); + + if ( + state == 6 || state == 7 || state == 0xc || state == 0xd || + state == 0x12 || state == 0x13 + ) + { + Scalar + cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax; + if (bodyTargetSpeed > cap) + { + bodyTargetSpeed = cap; + } + if (bodyTargetSpeed < 0.0f) + { + bodyTargetSpeed = 0.0f; + } + } + + Scalar + demand = bodyTargetSpeed, + distance = 0.0f; + + switch (bodyStateAlarm.GetLevel()) + { + case 0: + if (demand <= standSpeed) + { + break; + } + SetBodyAnimation(5); + // fall through + + case 2: case 3: case 4: case 5: case 8: case 9: case 10: case 0xb: + case 0xe: case 0xf: case 0x10: case 0x11: case 0x14: case 0x15: + case 0x16: case 0x17: case 0x1a: case 0x1b: case 0x20: + distance = bodyAnimation.Advance( + time_slice * globalTimeScale * idleStrideScale, move_joints); + bodyCycleSpeed = distance / time_slice; + break; + + case 1: + break; + + case 6: case 7: + if (demand > bodyCycleSpeed) + { + bodyCycleSpeed += forwardCycleRate * time_slice; + if (bodyCycleSpeed > demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed > walkStrideLength) + { + bodyCycleSpeed = walkStrideLength; + } + } + else if (demand < bodyCycleSpeed) + { + bodyCycleSpeed -= forwardCycleRate * time_slice; + if (bodyCycleSpeed < demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed < standSpeed) + { + bodyCycleSpeed = standSpeed; + } + } + distance = bodyAnimation.Advance( + time_slice * (bodyCycleSpeed / walkStrideLength) * globalTimeScale, + move_joints); + break; + + case 0xc: case 0xd: + if (demand > bodyCycleSpeed) + { + bodyCycleSpeed += forwardCycleRate * time_slice; + if (bodyCycleSpeed > demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed > runSpeedMax) + { + bodyCycleSpeed = runSpeedMax; + } + } + else if (demand < bodyCycleSpeed) + { + bodyCycleSpeed -= forwardCycleRate * time_slice; + if (bodyCycleSpeed < demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed < reverseSpeedMax) + { + bodyCycleSpeed = reverseSpeedMax; + } + } + distance = bodyAnimation.Advance( + time_slice * (bodyCycleSpeed / reverseStrideLength) * globalTimeScale, + move_joints); + break; + + case 0x12: case 0x13: + if (demand > bodyCycleSpeed) + { + bodyCycleSpeed += gimpCycleRate * time_slice; + if (bodyCycleSpeed > demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed > gimpSpeedMax) + { + bodyCycleSpeed = gimpSpeedMax; + } + } + else if (demand < bodyCycleSpeed) + { + bodyCycleSpeed -= gimpCycleRate * time_slice; + if (bodyCycleSpeed < demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed < gimpStrideLength) + { + bodyCycleSpeed = gimpStrideLength; + } + } + { + Scalar + ratio = bodyCycleSpeed / gimpStrideLength; + if (ratio <= 0.0f) + { + ratio = -ratio; + } + distance = bodyAnimation.Advance( + ratio * time_slice * globalTimeScale, move_joints); + } + break; + + case 0x18: case 0x19: + { + Scalar + speed_cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax, + stride = (mode == 3) ? gimpLeftStrideLength : gimpRightStrideLength; + + if (demand > bodyCycleSpeed) + { + bodyCycleSpeed += forwardCycleRate * time_slice; + if (bodyCycleSpeed > demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed > stride) + { + bodyCycleSpeed = stride; + } + } + else if (demand < bodyCycleSpeed) + { + bodyCycleSpeed -= forwardCycleRate * time_slice; + if (bodyCycleSpeed < demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed < speed_cap) + { + bodyCycleSpeed = speed_cap; + } + } + distance = bodyAnimation.Advance( + time_slice * (bodyCycleSpeed / stride) * globalTimeScale, + move_joints); + } + break; + + default: + Fail("Unsupported mech animation"); + } + + return distance; +} + // //############################################################################# // @004a7f50 -- prefix + suffix -> the clip's resource ID. diff --git a/restoration/source410/BT/MECH2.NOTES.md b/restoration/source410/BT/MECH2.NOTES.md index 72946136..081446f5 100644 --- a/restoration/source410/BT/MECH2.NOTES.md +++ b/restoration/source410/BT/MECH2.NOTES.md @@ -1,10 +1,10 @@ # MECH2.CPP — reconstruction notes -**Status: THE GAIT IS LIVE (2026-08-03). Transition machine + clip loader + -the two GROUND `Advance*` entry points reconstructed and wired into -`Mech::Simulate`; first live run showed 16 of 22 joints animating in paired -left/right strides. Still deferred: the two AIRBORNE `Advance*` flavours and -the `Gimp*ClipFinished` limp machines — see "What is deferred".** +**Status: ALL TWELVE FUNCTIONS RECONSTRUCTED (2026-08-03) — the gait AND the +limp are live-verified. The walk shows 16 of 22 joints in paired strides; the +forced limp breaks the pair symmetry exactly as it should (drag-leg joint at +18 poses vs its partner's 55) with the hull lurching at 10–23 under a 26.9 +demand.** `mech2.cpp` is the mech's gait: which walk clip is playing, when it changes, and to what. It sits between the locomotion demand and the clip player: @@ -321,3 +321,51 @@ dispatcher. One correction queued by the raw decomp: `+0x598` is written by a VECTOR op in IntegrateMotion's replicant branch (FUN_00408644 on floats), so `motionEventName` is likely a Point3D, not a CString — revisit when that branch is reconstructed. + +## The limp, settled and live (2026-08-03) + +The "gimp" family had two conflicting donor readings — limp vs jump-jet. The +binary settles it: **limp**. The mode test in every gimp function is +`mech+0x40` ∈ {3, 4}, the same values the damage model documents as "limp +gait graphic (left 3 / right 4)"; there is no jump-jet control anywhere in +the cockpit; and the donor's own port routing (graphic-alarm 3/4) was reading +the same field under a different name. The donor's "run-jump clip" +annotations in its mech3 member map were its misreading, which is why its +mech2 header said "airborne/jump-jet" — recorded so nobody re-litigates it. + +**How the limp actually works** (from @004a7970/@004a6344 + the advancers +@004a71f4/@004a5bf8, all four now reconstructed): + +* The limp replaces ONE stride. Limping left, the right stride (6) hands off + to the left limp figure (0x16 → the self-cycling 0x18); the other leg's + clips keep their normal alternation. That asymmetry is what reads as a + limp rather than a different gait. +* Both machines CLAMP THEIR DEMAND while in a cycle — the leg machine writes + the mapper's own speedDemand cell down to the damaged side's cap (hence + `MechControlsMapper::SetSpeedDemand`), the body machine clamps + bodyTargetSpeed, and both floor at zero. A limping mech cannot command + more speed than its figure carries, nor reverse out of a forward cycle. +* The limp flavours of Advance* keep states 0x16–0x1b LIVE (the normal + flavours treat them as the reset group — which is precisely why the limp + flavours must be selected while limping, or the figure gets neutralized + mid-cycle). They carry no death latch and no wind-down: movement modes are + exclusive, a limping mech is not falling. +* All movement-mode reads go through `Mech::MovementMode()` (mech+0x40 == + the simulation state), which also honours the `BT_FORCE_LIMP=3|4` dev hook + (set once in the ctor) so the machinery can be exercised before the damage + model's limp hook exists. + +**Verification, two runs:** + +* No-regression: the normal mission's speed sequence is BIT-IDENTICAL to + 5.3.95 (7.31972, 26.6726, 22.1601, …) — the branch costs nothing when not + limping. +* Forced left limp (`pod_render_limp.conf`): demand stays 26.9, hull lurches + at 10–23; and on the wire the healthy run's tight pose-count pairs + (650/649 … 434/421) break to a 3× asymmetry — the drag-leg joint at 18 + poses against its partner's 55. No fault, no "Unsupported mech animation". + +**Still open in this family:** the damage hook itself (leg zone ≥ 0.5 → +mode 3/4 — a MECHDMG increment; nothing sets the mode in real play yet), and +IntegrateMotion's remaining pieces (orientation/velocity integration, the +dead-reckon fold, turn-in-place arming). diff --git a/restoration/source410/BT/MECHMPPR.HPP b/restoration/source410/BT/MECHMPPR.HPP index a3f16891..d9e26245 100644 --- a/restoration/source410/BT/MECHMPPR.HPP +++ b/restoration/source410/BT/MECHMPPR.HPP @@ -149,6 +149,16 @@ Scalar GetTurnDemand() const { Check(this); return turnDemand; } + // + // The LIMP machines write the demand cell DOWN to the damaged side's + // speed cap (binary GimpLegClipFinished @004a7970 preamble writes + // mapper+0x128 directly) -- a limping mech cannot command more speed + // than its limp figure carries. + // + void + SetSpeedDemand(Scalar demand) + { Check(this); speedDemand = demand; } + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Local Data -- the published control inputs (the streamed mappings write // these; InterpretControls, phase 5.3, reads them to drive the mech).