Torso: the TWIST goes LIVE -- electrical watchdog chain, centered crosshair, coherent controls (task #57/#58)

The MadCat torso twists, the view turns with it, and targeting follows.
Three reconstruction fronts closed:

THE ELECTRICAL WATCHDOG CHAIN (why the torso never powered up):
- PowerWatcher::UpdateWatch reconstructed (@004b181c, the REAL registered
  Performance -- PTR @0050f5fc; Ghidra missed the fn start): the watchdog
  MIRRORS the watched subsystem's electrical level (+0x278), brownout
  downgrade when gen output <= minVoltage% x rated.  @004b1804 relabeled
  ResetToInitialState (slot 10) -- the old "Simulation" tag was wrong.
- The factory watcher-CONNECT pass reconstructed (vtable slot +0x38,
  @004aee2c/@004b1a40 byte-identical, recovered from raw exe bytes):
  watchedLink.Add(roster[watchedSubsystem]) on the master node.  Was the
  SubProxy::Start() no-op -- every watchdog sat at 0 forever.
- MinVoltageScale = 0.01 (a 10-byte x87 literal @0x4b1924; was 1.0f =
  permanent brownout) and PowerWatcher's Derivation chains its REAL base
  HeatWatcher (the HeatableSubsystem stand-in broke IsDerivedFrom for the
  whole Torso/Searchlight/ThermalSight family).
- KB correction swept: derivation tag 0x50e604 = HEATWATCHER (not
  "HeatSink"); the btl4gaug heat-widget gate now tests it via the
  BTIsHeatWatcher bridge.

THE CROSSHAIR (task #58 forensics, 6-agent workflow + live probes):
- The VIEW is TORSO-MOUNTED: jointtorso -> jointeye -> siteeyepoint in
  every twist-capable .SKL; the camera + canopy ride the same hinge
  subtree through HingeRenderable's live matrix-stack compose -- ALREADY
  WORKING in the port.  The crosshair stays screen-centered (center IS
  the boresight); the twist reads on the tape carets/compass/radar.
- The real bug was the port's gBTAimX = tan(twist) slew (the falsified
  "body-mounted view" model): the camera already carried the twist, so
  the crosshair counter-slid to hull-forward and the fire ray with it.
  Deleted; the pick ray inherits the twist from the yawing eye basis.
- Two instrumentation traps documented (chase-eye-as-default-camera,
  BT_FORCE_TORSO clobbering real joints -> the hook now only fills
  unresolved ones); an over-correcting explicit eye compose was added on
  those false readings and retired the same day.

CONTROLS + REPLICATION:
- Q/E spring-center on release (the axis is a twist-RATE demand; the old
  hold-deflection model drifted forever); X also zeroes the axis and
  pulses the authentic torso Recenter (@004b6918).  M cycles control
  mode via the real CycleControlMode body.
- Torso update-record DIRECTION fixed: engine truth is Write=serialize /
  Read=apply; @004b6a78 is the READ (was mislabeled Write) and the
  missing WRITE @004b6a1c recovered from raw disasm (recordLength 0x1C,
  twist/vel/rate at +0x10/14/18) -- kills the replicant's 0xCDCDCDCD
  -140-degree ghost twist.
- Marching-ghost desync: 4 Standing-case guards zero stale reverse
  cycleSpeed (negative cadence passed the <= ZeroSpeed stop gate).
- Kill credit rerouted to the OBSERVED killer (lastInflictingID ->
  killer's player link) -- kills count, target K/D populates.

KB: subsystems.md (watcher chain), multiplayer.md (record direction),
combat-damage.md + gauges-hud.md + cockpit-view.md (torso-mounted view
re-correction), decomp-reference.md (new addresses + tag fix),
open-questions.md (dead capability-roster loops 2-4, snapshot CD read).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-13 13:27:49 -05:00
co-authored by Claude Fable 5
parent 065c114590
commit 02cdfd6576
29 changed files with 1112 additions and 124 deletions
+14 -5
View File
@@ -52,7 +52,8 @@
// FUN_0044a5b4/5dc GraphicsViewRecord ctor/dtor
// FUN_0044a650/630 GraphicsViewRecord Erase / Reset
// DAT_00524e20 DebugStream (warning channel)
// 0x50e3ec / 0x50e604 heat-subsystem class-derivation tables
// 0x50e3ec / 0x50e604 heat class-derivation tags (HeatableSubsystem /
// HEATWATCHER -- task #57 relabel; was "HeatSink")
//
// GraphicsView vtable slots used below (this+0x48):
// +0x08 SetExtent +0x10 SetOrigin +0x18 SetColor +0x24 MoveTo
@@ -363,9 +364,12 @@ void
//
// HeatConnection -- @004c3664 ctor / @004c3720 Transfer.
// ctor: source = the heat subsystem; flag@0x14 records whether it is the
// "primary" heat class (IsDerivedFrom 0x50e3ec) vs. the alternate (0x50e604);
// destination@0x18. Transfer: no subsystem -> 100 ; subsystem operational
// (+0x40 == 1) -> 100 ; else Round( Resolve(subsys+0x114 currentTemperature) ).
// "primary" heat class (IsDerivedFrom 0x50e3ec HeatableSubsystem) vs. the
// alternate (0x50e604 = HEATWATCHER, task #57 relabel); destination@0x18.
// Transfer: no subsystem -> 100 ; subsystem operational (+0x40 == 1) -> 100 ;
// else Round( Resolve(subsys+0x114) ) -- NB for a Heatable that offset is
// currentTemperature, for a WATCHER it is watchedLink (the flag@0x14 likely
// picks the read; the watcher path is unverified [T4]).
// Used by ColorMapperHeat -- THIS is the heat gauge's data feed.
//
// All Transfer bodies are reproduced inside the owning gauge's notes; the
@@ -925,8 +929,13 @@ ColorMapperHeat::ColorMapperHeat(
// The subsystem MUST be heat-bearing (one of the two heat class
// derivation tables) or the data feed is meaningless.
//
extern int BTIsHeatWatcher(Subsystem *sub); // heatfamily_reslice.cpp bridge
if (!subsystem->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()) && // FUN_0041a1a4 (0x50e3ec)
!subsystem->IsDerivedFrom(*HeatSink::GetClassDerivations())) // (0x50e604)
!BTIsHeatWatcher(subsystem)) // (0x50e604 = HEATWATCHER -- task #57 relabel:
// the old "HeatSink" reading was redundant after
// the HeatableSubsystem test; the binary's
// alternate branch is the disjoint WATCHER family
// (ammo bins / torso carry a mirrored heatAlarm))
{
Verify(False); // FUN_0040385c -- "Bad subsystem type" BTL4GAUG.CPP:0x68a
}
+2 -1
View File
@@ -203,7 +203,8 @@
// ColorMapperHeat -- the cockpit heat tint. ANCHOR @004c3f6c.
// Connection: @004c3664 (reads HeatableSubsystem::currentTemperature@0x114
// as a 0..100 percentage). The named subsystem must derive from a heat
// class (IsDerivedFrom 0x50e3ec or 0x50e604) or the ctor asserts (line 0x68a).
// class (IsDerivedFrom 0x50e3ec HeatableSubsystem or 0x50e604 HEATWATCHER
// -- task #57 relabel) or the ctor asserts (line 0x68a).
//
class ColorMapperHeat :
public ColorMapper
+129
View File
@@ -49,6 +49,9 @@
#if !defined(MECHWEAP_HPP)
# include <mechweap.hpp> // MechWeapon::GetClassDerivations (reticle pip)
#endif
#if !defined(MECHDMG_HPP)
# include <mechdmg.hpp> // Mech__DamageZone::segmentIndex (the @004d097c dispatcher)
#endif
#if !defined(NOTATION_HPP)
# include <notation.hpp>
#endif
@@ -368,6 +371,22 @@ HierarchicalDrawComponent*
LinearMatrix offset_matrix;
offset_matrix = segment->GetBaseOffset(); // [0x74]
// task #58 diagnostics (BT_SEG_DUMP): the draw-tree topology -- which
// segment carries which joint, and who parents whom.
if (getenv("BT_SEG_DUMP"))
{
int ji = segment->GetJointIndex();
DEBUG_STREAM << "[seg] " << segment->GetIndex()
<< " '" << (const char *)segment->GetName()
<< "' parent=" << (segment->GetParent()
? segment->GetParentIndex() : -1)
<< " joint=" << ji
<< " jtype=" << (ji == -1 ? -1
: (int)joint_subsystem->GetJoint(ji)->GetJointType())
<< " site=" << (int)(segment->IsSiteSegment() != 0)
<< "\n" << std::flush;
}
//
// Parent renderable: root if the segment has no parent, else the
// renderable already built for its parent segment.
@@ -866,6 +885,116 @@ void BTRebuildMechModel(Entity *entity)
//
//#############################################################################
// StartEntityEffectImplementation @004d097c (coverage-audit reconstruction)
//#############################################################################
//
// The per-zone EFFECT DISPATCHER -- the target of the whole authentic chain:
// MechDeathHandler's class-5 -> RendererManager::StartEntityEffect ->
// Renderer::StartEntityEffectMessageHandler [T0: resolves the GameModel +
// applies the ExplosionResourceTable graphic-state remap] -> this virtual.
// The binary body (@004d097c, part_014.c:5839):
// 1. resolve the zone -> its transform (+0x74, row 3 = the world position)
// and its VIDEO INDEX (+0xc4->+0xc0) = the segment slot;
// 2. look the entity up in the renderer's per-entity tree (renderer+0x3a4,
// == our mMechRenderTrees) -> the segment's DCS = the effect SOCKET;
// 3. SearchList(resource, VideoModelResourceType) -> walk the video-object
// records; atoi(name) < 1000 WARNS (the authentic gate: only INDIE/psfx
// ids attach per-zone), >= 1000 starts the effect ON the socket, tagged
// with the owning player (+0x190 -> +0x1e0) for the stop-all sweep.
// Port mapping: the socket attachment = BTStartPfxAttached (the emitter rides
// the segment via BTResolveSegmentWorld each frame); the tag = the entity
// (BTStopEntityPfx kills by entity on respawn). Layout access is by NAMED
// members only (zone->segmentIndex; the binary micro-offsets differ on our
// compiled classes -- the databinding trap).
//
void
BTL4VideoRenderer::StartEntityEffectImplementation(
Entity *entity,
DamageZone *damage_zone,
ResourceDescription::ResourceID resource_ID
)
{
if (entity == NULL || damage_zone == NULL)
{
DEBUG_STREAM << "StartEntityEffectImplementation: no entity/zone" << std::endl;
return;
}
// 1. the zone's segment slot (binary +0xc4->+0xc0; our named member)
int seg_index = ((Mech__DamageZone *)damage_zone)->EffectSegmentIndex();
// 2. the segment's world position + frame (binary: zone transform +0x74)
extern int BTResolveSegmentWorld(void *entity, int seg_index, float *pos3, float *rows9);
float pos[3], rows[9];
if (!BTResolveSegmentWorld(entity, seg_index, pos, rows))
{
DEBUG_STREAM << "StartEntityEffectImplementation: entity has no segment table" << std::endl;
return;
}
// 3. the effect resource's VIDEO MODEL (type 10) -- the effect-number list
Check(application);
ResourceDescription *res = application->GetResourceFile()->SearchList(
resource_ID, ResourceDescription::VideoModelResourceType);
if (res == NULL)
{
DEBUG_STREAM << "StartEntityEffectImplementation: " << (long)resource_ID
<< " has no video resource" << std::endl;
return;
}
res->Lock();
// VideoModel payload [T0 L4VIDEO.h L4VideoObject, RES byte-verified]:
// int32 count + count x 32-byte records {char name[15]; pad; int type;
// int modes; float blinkPeriod; float pctOn} -- atoi(name) = the effect id.
const unsigned char *pay = (const unsigned char *)res->resourceAddress;
int count = *(const int *)pay;
if (count < 0 || count > 16)
count = 0; // malformed -- refuse
const unsigned char *rec = pay + 4;
for (int i = 0; i < count; ++i, rec += 32)
{
char name[16];
memcpy(name, rec, 15);
name[15] = 0;
int fx = atoi(name);
if (fx < 1000)
{
// authentic gate (@004d097c: "< 1000" warns) -- per-zone effects
// are INDIE/psfx-band only
DEBUG_STREAM << "StartEntityEffectImplementation: non-INDIE effect "
<< fx << " in zone effect list" << std::endl;
continue;
}
extern void BTStartPfxAttached(int, void *, int, float, float, float, const float *);
BTStartPfxAttached(fx - 1000, (void *)entity, seg_index,
pos[0], pos[1], pos[2], rows);
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[zonefx] entity " << entity->GetEntityID()
<< " seg " << seg_index << " psfx " << (fx - 1000)
<< " at(" << pos[0] << "," << pos[1] << "," << pos[2] << ")" << std::endl;
}
res->Unlock();
}
//
// StopAllEntityEffectsImplementation @004d0c14 -- the respawn cleanup: the
// binary kills every effect tagged (playerIdx<<16 .. |0xffff); the port kills
// every emitter tagged to the entity.
//
void
BTL4VideoRenderer::StopAllEntityEffectsImplementation(Entity *entity)
{
if (entity == NULL)
{
DEBUG_STREAM << "StopAllEntityEffectsImplementation: no entity" << std::endl;
return;
}
extern void BTStopEntityPfx(void *owner);
BTStopEntityPfx((void *)entity);
}
//#############################################################################
// SwapToWreck (ExplosionScripts effect 104, reconstructed)
//#############################################################################
+20
View File
@@ -623,6 +623,26 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
Logical
TestInstance() const;
//
// The per-zone EFFECT DISPATCHER (@004d097c, coverage-audit discovery
// 2026-07-13) -- the missing target of the engine's whole authentic
// StartEntityEffect chain (MechDeathHandler class-5 -> RendererManager
// -> Renderer::StartEntityEffectMessageHandler [T0, applies the
// ExplosionResourceTable graphic-state remap] -> THIS virtual).
// Resolves the zone's SEGMENT socket, walks the effect resource's
// VideoModel (type 10), and starts each >=1000 effect ATTACHED to the
// segment (the smoke follows the walking mech); <1000 warns
// (byte-faithful to the binary's gate). StopAll kills every effect
// tagged to the entity (the respawn cleanup, @004d0c14 analog).
//
virtual void
StartEntityEffectImplementation(
Entity *entity,
DamageZone *damage_zone,
ResourceDescription::ResourceID resource_ID); // @004d097c
virtual void
StopAllEntityEffectsImplementation(Entity *entity); // @004d0c14
//
// Material substitution (mirrors RPL4VideoRenderer).
//
+29 -11
View File
@@ -1494,12 +1494,31 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat
{
return;
}
BTPlayer *local_player = (BTPlayer *)application->GetMissionPlayer();
if (local_player != 0)
// OBSERVED-KILL credit (MP KILLS fix, 2026-07-13; the symmetric twin of
// BTPlayerCountObservedDeath): every node maintains LOCAL score copies,
// so the kill must land on the KILLER's player-link object -- the same
// object the PilotList gauge reads (the roster resolves pilots via the
// mechs' GetPlayerLink()). The old GetMissionPlayer() dispatch credited
// a copy the scoreboard never displays (kills stuck at 0 while deaths --
// which already flow through the player link -- counted; user-reported).
// Resolving the killer from the victim's lastInflictingID also credits
// the REMOTE player's local copy when THEY score ("target's kills
// display 0") -- each node witnesses the death transition and tallies
// its own copies self-consistently.
BTPlayer *killer_player = 0;
if (application->GetHostManager() != 0)
{
// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= our mech) so
// the local player is credited via `this->killCount++` -- if it were our own
// mech, ScoreMessageHandler takes the suicide branch (award negated, no kill).
Entity *killer = application->GetHostManager()->GetEntityPointer(
((Mech *)victim)->lastInflictingID);
extern int BTIsRegisteredMech(Entity *e);
if (killer != 0 && killer != victim && BTIsRegisteredMech(killer))
killer_player = (BTPlayer *)((Mech *)killer)->GetPlayerLink();
}
if (killer_player != 0)
{
// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= the
// receiver's mech) so the handler credits `killCount++` -- a suicide
// (killer == victim) never reaches here.
BTPlayer::ScoreMessage kill(
Player::ScoreMessageID, // 0x12
sizeof(BTPlayer::ScoreMessage),
@@ -1507,13 +1526,12 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat
0.0f, // scoreAward (killBonus; 0 for bring-up)
damage, // damageAmount (killing-blow)
victim->GetEntityID()); // senderMechID = victim
local_player->Dispatch(&kill);
killer_player->Dispatch(&kill);
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] *** KILL *** localPlayer=" << (void *)local_player
<< " killCount=" << local_player->GetKillCount()
<< " deaths=" << local_player->GetDeaths()
<< " score=" << (Scalar)local_player->GetScore()
<< " rank=" << local_player->GetRanking() << "\n" << std::flush;
DEBUG_STREAM << "[score] *** KILL *** killerPlayer=" << (void *)killer_player
<< " killCount=" << killer_player->GetKillCount()
<< " deaths=" << killer_player->GetDeaths()
<< " score=" << (Scalar)killer_player->GetScore() << std::endl;
}
// MP DEATH: credit a death to the VICTIM's own player. NULL for the solo
+53 -10
View File
@@ -337,16 +337,14 @@ void
{
HeatableSubsystem *watched = (HeatableSubsystem *)watchedLink.Resolve(); // FUN_00417ab4(this+0x114)
// BRING-UP GUARD (marked deviation): in the shipped game watchedLink ALWAYS
// resolves -- the HeatWatcher resource's "WatchedSubsystem" name binds to a
// roster segment at load -- so the binary (FUN_004aeac4) derefs `watched`
// unconditionally. Our port does not yet resolve the watch link, so Resolve()
// returns null; the deref AVs the moment a HeatWatcher-derived subsystem's
// Performance actually runs (exposed by the WAVE 4 un-stub: AmmoBinSimulation
// calls this directly; PowerWatcher::Simulation for Searchlight/ThermalSight).
// With no resolved source, hold the heat alarm at Normal (same established
// pattern as Emitter::TrackSeekVoltage's null-source guard). FAITHFUL
// FOLLOW-UP = resolve watchedLink so the alarm tracks the real temperature.
// The watch link is now BOUND by the authentic factory connect pass (task
// #57: the mech factory's post-roster loop calls the binary's vtable slot
// +0x38 -- FUN_004aee2c / FUN_004b1a40 -> BTWatcherBindTarget below), so
// Resolve() succeeds on every master-node watcher whose resource named a
// WatchedSubsystem. The binary derefs `watched` unconditionally; the null
// guard stays for replicant nodes (the binary never binds there either --
// the +0x38 body is master-gated -- and their watcher Performances don't
// run) and for bring-up safety.
if (watched == 0)
{
heatAlarm.SetLevel(0); // NormalHeat (no watched source yet)
@@ -369,6 +367,51 @@ void
}
}
//
// @004aee2c (HeatWatcher vtable slot 14, offset +0x38; the PowerWatcher/Torso
// override @004b1a40 is byte-identical) -- the factory post-roster CONNECT
// pass, disassembled from the raw binary (Ghidra missed both function starts):
//
// owner = this->owner (+0xD0)
// if ((owner->simulationFlags & 0xC) == 0 && (owner->simulationFlags & 0x100))
// watchedLink(+0x114).Add( owner->roster(+0x128)[ watchedSubsystem(+0x128) ] )
//
// The mech factory loop tests IsDerivedFrom(HeatWatcher @0x50e604) then calls
// the slot. Split across the TU boundary the same way as the other
// cross-family helpers: the OWNER-side master gate + roster lookup live in the
// mech family (complete Mech type); these two bridges are the family-side
// derivation test and the link Add. [T1: bytes @004aee2c/@004b1a40]
//
int
BTWatcherWatchedIndex(Subsystem *sub) // -1 = not a HeatWatcher
{
if (sub == 0 || !sub->IsDerivedFrom(*HeatWatcher::GetClassDerivations()))
{
return -1;
}
return ((HeatWatcher *)sub)->watchedSubsystem; // @0x128 (resource +0xE4, name index +2)
}
void
BTWatcherBindTarget(Subsystem *sub, Subsystem *target)
{
((HeatWatcher *)sub)->watchedLink.Add(target); // (**(link+4))(link, target) = FUN_00417a80
}
//
// Cross-family derivation probe: 0x50e604 is the HEATWATCHER derivation tag
// (TestInstance @4aec38 checks its own class against it) -- NOT HeatSink, as
// an old btl4gaug label claimed (HeatSink : HeatableSubsystem would make an
// OR-test after HeatableSubsystem redundant; the binary's alternate branch is
// the disjoint watcher family). Bridged so gauge TUs can test without
// including this family's headers.
//
int
BTIsHeatWatcher(Subsystem *sub)
{
return sub != 0 && sub->IsDerivedFrom(*HeatWatcher::GetClassDerivations());
}
//
// @4aec54 (468 bytes) -- parse the HeatWatcher resource. Stamps classID 0x0BBF
// / size 0xF0; defaults the watched index to -1; requires DegradationTemperature,
+32 -4
View File
@@ -1187,12 +1187,40 @@ Mech::Mech(
//
// Build the capability sub-rosters by IsDerivedFrom() class tests.
//
for (int id = 2; id < subsystemCount; ++id) // "start" each subsystem
// Loop 1 = the watcher CONNECT pass (task #57). The binary calls vtable
// slot +0x38 on every HeatWatcher-derived subsystem (@0x50e604 test);
// the slot bodies (FUN_004aee2c, PowerWatcher/Torso override @004b1a40,
// byte-identical -- Ghidra missed both starts, recovered from raw bytes)
// bind watchedLink to roster[watchedSubsystem], gated on the owner being
// the live master node ((flags & 0xC)==0 && (flags & 0x100)). The gate is
// hoisted out of the loop unchanged (constant per mech). This replaces
// the old SubProxy::Start() no-op that left every watchdogAlarm at 0 --
// which held the Torso's ElectricalStateLevel() below Ready and zeroed the
// twist rate forever.
//
if ((simulationFlags & 0xC) == 0 // SegmentCopyMask
&& (simulationFlags & 0x100) != 0) // MasterHeatSinkFlag
{
SubProxy *s = (SubProxy *)subsystemArray[id];
if (s != 0 && s->IsDerivedFrom(0x50e604)) // FUN_0041a1a4
extern int BTWatcherWatchedIndex(Subsystem *sub); // heatfamily_reslice.cpp
extern void BTWatcherBindTarget(Subsystem *sub, Subsystem *target);
for (int id = 2; id < subsystemCount; ++id)
{
s->Start(); // (**(s+0x38))(s)
int watched = BTWatcherWatchedIndex(subsystemArray[id]); // -1 = not a HeatWatcher
if (watched < 0)
continue;
if (watched < subsystemCount && subsystemArray[watched] != 0)
{
BTWatcherBindTarget(subsystemArray[id], subsystemArray[watched]);
DEBUG_STREAM << "[watch] subsystem " << id << " watches "
<< watched << std::endl;
}
else
{
// The binary has no range check (CreateStreamed validated the
// name); flag data drift honestly instead of a wild read.
DEBUG_STREAM << "[watch] subsystem " << id
<< " BAD WatchedSubsystem index " << watched << std::endl;
}
}
}
for (int id = 2; id < subsystemCount; ++id) // heatable roster
+56
View File
@@ -570,6 +570,20 @@ Scalar
switch (legAnimationState) // this+0x3b0
{
case StandingAnimation: // 0
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
// reverse speed, and several stand-entry paths (turn exit, terminal
// poses) never touch the cycle -- Standing could be entered with a
// stale legCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
// LOOKS still (case 0 never advances the clip) but the stale cycle
// REPLICATES and the peer's replicant marches in place. A standing
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
if (legCycleSpeed != 0.0f)
{
legCycleSpeed = 0.0f;
ForceUpdate(8); // type-3 record: legs stopped
}
// RAW (part_012.c FUN_004a5028 case 0): standSpeed < commandedSpeed ->
// begin WALKING (state 5); 0 <= commanded < standSpeed -> stay standing;
// commanded < 0 -> stand-to-reverse (0x10). (The earlier draft had the
@@ -823,6 +837,20 @@ Scalar
switch (bodyAnimationState) // this+0x728
{
case StandingAnimation: // 0
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
// reverse speed, and several stand-entry paths (turn exit, terminal
// poses) never touch the cycle -- Standing could be entered with a
// stale bodyCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
// LOOKS still (case 0 never advances the clip) but the stale cycle
// REPLICATES and the peer's replicant marches in place. A standing
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
if (bodyCycleSpeed != 0.0f)
{
bodyCycleSpeed = 0.0f;
ForceUpdate(8); // type-3 record: legs stopped
}
// RAW (FUN_004a5678 case 0): standSpeed < bodyTargetSpeed -> begin WALKING
// (5); 0 <= target < standSpeed -> stay standing; target < 0 -> reverse
// (0x10). (The earlier draft had the comparison INVERTED.)
@@ -979,6 +1007,20 @@ Scalar
switch (bodyAnimationState)
{
case StandingAnimation: // 0
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
// reverse speed, and several stand-entry paths (turn exit, terminal
// poses) never touch the cycle -- Standing could be entered with a
// stale bodyCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
// LOOKS still (case 0 never advances the clip) but the stale cycle
// REPLICATES and the peer's replicant marches in place. A standing
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
if (bodyCycleSpeed != 0.0f)
{
bodyCycleSpeed = 0.0f;
ForceUpdate(8); // type-3 record: legs stopped
}
if (bodyTargetSpeed <= standSpeed) // 0x6b4 <= 0x530
{
distance = 0.0f;
@@ -1166,6 +1208,20 @@ Scalar
switch (legAnimationState)
{
case StandingAnimation: // 0
// STANDING ZEROES THE CYCLE (reverse-stop desync, live-diagnosed
// 2026-07-13): a REVERSE cadence is NEGATIVE, so the walk-family stop
// gate (cycleSpeed <= ZeroSpeed) passes while still cycling at full
// reverse speed, and several stand-entry paths (turn exit, terminal
// poses) never touch the cycle -- Standing could be entered with a
// stale legCycleSpeed = -2.507 ([gaitSM] state=0 evidence). The master
// LOOKS still (case 0 never advances the clip) but the stale cycle
// REPLICATES and the peer's replicant marches in place. A standing
// mech's cycle is 0 (the clean forward-stop log: legSum ~3e-8).
if (legCycleSpeed != 0.0f)
{
legCycleSpeed = 0.0f;
ForceUpdate(8); // type-3 record: legs stopped
}
if (motionSource->commandedSpeed <= standSpeed) // +0x128 <= 0x530
{
distance = 0.0f;
+152 -16
View File
@@ -646,6 +646,9 @@ static int gBTLaserKey = 0; // raw key states (set by the keyboar
static int gBTPPCKey = 0;
static int gBTMissileKey = 0;
static int gBTPinkyKey = 0; // key '4' = the pod's 4th fire button (Pinky 0x45)
int gBTModeCycle = 0; // 'M' edge: cycle the control mode (mapper consumes)
float gBTTwistAxis = 0.0f; // Q/E torso-twist deflection (assisted-mode stick X)
int gBTTorsoRecenter = 0; // 'X' edge: pulse the authentic torso recenter (mapper consumes)
static int gBTConfigKey = 0; // task #6: HOLD 'G' = the weapon-configure button
static int gBTGenSelKey = 0; // task #12: F5..F8 = SelectGeneratorA..D, F9 = mode toggle
// (0 idle; else the message id 4..8)
@@ -785,6 +788,69 @@ static BTProjectile gProjectiles[64];
extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, float);
//###########################################################################
// Zone-effect bridge (mechdmg has no `application` access): route the damage-
// band effect through the AUTHENTIC RendererManager::StartEntityEffect chain
// (the @004d097c dispatcher; the AudioRenderer hears the same broadcast).
// Returns 0 when the manager isn't up so the caller can fall back.
//###########################################################################
int
BTStartZoneEffect(Mech *mech, void *zone, int resource)
{
if (mech == 0 || zone == 0 || resource <= 0
|| application == 0 || application->GetRendererManager() == 0)
return 0;
application->GetRendererManager()->StartEntityEffect(
(Entity *)mech, (DamageZone *)zone,
(ResourceDescription::ResourceID)resource);
return 1;
}
//###########################################################################
// Segment world-transform bridge (the @004d097c per-zone effect dispatcher +
// the attached-emitter follow in L4VIDEO's PFX layer). Resolves the entity's
// segment (by index) to its world position + 3x3 basis rows; falls back to
// the mech origin at torso height when the segment doesn't resolve. Returns
// 0 for a non-mech / unregistered entity (the follow then keeps its last
// frame -- and the emitter dies with its authored window anyway).
//###########################################################################
int
BTResolveSegmentWorld(void *entity, int seg_index, float *pos3, float *rows9)
{
extern int BTIsRegisteredMech(Entity *e);
if (entity == 0 || !BTIsRegisteredMech((Entity *)entity))
return 0;
Mech *m = (Mech *)entity;
Point3D p = m->localOrigin.linearPosition;
p.y += kMuzzleHeight; // fallback: torso height
if (seg_index >= 0)
{
EntitySegment::SegmentTableIterator it(m->segmentTable);
EntitySegment *seg;
while ((seg = it.ReadAndNext()) != NULL)
{
if (seg->GetIndex() == seg_index)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld);
p = mw; // Point3D = matrix translation
break;
}
}
}
pos3[0] = (float)p.x; pos3[1] = (float)p.y; pos3[2] = (float)p.z;
UnitVector ax, ay, az;
m->localToWorld.GetFromAxis(X_Axis, &ax);
m->localToWorld.GetFromAxis(Y_Axis, &ay);
m->localToWorld.GetFromAxis(Z_Axis, &az);
rows9[0] = (float)ax.x; rows9[1] = (float)ax.y; rows9[2] = (float)ax.z;
rows9[3] = (float)ay.x; rows9[4] = (float)ay.y; rows9[5] = (float)ay.z;
rows9[6] = (float)az.x; rows9[7] = (float)az.y; rows9[8] = (float)az.z;
return 1;
}
//###########################################################################
// PER-ROUND DETONATION (the binary's Missile::MoveAndCollide @004bef78: every
// round spawns ITS OWN ExplosionModelFile at its impact point, resource
@@ -1299,6 +1365,11 @@ void
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f);
ramLastVictim = 0;
ramContactLinger = 0.0f;
// StopAllEntityEffects (@004d0c14): a respawned mech must not trail its
// corpse's attached zone effects -- the authentic per-entity effect
// cleanup, broadcast to every renderer.
if (application != 0 && application->GetRendererManager() != 0)
application->GetRendererManager()->StopAllEntityEffects((Entity *)this);
// --- our RELOCATED gait/motion accumulators -> identity (the 1995 offsets
// the decomp zeroes map to these named members in our layout) ---
@@ -1823,6 +1894,56 @@ void
// task #13: 'C' cycles the coolant valve (BT_VALVE_SLOT
// picks the condenser roster slot; default = Condenser1).
gBTValveKey = focused && (pAsync('C') & dn) ? 1 : 0;
// TORSO CONTROLS (2026-07-13): 'M' cycles the control mode
// (Basic -> Standard -> Veteran -- the pod console button,
// CycleControlModeMessageHandler); Q/E deflect the torso
// twist axis (the STICK in Standard/Veteran, where A/D
// become the pedals). Ramped like the turn stick.
{
static int sPrevM = 0;
const int mNow = focused && (pAsync('M') & dn) ? 1 : 0;
if (mNow && !sPrevM) gBTModeCycle = 1; // edge -> one cycle
sPrevM = mNow;
const int tw = (focused && (pAsync('E') & dn) ? 1 : 0)
- (focused && (pAsync('Q') & dn) ? 1 : 0);
static float sTwist = 0.0f;
if (tw != 0)
{
sTwist += tw * kStickRate * dt;
if (sTwist > 1.0f) sTwist = 1.0f;
if (sTwist < -1.0f) sTwist = -1.0f;
}
else if (sTwist != 0.0f)
{
// SPRING-CENTER on release (user fix 2026-07-13: the
// old hold-deflection model left a residual axis ->
// the torso drifted forever with no way to stop).
// The pod stick is spring-centered: the axis is a
// twist-RATE demand, so release = rate 0 = the torso
// HOLDS where you aimed it (position is kept by
// Torso::currentTwist, not by the axis). Same model
// as the A/D turn stick (kStickCenterRate).
const float step = kStickCenterRate * dt;
if (sTwist > step) sTwist -= step;
else if (sTwist < -step) sTwist += step;
else sTwist = 0.0f;
}
// X = all-stop for the torso too: zero the axis NOW and
// pulse the AUTHENTIC recenter (torso centerCommand ->
// Recenter @004b6918 slews currentTwist back to 0; any
// new Q/E deflection cancels it, sim-side). Separate
// edge detector from the drive all-stop below -- both
// fire on the same press.
static int sPrevXT = 0;
const int xtNow = focused && (pAsync('X') & dn) ? 1 : 0;
if (xtNow && !sPrevXT)
{
sTwist = 0.0f;
gBTTorsoRecenter = 1; // mapper consumes -> CommandRecenter()
}
sPrevXT = xtNow;
gBTTwistAxis = sTwist;
}
// gBTDrive.fire = "any weapon trigger down" (feeds the bring-up
// damage dispatcher + the beam-visual keepalive)
gBTDrive.fire = (gBTLaserKey || gBTPPCKey || gBTMissileKey || gBTPinkyKey) ? 1 : 0;
@@ -1842,18 +1963,24 @@ void
}
sPrevV = vNow;
// RETICLE = TORSO BORESIGHT (task #39 correction): the pod had
// NO free-aim cursor -- the binary's HudSimulation computes
// reticlePosition from the mech's POSE quaternions
// (part_013.c:5652+, a rate-limited quat->euler of the torso
// aim), i.e. the crosshair marks where the TORSO GUNS point
// relative to the view, and you aim by steering the mech /
// twisting the torso (the pod's right stick). The earlier
// mouse-cursor slew was a mis-sourced stand-in and is
// REMOVED. Our cockpit view is body-mounted, so the
// crosshair deflection = the torso twist projected to screen
// -- identically ZERO on the fixed-torso BLH (dead-centre
// boresight). BT_AIM="x y" remains as the headless harness.
// RETICLE = SCREEN CENTER (task #58 correction, supersedes the
// task #39 "body-mounted view" model): the CAMERA yaws with
// the torso (the cockpit sits on jointtorso -- see the
// gBTEyeTwist publisher), so screen center always IS the gun
// boresight and the crosshair stays centered while the WORLD
// rotates past it -- the twist reads on the bottom tape
// carets / compass / radar wedge, not the crosshair. [T1:
// jointtorso->jointeye->siteeyepoint chain + FUN_004c22c4
// inverse-chain view; crosshair-twist forensics 2026-07-13.]
// The old gBTAimX = tan(twist) slew was a port invention on
// the falsified body-mounted premise -- with the yawing eye
// it double-counts (crosshair pinned to HULL-forward, fire
// ray resolving body-forward: "twisting leaves the
// crosshairs behind"). Reticle mobility exists in the
// binary (Reticle::reticlePosition, RETICLE.h:42) but its
// twist-era use is the FIXED-TORSO free-aim channel
// (mech+0x36c, writer un-exported) -- deferred.
// BT_AIM="x y" remains as the headless harness.
{
static int sAimEnv = -1;
static float sAimEnvX = 0.0f, sAimEnvY = 0.0f;
@@ -1870,10 +1997,10 @@ void
}
else
{
// boresight = torso twist vs the body-mounted view,
// projected through the live per-axis projection.
extern float BTTwistToReticleX(float twist_rad);
gBTAimX = BTTwistToReticleX(gBTHudTwist); // BLH: 0
// Boresight = screen center: the eye carries the
// twist (task #58), the pick ray inherits it from
// the published eye basis.
gBTAimX = 0.0f;
gBTAimY = 0.0f;
}
}
@@ -3299,6 +3426,15 @@ void
gBTHudTwistLimit = (float)GetHorizontalFiringReach();// HorizontalTorsoLimit (attrs 5/6)
gBTHudGroupMask = (int)targetReticle.reticleElementMask & 0xF;
gBTHudPrimary = ((int)targetReticle.reticleElementMask & 0x20) != 0;
// task #58: publish the live twist for renderer-side DIAGNOSTICS
// (correlating [eyefwd] against the twist). The cockpit eye does
// NOT consume this -- it inherits the twist authentically through
// jointtorso's HingeRenderable in the draw traversal (see the note
// in DPLEyeRenderable::Execute).
{
extern float gBTEyeTwist;
gBTEyeTwist = gBTHudTwist;
}
}
// task #6 ORDER FIX: the scripted block must run BEFORE the fire-push
+15 -2
View File
@@ -1092,8 +1092,21 @@ void
}
if (d != 0)
{
BTSpawnDamageEffect(owner, d->effectResource, // explosion AT the zone's
zone->segmentIndex); // segment (world position)
// THE AUTHENTIC CHAIN (@004d097c dispatcher, landed 2026-07-13):
// RendererManager::StartEntityEffect -> the Renderer message
// handler [T0: GameModel resolve + ExplosionResourceTable
// graphic-state remap] -> BTL4VideoRenderer::
// StartEntityEffectImplementation -> the effect ATTACHED to the
// zone's segment (smoke follows the walking mech) -- and the
// AudioRenderer's implementation hears the same message (zone
// sounds ride free). Explosion::Make fallback only when the
// manager isn't up (early bring-up).
extern int BTStartZoneEffect(Mech *mech, void *zone, int resource);
if (!BTStartZoneEffect(owner, zone, d->effectResource))
{
BTSpawnDamageEffect(owner, d->effectResource, // explosion AT the zone's
zone->segmentIndex); // segment (world position)
}
zone->ApplyDamageGraphicState(d->graphicState); // destroyed skin (graphic state)
// A graphic-state change means the segment's MODEL changed (intact ->
// destroyed variant, keyed by GetVideoObjectName(skl, gstate)). Fire
+6
View File
@@ -211,6 +211,12 @@
int
segmentIndex; // @0x194 GetSegmentIndex() result
public:
// The zone's segment slot -- consumed by the @004d097c per-zone effect
// dispatcher (BTL4VideoRenderer::StartEntityEffectImplementation).
int EffectSegmentIndex() const { return segmentIndex; }
protected:
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Damage Support
//
+80 -16
View File
@@ -440,6 +440,18 @@ void
Check(message);
if (message->dataContents > 0)
{
CycleControlModeNow();
}
}
//
// The mode-cycle body, shared by the console-button message handler above and
// the desktop 'M' key (mech4 key poll -> gBTModeCycle).
//
void
MechControlsMapper::CycleControlModeNow()
{
{
controlMode = (ControlMode)(controlMode + 1);
if (controlMode > VeteranMode)
@@ -448,23 +460,28 @@ void
}
NotifyOfControlModeChange(controlMode); // vtable+0x48
Mech *mech = GetMech();
int torso = *(int *)((int)mech + 0x438);
int cockpit = *(int *)((int)mech + 0x5b4);
if (controlMode == BasicMode)
// TYPED torso reconfiguration (2026-07-13): the raw block this
// replaces wrote the BINARY's offsets (torso+0x1f0/0x274/0x220...)
// straight onto OUR compiled Torso -- the databinding trap: garbage
// writes into whatever members live there in this build. The
// observable semantics via named members: Basic clears the analog
// axes and recenters (the sim's centerCommand -> Recenter); the
// assisted modes just free the torso (the sim clamps to the authored
// limits on its own).
Mech *mech = GetMech();
Torso *torso = (mech != 0) ? (Torso *)mech->GetTorsoSubsystem() : 0;
if (torso != 0)
{
*(LWord *)(torso + 0x1f0) = 0; // no free aim
*(LWord *)(torso + 0x274) = 1; // auto-center on
*(LWord *)(torso + 0x220) = *(LWord *)(torso + 0x228); // recenter yaw
*(LWord *)(torso + 0x224) = *(LWord *)(torso + 0x22c); // recenter pitch
*(LWord *)(cockpit + 0x2a0) = 1;
}
else if ((unsigned)(controlMode - 1) < 2) // Standard / Veteran
{
*(LWord *)(torso + 0x220) = *(LWord *)(torso + 0x230); // yaw -> limit
*(LWord *)(torso + 0x224) = *(LWord *)(torso + 0x234); // pitch -> limit
if (controlMode == BasicMode)
{
torso->SetAnalogTwistAxis(0.0f);
torso->SetAnalogElevationAxis(0.0f);
torso->CommandRecenter();
}
// Standard/Veteran: nothing to force -- the sim's limits govern.
}
DEBUG_STREAM << "[mode] control mode -> " << (int)controlMode
<< " (0=Basic 1=Standard 2=Veteran)" << std::endl;
}
Check_Fpu();
}
@@ -655,7 +672,54 @@ void
}
throttlePosition = (key_throttle >= 0.0f) ? key_throttle : -key_throttle;
reverseThrust = (key_throttle < 0.0f) ? 1 : 0;
stickPosition.x = key_turn;
// CONTROL-MODE AXIS ROUTING (2026-07-13, the pod mapping): in
// BASIC the stick steers the legs (turn) and the torso auto-
// centers; in STANDARD/VETERAN the stick is the TORSO (free aim /
// twist) and the PEDALS steer -- so the keyboard's A/D feed the
// pedals and Q/E ('the stick') feed the twist. 'M' cycles modes
// (the same body the pod console button message drives, so the
// CONTROL MODE gauge tracks).
{
extern int gBTModeCycle;
extern float gBTTwistAxis;
extern int gBTTorsoRecenter;
if (gBTModeCycle)
{
gBTModeCycle = 0;
CycleControlModeNow();
}
if (controlMode == BasicMode)
{
stickPosition.x = key_turn;
pedalsPosition = 0.0f;
}
else
{
stickPosition.x = gBTTwistAxis; // the torso axis
pedalsPosition = key_turn; // A/D = the pedals
// 'X' recenter pulse (2026-07-13): one-frame centerCommand
// -> the sim arms recenterActive and Recenter (@004b6918)
// slews the torso home; any Q/E input cancels it sim-side.
// centerCommand is a pod BUTTON state, so the writer clears
// it while unpressed (Basic's own path re-asserts every
// frame; this branch owns it in Standard/Veteran).
{
Torso *rcTorso = (Torso *)mech->GetTorsoSubsystem();
if (rcTorso != 0)
{
if (gBTTorsoRecenter)
{
gBTTorsoRecenter = 0;
rcTorso->CommandRecenter();
}
else
{
rcTorso->ClearRecenterCommand();
}
}
}
}
}
stickPosition.y = 0.0f;
}
}
+5
View File
@@ -104,6 +104,11 @@ class Pilot;
CycleControlModeMessageHandler( // @004afbe0
ReceiverDataMessageOf<ControlsButton> *message
);
// The shared mode-cycle body (the console-button handler above + the
// desktop 'M' key both drive it).
void
CycleControlModeNow();
void
CycleDisplayModeMessageHandler( // @004afcac
ReceiverDataMessageOf<ControlsButton> *message
+71 -7
View File
@@ -75,7 +75,12 @@ static const Scalar MaxOutputVoltage = 1.0f; // _DAT_004b2154 / _DAT_004b2258
static const Scalar RatedVoltageRef = 1.0f; // _DAT_004b1d10 (myomer rated reference)
static const Scalar DegradedDriveScale = 0.5f; // _DAT_004b1d14 (heat-degradation throttle)
static const Scalar ResourceUnset = -1.0f; // _DAT_004b177c / _DAT_004b1a3c / _DAT_004b2580
static const Scalar MinVoltageScale = 1.0f; // _DAT_004b1924 (PowerWatcher threshold scale)
static const Scalar MinVoltageScale = 0.01f; // _DAT_004b1924 -- a 10-byte x87 literal
// (0a d7 a3 70 3d 0a d7 a3 f8 3f = 0.01,
// byte-read from the exe, task #57): converts
// the authored MinVoltagePercent to a fraction.
// Was 1.0f, which made minVoltage 100x too big
// and latched the watchdog brownout (level 1).
//###########################################################################
@@ -1191,8 +1196,12 @@ PowerWatcher::SharedData
Derivation*
PowerWatcher::GetClassDerivations()
{
// Real base ctor is @004aeb40 (HeatWatcher); HeatableSubsystem stands in.
static Derivation classDerivations(HeatableSubsystem::GetClassDerivations(), "PowerWatcher");
// Chains to the REAL base HeatWatcher (@004aeb40) -- task #57: the old
// HeatableSubsystem stand-in predated the base-chain re-base and broke
// IsDerivedFrom(HeatWatcher) for every PowerWatcher descendant, which
// silently skipped the Torso/Searchlight/ThermalSight in the factory
// watcher-CONNECT pass (their watchdogs stayed at 0 = never Ready).
static Derivation classDerivations(HeatWatcher::GetClassDerivations(), "PowerWatcher");
return &classDerivations;
}
@@ -1254,13 +1263,68 @@ Logical
}
//
// @004b1804 -- delegate to the base simulation.
// @004b1804 (slot 10) -- ResetToInitialState: chain to the HeatWatcher base.
// (This body was previously MISLABELED as the Simulation. The vtable
// @0050f99c slot 10 (offset 0x28) is 004b1804, exactly where every other
// subsystem carries ResetToInitialState; the registered Performance PTR
// @0050f5fc points at 004b181c -- UpdateWatch below.)
//
void
PowerWatcher::Simulation(Scalar time_slice)
PowerWatcher::ResetToInitialState(Logical /*powered*/)
{
HeatWatcher::ResetToInitialState(True); // FUN_004aea9c = HeatWatcher::ResetToInitialState
(void)time_slice;
HeatWatcher::ResetToInitialState(True); // FUN_004aea9c
}
//
// @004b181c -- the per-tick watch update: THE registered Performance
// (PTR @0050f5fc -> 004b181c), and called directly by the Torso master/copy
// simulations (@004b5cf0/@004b65f8 first line). Ghidra missed the function
// start; recovered from the raw pseudocode at part_013.c:1645:
//
// FUN_004aeac4(this); // heat mirror
// watched = watchedLink.Resolve(); // +0x114
// gen = watched->voltageSource.Resolve(); // +0x1D0
// watchdogAlarm.SetLevel(watched->elecLevel); // mirror +0x278
// if (elecLevel == 4 && gen && gen->outputVoltage(+0x1DC)
// <= minVoltage(+0x180) * gen->ratedVoltage(+0x1D8))
// watchdogAlarm.SetLevel(1); // brownout
//
// The watchdog MIRRORS the watched subsystem's electrical state -- this is
// what drives ElectricalStateLevel()==Ready on the Torso (the twist-rate
// power gate) and WatchedVoltageLevel on Searchlight/ThermalSight.
//
void
PowerWatcher::UpdateWatch()
{
WatchSimulation(0.0f); // FUN_004aeac4 -- heat-alarm mirror
PoweredSubsystem *watched = (PoweredSubsystem *)watchedLink.Resolve(); // FUN_00417ab4(this+0x114)
if (watched == 0)
{
// The binary derefs unconditionally (the link always binds on the
// master node). Null only on replicants / bring-up: hold Not-Ready.
watchdogAlarm.SetLevel(0);
return;
}
int level = watched->electricalStateAlarm.GetLevel(); // *(watched+0x278)
watchdogAlarm.SetLevel(level); // FUN_0041bbd8(this+0x184, level)
Generator *source = (Generator *)watched->ResolveVoltageSource(); // FUN_00417ab4(watched+0x1d0)
if (level == PoweredSubsystem::Ready && source != 0
&& source->MeasuredVoltage() <= minVoltage * source->RatedVoltageOf())
{
watchdogAlarm.SetLevel(1); // brownout: Ready but sagging source
}
}
//
// The Performance wrapper (PTR @0050f5fc -> @004b181c takes no dt).
//
void
PowerWatcher::Simulation(Scalar /*time_slice*/)
{
UpdateWatch();
}
//
+8 -1
View File
@@ -553,7 +553,14 @@ class Generator;
Logical
HandleMessage(int message); // @004b179c (chains to base @004aea84)
void
Simulation(Scalar time_slice); // @004b1804 (chains to base @004aea9c)
ResetToInitialState(Logical powered); // @004b1804 (slot 10; chains to base @004aea9c)
void
UpdateWatch(); // @004b181c -- watchdog mirror of the watched
// subsystem's electrical state (+ brownout);
// the Torso sims call this directly
void
Simulation(Scalar time_slice); // the registered Performance (PTR @0050f5fc
// -> @004b181c): forwards to UpdateWatch()
public:
// BASE-CHAIN RE-BASE: the shadow watchedLink was DELETED -- the binary has a
+101 -14
View File
@@ -94,6 +94,9 @@ namespace {
inline Scalar
RecordField(Simulation::UpdateRecord *record, int byte_offset)
{ return *(const Scalar*)((const char*)record + byte_offset); }
inline void
WriteRecordField(Simulation::UpdateRecord *record, int byte_offset, Scalar value)
{ *(Scalar*)((char*)record + byte_offset) = value; }
}
//###########################################################################
@@ -243,19 +246,29 @@ Torso::Torso(
// rate. TorsoSimulation drives the sweep (below). Remove after verification.
if (isDamagedCopy == 0 && getenv("BT_FORCE_TORSO"))
{
// ⚠ HARNESS TRAP (task #58 post-mortem): this hook used to override the
// joint nodes UNCONDITIONALLY with the BLH demo names -- on a mech whose
// resource already resolved REAL torso joints (the MadCat's jointtorso)
// that silently redirected the whole sweep into the SHADOW hinge, and a
// probe run "proved" the cockpit camera ignored the twist (it doesn't;
// the joint chain delivers it). Now: only fill joints that did NOT
// resolve from the resource; a twist-capable mech sweeps its real ones.
horizontalEnabled = True; // @0x250
const char *mj = getenv("BT_FORCE_TORSO_JOINT");
if (mj == 0 || *mj == '\0') mj = "jointshakey2";
horizontalJointNode = ResolveJoint(mj); // torso body (ball)
horizontalShadowJointNode = ResolveJoint("jointtshadow"); // shadow twist (hingey)
horizontalLimitLeft = 0.7f; // @0x1E0 ~40 deg
horizontalLimitRight = -0.7f; // @0x1DC
if (horizontalJointNode == 0)
{
const char *mj = getenv("BT_FORCE_TORSO_JOINT");
if (mj == 0 || *mj == '\0') mj = "jointshakey2";
horizontalJointNode = ResolveJoint(mj); // torso body (ball)
horizontalShadowJointNode = ResolveJoint("jointtshadow"); // shadow twist (hingey)
horizontalLimitLeft = 0.7f; // @0x1E0 ~40 deg
horizontalLimitRight = -0.7f; // @0x1DC
}
baseTwistRate = 1.0f; // @0x23C rad/s slew
if (getenv("BT_TORSO_LOG"))
{
DEBUG_STREAM << "[torso] FORCE-ENABLE '" << mj << "' -> " << (void*)horizontalJointNode;
DEBUG_STREAM << "[torso] FORCE-ENABLE node=" << (void*)horizontalJointNode;
if (horizontalJointNode) DEBUG_STREAM << " type=" << (int)horizontalJointNode->GetJointType();
DEBUG_STREAM << " ; shadow 'jointtshadow' -> " << (void*)horizontalShadowJointNode;
DEBUG_STREAM << " ; shadow node=" << (void*)horizontalShadowJointNode;
if (horizontalShadowJointNode) DEBUG_STREAM << " type=" << (int)horizontalShadowJointNode->GetJointType();
DEBUG_STREAM << "\n" << std::flush;
}
@@ -387,14 +400,17 @@ Logical
}
//
// @004b6a78 (slot 6) [CONFIDENT] -- network/replay update record. Samples the
// @004b6a78 (slot 6) [CONFIDENT] -- the REPLICANT-side apply (engine semantics
// [T0]: ReadUpdateRecord = write the object FROM the record). Samples the
// clock (FUN_00414b60) into lastUpdateTime, biasing it forward by one interval
// when the elapsed window is below MinSlewMs, chains to Subsystem::WriteUpdateRecord
// (FUN_0041bd34), then writes twistAtUpdate / twistVelocity / twistRate from the
// record fields (record +0x10 / +0x14 / +0x18).
// when the elapsed window is below MinSlewMs, chains to the base apply
// (FUN_0041bd34 = ReadUpdateRecord, 2 args), then applies twistAtUpdate /
// twistVelocity / twistRate from the record extras (+0x10 / +0x14 / +0x18).
// (Task #57: previously mislabeled as WriteUpdateRecord -- the direction flip
// made the MASTER consume its own blank record.)
//
void
Torso::WriteUpdateRecord(UpdateRecord *message, int update_model)
Torso::ReadUpdateRecord(UpdateRecord *message)
{
lastUpdateTime = GetCurrentTime(); // @0x254
if ((Scalar)(lastUpdateTime - GetCreationTime()) / MsPerSecond < MinSlewMs)
@@ -402,13 +418,41 @@ void
lastUpdateTime += (lastUpdateTime - GetCreationTime()); // stretch tiny windows
}
Subsystem::WriteUpdateRecord(message, update_model); // FUN_0041bd34
Subsystem::ReadUpdateRecord(message); // FUN_0041bd34
twistAtUpdate = RecordField(message, 0x10); // @0x21C
twistVelocity = RecordField(message, 0x14); // @0x1E8
twistRate = RecordField(message, 0x18); // @0x238
}
//
// @004b6a1c (slot 7) [CONFIDENT, raw-disasm recovery -- Ghidra missed the
// function start] -- the MASTER-side serialize (engine semantics [T0]:
// WriteUpdateRecord = fill the record FROM the object). Chains the base
// producer (FUN_0041c500(this, record, model), 3 args), extends the record to
// 0x1C bytes and appends currentTwist / twistVelocity / twistRate at
// +0x10/+0x14/+0x18 (exactly what the replicant's @004b6a78 applies), then
// snapshots twistAtUpdate = currentTwist:
//
// 004b6a2e call 0041c500 ; base WriteUpdateRecord
// 004b6a38 mov [record], 0x1C ; recordLength = 0x1C
// 004b6a44+ record+0x10 = this+0x1D8 ; currentTwist
// 004b6a51 record+0x14 = this+0x1E8 ; twistVelocity
// 004b6a5a record+0x18 = this+0x238 ; twistRate
// 004b6a63+ this+0x21C = this+0x1D8 ; twistAtUpdate = currentTwist
//
void
Torso::WriteUpdateRecord(UpdateRecord *message, int update_model)
{
Subsystem::WriteUpdateRecord(message, update_model); // FUN_0041c500
message->recordLength = 0x1C; // base header 0x10 + 3 Scalars
WriteRecordField(message, 0x10, currentTwist); // @0x1D8
WriteRecordField(message, 0x14, twistVelocity); // @0x1E8
WriteRecordField(message, 0x18, twistRate); // @0x238
twistAtUpdate = currentTwist; // @0x21C snapshot at send
}
//#############################################################################
// Per-frame simulation
@@ -464,6 +508,34 @@ void
analogTwistAxis = ((++s_sweep / 90) & 1) ? -1.0f : 1.0f; // +/- every ~90 frames
}
// TORSO GATE PROBE (BT_TORSO_LOG): why is the twist rate zero?
if (getenv("BT_TORSO_LOG"))
{
static int s_tl = 0;
if ((s_tl++ % 120) == 0)
{
PoweredSubsystem *w = (PoweredSubsystem *)watchedLink.Resolve();
DEBUG_STREAM << "[torso] hmOff=" << (int)HeatModelOff()
<< " elec=" << (int)ElectricalStateLevel()
<< " heatState=" << (int)HeatStateLevel()
<< " rate=" << effectiveTwistRate
<< " base=" << baseTwistRate
<< " hEn=" << (int)horizontalEnabled
<< " limits=(" << horizontalLimitRight << ".." << horizontalLimitLeft << ")"
<< " axis=" << analogTwistAxis
<< " twist=" << currentTwist
<< " wIdx=" << watchedSubsystem
<< " w=" << (void*)w
<< " wElec=" << (w ? w->electricalStateAlarm.GetLevel() : -1)
<< " wSrc=" << (void*)(w ? w->ResolveVoltageSource() : 0);
Generator *g = (w ? (Generator *)w->ResolveVoltageSource() : 0);
if (g)
DEBUG_STREAM << " gOut=" << g->MeasuredVoltage()
<< " gRated=" << g->RatedVoltageOf()
<< " minV=" << minVoltage;
DEBUG_STREAM << std::endl;
}
}
Scalar twistStep = effectiveTwistRate * time_slice;
Scalar elevStep = effectiveElevationRate * time_slice;
@@ -616,6 +688,21 @@ void
// external joint pass as the master path (see TorsoSimulation for the @004b67ec
// note). Harmless in single-player bring-up (no copies); correct for MP.
UpdateJoints(); // FUN_004b67ec
// COPY-SIDE PROBE (BT_TORSO_LOG): where does the replicant's twist come from?
if (getenv("BT_TORSO_LOG"))
{
static int s_cl = 0;
if ((s_cl++ % 120) == 0)
DEBUG_STREAM << "[torso-copy] cur=" << currentTwist
<< " target=" << targetTwist
<< " atUpd=" << twistAtUpdate
<< " rate=" << twistRate
<< " vel=" << twistVelocity
<< " lastUpd=" << lastUpdateTime
<< " now=" << GetCurrentTime()
<< " copy=" << (int)isDamagedCopy << std::endl;
}
}
+18 -4
View File
@@ -156,11 +156,13 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
void SetMovedFlag() { ForceUpdate(); }
// Local helper: "is value pinned at this software limit?".
Logical AtLimit(Scalar value, Scalar limit) const { return fabsf(value - limit) <= 0.0001f; }
// CROSS-FAMILY delegate shims -- still no-ops (no layout impact; STEP-4 will
// chain them to the real PowerWatcher/HeatWatcher base once the Torso is live).
// CROSS-FAMILY delegate shims (STEP-4): UpdateWatch now chains to the real
// PowerWatcher per-tick watch body (@004b181c, task #57) -- this is what
// raises watchdogAlarm to Ready and un-gates the twist rate. The other
// two remain no-ops (no layout impact).
void WatcherResetToInitialState() {}
Logical WatcherHandleDeathMessage(Message & /*m*/) { return False; }
void WatcherUpdateWatch() {}
void WatcherUpdateWatch() { UpdateWatch(); } // FUN_004b181c (body: powersub.cpp)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Test Class Support
@@ -189,6 +191,8 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
// Controls (@0x1F0 twist, @0x1F4 elevation); proportional, no button ramp.
void SetAnalogTwistAxis(Scalar v) { analogTwistAxis = v; }
void SetAnalogElevationAxis(Scalar v) { analogElevationAxis = v; }
void CommandRecenter() { centerCommand = 1; } // @0x208 (Basic-mode re-center)
void ClearRecenterCommand() { centerCommand = 0; } // button released (writer-owned state)
Logical GetHorizontalEnabled() const { return horizontalEnabled; } // @0x250 (mapper free-aim gate @004afd10)
// Reachable horizontal (yaw) half-arc the guns can be brought to bear by
@@ -224,7 +228,17 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
// Subsystem virtual overrides (slots on vtable @0051103c)
//
public:
void WriteUpdateRecord(UpdateRecord *message, int update_model); // slot 6, @004b6a78
// Update-record pair (task #57 direction fix): the ENGINE's semantics
// [T0 SIMULATE.cpp] are Write = PRODUCE the outgoing record (master),
// Read = APPLY the incoming record (replicant). @004b6a78 (slot 6,
// base chain 41bd34 = ReadUpdateRecord) CONSUMES record+0x10/14/18 --
// it is the READ side, previously mislabeled Write; the real WRITE
// side is @004b6a1c (slot 7, base chain 41c500, 3 args), which Ghidra
// missed -- recovered from raw disasm. With the old flip the master
// never serialized twist and clobbered its own fields from the
// uninitialized stream buffer (the replicant's 0xCDCDCDCD twist pin).
void ReadUpdateRecord(UpdateRecord *message); // slot 6, @004b6a78 (apply)
void WriteUpdateRecord(UpdateRecord *message, int update_model); // slot 7, @004b6a1c (serialize)
Logical HandleDeathMessage(Message &message); // slot 9, @004b5be0 -> @004b179c
void ResetToInitialState(); // slot 10, @004b5bf8