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 -1
View File
@@ -61,7 +61,20 @@ inverse(eyeWorld)`. No LookAt anywhere.
- offset matrix = the segment's **GetBaseOffset()** (segment+0x74, the LOCAL rest transform) — - offset matrix = the segment's **GetBaseOffset()** (segment+0x74, the LOCAL rest transform) —
NOT GetSegmentToEntity, NOT an upright basis; NOT GetSegmentToEntity, NOT an upright basis;
- parent = the eyepoint segment's **parent draw component** (`dcs_array[GetParentIndex()]`) — - parent = the eyepoint segment's **parent draw component** (`dcs_array[GetParentIndex()]`) —
liveness (torso pose, gait, gyro) reaches the eye ONLY through this parent chain; liveness reaches the eye through this parent chain in the PORT TOO (task #58 FINAL): the
twist path is `Torso::PushTwist → Joint::SetRotation → hinge rotationAmount →
HingeRenderable::Execute` (multiplies the live hinge into the matrix stack), and the eye
executes inside that scope (`jointtorso → jointeye → siteeyepoint`; VERIFIED: with the real
jointtorso swept, the active cockpit `[eyefwd]` swings ~60° and the torso hinge renderable
sits in the eye's parent chain). The CAMERA yaws with the twist, the canopy rides the same
subtree, the crosshair stays screen-centered — see [[combat-damage]] Targeting.
⚠ TWO instrumentation traps produced false "eye is frozen" readings first: (1) the headless
default camera is the CHASE eye (root-parented, same DPLEyeRenderable class — gate probes on
the COCKPIT eye being live); (2) `BT_FORCE_TORSO` used to override the joint names with the
BLH demo pair (`jointshakey2`/`jointtshadow`), silently redirecting a MadCat sweep into the
SHADOW hinge — the hook now only fills joints the resource left unresolved. An explicit
eye-side twist compose added on those false readings DOUBLE-ROTATED the view (2× vs the 1×
canopy) and is retired;
- the `EyepointRotation` entity attribute (EulerAngles; Mech member, init 0). - the `EyepointRotation` entity attribute (EulerAngles; Mech member, init 0).
- **Combine order:** `FUN_0040b104(dst,A,B)` computes `dst = B × A` (translation from B) → - **Combine order:** `FUN_0040b104(dst,A,B)` computes `dst = B × A` (translation from B) →
the eye local = `baseOffset × R`, R applied FIRST in eye-local space. Recomputed only when the the eye local = `baseOffset × R`, R applied FIRST in eye-local space. Recomputed only when the
+39 -23
View File
@@ -32,29 +32,45 @@ player input). [T1]
`mech+0x38c` = targeted sub-zone (1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint, `mech+0x38c` = targeted sub-zone (1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint,
refreshed each frame. [T1] refreshed each frame. [T1]
**The AUTHENTIC acquisition model (recovered 2026-07-08; corrected 2026-07-08, task #39):** THERE **The AUTHENTIC acquisition model (recovered 2026-07-08; task #39; RE-CORRECTED 2026-07-13, task
IS NO FREE-AIM MOUSE CURSOR. The pod had a stick whose yaw drove the **torso twist** #58 crosshair-twist forensics):** THERE IS NO FREE-AIM MOUSE CURSOR — and **the VIEW is
(`MechControlsMapper` writes `HUD::SetFreeAimSlew(stick_x)` → cockpit+0x28C, gated on the torso's TORSO-MOUNTED, not body-mounted**. The cockpit eye hangs off the torso yaw hinge in every
horizontal-twist being enabled — `mechmppr.cpp:735/762`, hud.hpp:167 [T1]); the crosshair marks twist-capable skeleton (`jointtorso` (hinge-Y) → `jointeye``siteeyepoint`; all 14 twist-enabled
where the **torso guns boresight** relative to the body-mounted view. You aim by **steering the Torso records name `TorsoHorizontalJoint=jointtorso`; `Torso::UpdateJoints` writes currentTwist
mech / twisting the torso**, not by moving a cursor. The engine `Reticle` struct into that joint each frame; VIEW = inverse of the LIVE joint chain, `FUN_004c22c4`
(`engine/MUNGA/RETICLE.h`) carries `reticlePosition`, `pickPointingOn`, `rayIntersection`, part_013.c:11742, no de-twist anywhere [T1]). So when the torso twists, **the CAMERA yaws with
`targetEntity`, `targetDamageZone` — a general struct shared with Red Planet — but in BT the guns and the crosshair stays SCREEN-CENTERED** — truthfully, because screen center always IS
`reticlePosition`/HotBoxVector (@mech-HUD+0x1FC) is **COMPUTED by HudSimulation** from the mech the boresight; the world rotates past it. The twist reads on INSTRUMENTS: the bottom 21-tick tape
pose quaternion (`FUN_0040954c` quat→euler of mech+0x10C) + the target geometry, and **zeroed to carets, the compass (= body yaw + twist, part_013.c:5674-5676), the radar wedge
centre when there is no target** (part_013.c:5680, `FUN_00408440(...,&DAT_004e0f74)` [T1]) — it is (part_013.c:11836-11859) [T1]. The old "crosshair = torso boresight offset in a body-mounted
NOT a free-floating cursor. The pick then hits whatever the guns point at; that entity + its view" model was falsified — it was never discriminated before because every prior live test used
DAMAGE ZONE become the target (`0x37c` rayIntersection, `0x388` targetEntity, `0x38c` the fixed-torso BLH, where both models center the crosshair.
targetDamageZone → aimed-zone damage). Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0`) The engine `Reticle` struct (`engine/MUNGA/RETICLE.h`) carries `reticlePosition` (a variable
boresight dead-ahead — the crosshair sits centred and you point the whole mech. Once locked, screen position 1..+1 [T0]) — the reticle CAN move, but its writer is in an un-exported decomp
`Emitter::FireWeapon` converges with NO aim/arc test (part_013.c:7758). `MechWeapon:: gap (`s_TargetReticle` has exactly ONE xref binary-wide: the read-side lookup part_014.c:5132);
UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange = dist < (1 hostZoneDamage) × the only coherent twist-era mover is the **fixed-torso free-aim channel**: `MechControlsMapper`
weaponRange`. The 0x388 WRITER (the per-frame reticle→mech copy) is in an un-exported decomp gap. writes `HUD::SetFreeAimSlew(stick_x)` → cockpit+0x28C **only when torso twist is disabled**
**Port status: RECONSTRUCTED + runtime-verified (tasks #36/#39, 2026-07-08) [T2].** The chain: (part_013.c:339-345/381-387 [T1]); HudSimulation integrates it (negated, part_013.c:5717) into
the crosshair = **torso boresight** (`BTTwistToReticleX(torsoTwist)`, L4VIDEO — tan(twist) +0x294 clamped ±(+0x29C) → `mech+0x36c` every frame (:5735) — no exported consumer; the
projected through the live per-axis projection; **0 = dead-centre on the fixed-torso BLH**; mech+0x36c → reticlePosition link is [T4]. NB: an earlier version of this section conflated
`BT_AIM="x y"` pins it for headless tests) → `BTGetAimRay` (the ACTIVE eye publishes pos+LookAtRH `reticlePosition` with **HotBoxVector** (HUD+0x1FC — the TARGET designator HudSimulation zeroes
basis via `BTSetAimCamera`, the render loop publishes proj._11/_22 + backbuffer dims) → with no target, part_013.c:5680); they are different things.
The pick hits whatever the guns point at; that entity + its DAMAGE ZONE become the target
(`0x37c` rayIntersection, `0x388` targetEntity, `0x38c` targetDamageZone → aimed-zone damage).
Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0` — no jointtorso in the skeleton at all)
boresight dead-ahead. Once locked, `Emitter::FireWeapon` converges with NO aim/arc test
(part_013.c:7758). `MechWeapon::UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange =
dist < (1 hostZoneDamage) × weaponRange`. The 0x388 WRITER is in the same un-exported gap.
**Port status: RECONSTRUCTED + runtime-verified (tasks #36/#39/#58) [T2].** The chain: the
cockpit eye inherits the twist AUTHENTICALLY through the draw traversal (`Torso::PushTwist`
hinge `rotationAmount``HingeRenderable::Execute` multiplies the live hinge into the matrix
stack; the eye executes under `jointtorso → jointeye`; verified `[eyefwd]` sweeps with the real
jointtorso — NO explicit eye-side compose exists, one briefly added on false "frozen eye"
probe readings double-rotated the view and is retired, see [[cockpit-view]]); the crosshair sits
at screen center (`gBTAimX = 0` — the old `BTTwistToReticleX` slew was THE bug: the camera
already yawed, so the crosshair counter-slid to HULL-forward, "twisting leaves the crosshairs
behind"; `BT_AIM="x y"` still pins it for headless tests) → `BTGetAimRay` (the ACTIVE eye
publishes pos+basis via `BTSetAimCamera`, leveled in pitch, the twist yaw riding in the basis) →
`Mech::PickRayHit` (world ray → local frame → `BoundingBox::HitBy` slab test vs the collision `Mech::PickRayHit` (world ray → local frame → `BoundingBox::HitBy` slab test vs the collision
template's ExtentBox → world hit point). template's ExtentBox → world hit point).
+13 -1
View File
@@ -83,12 +83,24 @@ Identify a class at runtime with `sub->GetClassID()` (== `sub+4`). [T1]
| **0x511830** | **MechWeapon**::ClassDerivations | The reticle AddWeapon loop (part_014.c:5386) hard-aborts on missing WeaponRange/PipPosition/… attrs for every member; part_012.c:9344 counts ready weapons; the Mech ctor roster at this[0x1ef] (`weaponRoster`, ex-mislabel "poweredSubsystems") ORs weapon `capabilityFlags`@+0x334. [T1] | | **0x511830** | **MechWeapon**::ClassDerivations | The reticle AddWeapon loop (part_014.c:5386) hard-aborts on missing WeaponRange/PipPosition/… attrs for every member; part_012.c:9344 counts ready weapons; the Mech ctor roster at this[0x1ef] (`weaponRoster`, ex-mislabel "poweredSubsystems") ORs weapon `capabilityFlags`@+0x334. [T1] |
| 0x50f4bc | PoweredSubsystem | btl4gau2 gauge wave. [T1] | | 0x50f4bc | PoweredSubsystem | btl4gau2 gauge wave. [T1] |
| 0x50e3ec | HeatableSubsystem | btl4gaug widget gate. [T1] | | 0x50e3ec | HeatableSubsystem | btl4gaug widget gate. [T1] |
| 0x50e604 | HeatSink | btl4gaug widget gate. [T1] | | 0x50e604 | **HeatWatcher** (task #57 CORRECTION — was "HeatSink") | `HeatWatcher::TestInstance` @4aec38 tests its OWN class vs this tag; the factory watcher-CONNECT loop (slot +0x38) selects on it; the btl4gaug widget gate ORs it after HeatableSubsystem (a HeatSink test there would be redundant — HeatSink : HeatableSubsystem). 0x50e590 = the HeatSinkBank (0xBBE) tag. [T1] |
| 0x51155c | roster at Mech this[0x1eb] (`heatableSubsystems` label) | tag ≠ 0x50e3ec — the label is UNVERIFIED [T4]; arbitrate before relying on it. | | 0x51155c | roster at Mech this[0x1eb] (`heatableSubsystems` label) | tag ≠ 0x50e3ec — the label is UNVERIFIED [T4]; arbitrate before relying on it. |
| 0x50e4fc | roster at Mech this[499] (`damageableSubsystems` label) | [T3] | | 0x50e4fc | roster at Mech this[499] (`damageableSubsystems` label) | [T3] |
| 0x50bdb4 | Mech | emitter.cpp destroyed-check. [T1] | | 0x50bdb4 | Mech | emitter.cpp destroyed-check. [T1] |
| 0x5121a8 | ProjectileWeapon-family (checked after 0x511830; reads ammo state @+0x364) | [T3] | | 0x5121a8 | ProjectileWeapon-family (checked after 0x511830; reads ammo state @+0x364) | [T3] |
### Watcher electrical chain (task #57 — all [T1], several from RAW-BYTE recovery)
| Address | What |
|---|---|
| @004aec54 | `HeatWatcher::CreateStreamedSubsystem` — parses `WatchedSubsystem=<name>` → segment index **+2** at resource+0xE4 |
| @004aee2c / @004b1a40 | vtable slot 14 (+0x38) CONNECT bodies (HeatWatcher / PowerWatcher+Torso, byte-identical): master-gated `watchedLink(+0x114).Add(owner->roster[watchedSubsystem(+0x128)])`. **Ghidra missed both starts** — recovered from raw exe bytes (vtables.tsv rows have GAPS at skipped slots; dump vtable+slot*4 bytes when a slot looks absent) |
| @004b181c | `PowerWatcher::UpdateWatch` — THE registered Performance (PTR @0050f5fc); watchdogAlarm(+0x184) MIRRORS watched subsystem's electrical level (+0x278); brownout→1 when `gen outputVoltage(+0x1DC) <= minVoltage(+0x180) × ratedVoltage(+0x1D8)`. Torso sims call it first-line |
| @004b1804 | `PowerWatcher::ResetToInitialState` (slot 10) — old recon MISLABELED it "Simulation" |
| 0x4b1924 | `MinVoltageScale` — a **10-byte x87 literal** (`0a d7 a3 70 3d 0a d7 a3 f8 3f` = **0.01**); the port had 1.0f → permanent brownout |
| @004b6a78 | `Torso::ReadUpdateRecord` (slot 6; base 41bd34) — REPLICANT apply of twist extras +0x10/14/18. Old recon mislabeled it Write |
| @004b6a1c | `Torso::WriteUpdateRecord` (slot 7; base 41c500) — MASTER serialize; recordLength=0x1C; **Ghidra missed the start** |
| 41bd34 / 41c500 | base Subsystem Read (apply) / Write (produce) update-record fns — engine [T0]: **Write=produce, Read=apply**; don't flip |
--- ---
## 3. Mech struct offsets (`Mech*`, binary layout) ## 3. Mech struct offsets (`Mech*`, binary layout)
+10 -3
View File
@@ -146,7 +146,8 @@ queue. `SeekVoltageGraph`'s 4 Seek* attrs are a cluster-child (not config-called
view only, via the recovered **dpl2d** 2D display-list API (recorders @0x487f34-0x488630; opcode view only, via the recovered **dpl2d** 2D display-list API (recorders @0x487f34-0x488630; opcode
map + coordinate model in `phases/phase-02-dpl2d-reticle.md`; port: `game/reconstructed/dpl2d.cpp`). map + coordinate model in `phases/phase-02-dpl2d-reticle.md`; port: `game/reconstructed/dpl2d.cpp`).
Geometry is the ctor's hardcoded calibration (originX 0.35, originY 0.25, scaleY 0.5, right range Geometry is the ctor's hardcoded calibration (originX 0.35, originY 0.25, scaleY 0.5, right range
ladder 0..1200 m, bottom heading tape, center cross + dot; tick ladders via FUN_004cd938). The ladder 0..1200 m, bottom TORSO-TWIST tape (NOT a heading tape — stale wording swept task #58; see
the tape entry below), center cross + dot; tick ladders via FUN_004cd938). The
range caret binds to the live target range (`BTSetHudTargetRange`, fed by mech4's targeting step). range caret binds to the live target range (`BTSetHudTargetRange`, fed by mech4's targeting step).
**Weapon pips:** the build loop (part_014.c:5386) registers EVERY subsystem **Weapon pips:** the build loop (part_014.c:5386) registers EVERY subsystem
`IsDerivedFrom(0x511830 = MechWeapon)` — lasers, PPCs AND missile launchers (BLH = 7 pips) — via `IsDerivedFrom(0x511830 = MechWeapon)` — lasers, PPCs AND missile launchers (BLH = 7 pips) — via
@@ -196,8 +197,14 @@ and every instrument is now live [T2]:**
around the hotbox point, +1/11.5 vertical (K=2.8145 baked projection; the port uses the live around the hotbox point, +1/11.5 vertical (K=2.8145 baked projection; the port uses the live
per-axis projection) — switching to the left/right edge ARROW past ±1.6 or behind. per-axis projection) — switching to the left/right edge ARROW past ±1.6 or behind.
- Reticle state Off/On + `PrimaryHudOn` (mask 0x20) picks full HUD vs the "simple X" list; - Reticle state Off/On + `PrimaryHudOn` (mask 0x20) picks full HUD vs the "simple X" list;
the aim group translates by `reticlePosition` = the TORSO BORESIGHT (SetMatrix; NOT a free the aim group SetMatrix-translates by `Reticle::reticlePosition` (screen 1..+1 [T0]) every
cursor — see [[combat-damage]] Targeting: the stick twists the torso, no mouse-aim exists). frame — but **NOT by torso twist** (task #58 CORRECTION): the VIEW is torso-mounted (the eye
hangs off jointtorso → jointeye → siteeyepoint), so the crosshair stays SCREEN-CENTERED through
a twist — screen center IS the boresight; the twist reads on the bottom tape/compass/radar
wedge instead. The reticlePosition writer is un-exported (one xref binary-wide: the read-side
lookup part_014.c:5132); its coherent use is the FIXED-torso free-aim channel (mech+0x36c
[T4]). The old "translates to the torso boresight" wording here was the falsified
body-mounted-view model — see [[combat-damage]] Targeting for the full re-correction.
This recovery also CONFIRMS the HUD attr-table ids 4/5/6/8/0xA/0xB/0xC/0xD name↔use pairings This recovery also CONFIRMS the HUD attr-table ids 4/5/6/8/0xA/0xB/0xC/0xD name↔use pairings
(hud.hpp had flagged them uncertain). Deferred: PNAME1-8.bgf 3D marker chain. (The canopy shell (hud.hpp had flagged them uncertain). Deferred: PNAME1-8.bgf 3D marker chain. (The canopy shell
is now authentic and shows by default — see [[cockpit-view]]; `BT_HIDE_COCKPIT=1` hides it.) is now authentic and shows by default — see [[cockpit-view]]; `BT_HIDE_COCKPIT=1` hides it.)
+15
View File
@@ -22,6 +22,21 @@ engine `AnimationInstance::Animate(dt, move_joints)` integrates `movement += Δt
moves the body that far → feet plant by construction (no stride constant; k=1). Idle anims have moves the body that far → feet plant by construction (no stride constant; k=1). Idle anims have
z=0. The `.MOD` physics governed thrust VEHICLES, NOT walk gait. [T1] z=0. The `.MOD` physics governed thrust VEHICLES, NOT walk gait. [T1]
## The MARCHING-GHOST desync — ROOT-CAUSED + FIXED (2026-07-13, live diagnosis) [T2]
The long-parked "replicant walks in place while its master stands still" reproduced live with
telemetry running: the master's `[gaitSM]` showed `state=0 cycleSpeed=-2.50725 kfCur=19` — BOTH
channels stuck at a REVERSE cadence inside Standing. Mechanism: the walk-family stop gate
(`cycleSpeed <= ZeroSpeed`, mech2.cpp) passes trivially for NEGATIVE (reverse) cadences, and
several stand-entry paths (turn exit, terminal poses) never touch the cycle — so a reverse-stop
enters Standing at full reverse cycle speed. The master looks still (case-0 never advances the
clip; `kfCur` freezes) but the stale cycle REPLICATES, and the peer's replicant animates it =
the marching ghost. A clean FORWARD stop decays the cycle first (`legSum ~3e-8` on the healthy
node) — which is why the bug was intermittent and direction-dependent. FIX: all four
Advance*Animation Standing cases zero their channel's cycle on entry + ForceUpdate(8) so the
peer hears the stop immediately. (The signed-demand change (task #15) is what let negative
cycles reach machinery written for forward decel.) The earlier "bob reduction + stutter"
symptom family likely shares this root — re-observe after this fix before chasing separately.
## The two-channel gait (real controls) ## The two-channel gait (real controls)
The BT `SequenceController` (`seqctl.cpp`, from `SelectSequence@004277a8`+`Advance@0042790c`) drives The BT `SequenceController` (`seqctl.cpp`, from `SelectSequence@004277a8`+`Advance@0042790c`) drives
locomotion. Under `BT_REAL_CONTROLS` two channels run — **roles corrected 2026-07-09 (task #49) from locomotion. Under `BT_REAL_CONTROLS` two channels run — **roles corrected 2026-07-09 (task #49) from
+24
View File
@@ -237,6 +237,30 @@ engine/port member. Wire sizes verified live (0x14/0x20/0x2c/0x78). Key mechan
A TU-local stub type forced the `BTMapperSpeedDemandRaw(void*)` bridge (mech.cpp carries a local A TU-local stub type forced the `BTMapperSpeedDemandRaw(void*)` bridge (mech.cpp carries a local
`struct MechControlsMapper` recon stub — typed cross-TU signatures mangle differently). `struct MechControlsMapper` recon stub — typed cross-TU signatures mangle differently).
## Subsystem update-record DIRECTION + the Torso pair (task #57, 2026-07-13) [T0/T1]
**Engine semantics [T0 SIMULATE.cpp:270-297]: `WriteUpdateRecord(record, model)` PRODUCES the
outgoing record from the object (master serialize; base sets `recordLength=sizeof`, stream
advances by `recordLength` — variable-length records are the mechanism); `ReadUpdateRecord(record)`
APPLIES the record into the object (replicant).** The names read backwards — do not flip them.
Base fns in every subsystem vtable: slot 6 = 41bd34 (Read/apply), slot 7 = 41c500 (Write/produce,
3 args).
- **Torso pair:** slot 6 `@004b6a78` = `ReadUpdateRecord` (apply: lastUpdateTime clock stamp +
twistAtUpdate/twistVelocity/twistRate ← record +0x10/14/18). Slot 7 `@004b6a1c` =
`WriteUpdateRecord`**Ghidra missed the function start**; raw-disasm recovery: chains base,
sets `recordLength=0x1C`, writes currentTwist/twistVelocity/twistRate → +0x10/14/18, snapshots
`twistAtUpdate = currentTwist`.
- **The bug this fixed:** the old recon had the APPLY body on the WRITE virtual (and no Write at
all) — the master consumed its own uninitialized stream buffer (0xCDCDCDCD → twistAtUpdate =
4.316e8) and never serialized twist; the replicant MadCat pinned at 140° (the "torso ghost
twist"). With the pair correct the replicant tracks `targetTwist = twistAtUpdate +
twistRate·elapsed` cleanly (cur=0 when master still). [T2 live]
- **Residual (authentic):** the initial full-state snapshot record does NOT carry the torso
extras; the replicant's blind +0x10 read can pick up 0xCD fill ONCE at spawn. The binary does
the same read; the twist clamp contains it (on the fixed-torso BLH it pins to ±0.01° —
invisible). First real twist record overwrites it.
## Remaining (P6 phase 4 / Phase 7) ## Remaining (P6 phase 4 / Phase 7)
The pod-LAN config (real IPs, bare-IP pilot entries); update-record velocity sourced from the body-channel The pod-LAN config (real IPs, bare-IP pilot entries); update-record velocity sourced from the body-channel
projection (above). See [[open-questions]]. [T3] projection (above). See [[open-questions]]. [T3]
+36
View File
@@ -21,7 +21,43 @@ authentic path scoped.
- Pod specifics for Phase 8: the 7-monitor driver setup, the RIO cockpit I/O protocol, current - Pod specifics for Phase 8: the 7-monitor driver setup, the RIO cockpit I/O protocol, current
Win10+wrapper pod config. Status: OPEN. Win10+wrapper pod config. Status: OPEN.
## BINARY-COVERAGE AUDIT discoveries (2026-07-13, 6-agent decomp census) [T1 verified-uncited]
New unaccounted functionality no prior list knew (addresses verified absent from game/+context/+docs):
- **`BTL4VideoRenderer::StartEntityEffectImplementation` @004d097c** (+helpers @4d0c14/@4d0c5c,
~93 ln) — the per-zone EFFECT DISPATCHER, vtable-dispatched from the engine's
StartEntityEffectMessageHandler (hence invisible to address-grep). Body fully mapped: zone
position (+0x74), video index (+0xc4→+0xc0), jointToDCS socket (+0x3a4, in CLASSMAP),
SearchList type 10, atoi-dispatch into the SAME effect-number bands the port already serves
(>=1000 psfx ✓, 3-15 specialfx ✓, 104 SwapToWreck ✓). **The whole per-zone damage/destruction
visual chain is dead without it. RECOMMENDED NEXT TARGET (S/M).**
- **Six L4VIDRND effect-renderable bodies** @4540ac/45447c/455eb8/456cf0/4589e0/458e5c (~576 ln)
incl. the 40-param ScalingExplosionRenderable ctor ExplosionScripts calls 6× — the live 1995
bodies behind the STUBBED markers (wreck flames/booms land into these).
- **FUN_00472480** (273 ln) — unnamed analog→discrete stepper Execute in the gauge band
(hysteresis + key-repeat), no caller found; the one hole in "gauge system complete".
Settle via vtable lookup (plausibly a config/roster scroller).
- **FUN_00454a70** (193 ln) — the PNAME1-8/PLACE1-8.bgf MP name-billboard loader (the known
"MP target identification" gap's exact function).
- **Explosion::SplashDamage @0042fad0** — substantial, unreferenced, directly in the combat
path; one-time diff vs engine EXPLODE.cpp would rule out 1995-vs-WinTesla falloff drift.
- **The binary's own VelociRender BGF/BMF/BSL/VTX loader (~4,550 ln, dead code)** — a free
ground-truth cross-check for [[bgf-format]]/[[asset-formats]] claims.
Full ranked audit: the workflow output (2026-07-13); top-10 ranking cross-checked against this
register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTORY in the KB
(task #8 "landed" vs the kShotDamage=12 bring-up residue) — audit before reworking.
## Deferred subsystems / feeds (authentic path scoped, marked in code) ## Deferred subsystems / feeds (authentic path scoped, marked in code)
- **Factory capability-roster loops 2-4 are STILL DEAD (task #57 discovery).** mech.cpp's
post-roster loops add to `heatableSubsystems`(0x51155c)/`weaponRoster`(0x511830)/
`damageableSubsystems`(0x50e4fc) through the local `SubProxy` stub whose `IsDerivedFrom`
RETURNS 0 — so those three mech-level rosters stay EMPTY (whatever consumes them sees
nothing; weapons demonstrably work through other paths, so audit consumers before assuming
impact). Loop 1 (the watcher CONNECT, 0x50e604) was reconstructed for real in task #57
use the same bridge pattern (family-side derivation test + typed body) for the other three. [T2]
- **Initial-snapshot torso record carries no twist extras**: the replicant's blind +0x10 read
can latch 0xCDCDCDCD ONCE at spawn until the first real twist record; clamps contain it
(invisible on fixed-torso mechs). Binary-authentic blind read — fix only if a visibly wrong
spawn pose ever surfaces. See [[multiplayer]]. [T2]
- **✅ 0xBD3 SubsystemMessageManager — UNTANGLED + LIVE (task #7, 2026-07-11) [T1/T2].** - **✅ 0xBD3 SubsystemMessageManager — UNTANGLED + LIVE (task #7, 2026-07-11) [T1/T2].**
Both halves landed: (1) the factory case builds the REAL messmgr (ctor @0049bca4, 0x130, Both halves landed: (1) the factory case builds the REAL messmgr (ctor @0049bca4, 0x130,
static reconstruction messmgr.cpp) cached at `mech+0x434` — the binary-wide census found static reconstruction messmgr.cpp) cached at `mech+0x434` — the binary-wide census found
+29
View File
@@ -55,6 +55,35 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
an untangle. NOT the valve/advanced-damage gate — that's the owning **BTPlayer** @mech+0x190, see an untangle. NOT the valve/advanced-damage gate — that's the owning **BTPlayer** @mech+0x190, see
[[combat-damage]]/[[open-questions]]). [T2] [[combat-damage]]/[[open-questions]]). [T2]
## The watcher electrical chain (task #57, 2026-07-13 — the torso power gate)
The Watcher branch is POWERED indirectly: a watcher WATCHES another roster subsystem and mirrors
its electrical state. The full chain, byte-verified [T1]:
- **Data:** the model entry `WatchedSubsystem=<name>` → segment index **+2** at resource+0xE4
(`HeatWatcher::CreateStreamedSubsystem` @004aec54); the ctor @004aeb40 stores it at
watcher+0x128 (`watchedSubsystem`).
- **Bind (factory post-roster loop 1):** vtable slot 14 (+0x38) — `@004aee2c` (HeatWatcher) /
`@004b1a40` (PowerWatcher/Torso override, byte-identical); **Ghidra missed both function
starts** (recovered from raw bytes; the vtables.tsv rows have GAPS where the exporter skipped
slots — dump the exe bytes at vtable+slot*4 when a slot looks missing). Master-gated
(`(owner->simulationFlags & 0xC)==0 && (flags & 0x100)`); binds `watchedLink(+0x114).Add(
owner->roster[+0x128][watchedSubsystem])`. Port: `BTWatcherWatchedIndex`/`BTWatcherBindTarget`
bridges (heatfamily_reslice.cpp) called from the mech.cpp factory loop.
- **Tick:** `@004b181c` = the REAL `PowerWatcher` Performance (PTR @0050f5fc → 004b181c) =
`UpdateWatch()`: heat mirror (FUN_004aeac4) + `watchdogAlarm.SetLevel(watched->electrical
level @+0x278)` + brownout downgrade to 1 when `gen->outputVoltage(+0x1DC) <= minVoltage(+0x180)
× gen->ratedVoltage(+0x1D8)`. The Torso sims (@004b5cf0/@004b65f8) call it first-line.
(`@004b1804` is slot-10 **ResetToInitialState**, NOT the Simulation — old recon mislabel, fixed.)
- **MinVoltageScale = 0.01** — a 10-byte x87 literal at 0x4b1924 (`0a d7 a3 70 3d 0a d7 a3 f8 3f`);
the port had 1.0f, making minVoltage 100× too big → the brownout latched every watchdog at 1.
- **PowerWatcher::GetClassDerivations chains HeatWatcher** (real base) — the old HeatableSubsystem
stand-in broke `IsDerivedFrom(HeatWatcher)` for Torso/Searchlight/ThermalSight and silently
skipped them in the connect pass.
- **Effect:** `Torso::ElectricalStateLevel()==Ready(4)` un-gates `effectiveTwistRate` — the MadCat
torso twists at its authored 50°/s (±140° limits, roster 17 watching 15 → generator @10000V);
the BLH is authentically fixed (`horizontalEnabled=0`, limits ±0.01°). [T2 live-verified]
- **STILL DEAD:** factory loops 2-4 (heatable/weapon/damageable capability rosters) go through the
`SubProxy` stub whose `IsDerivedFrom` returns 0 — they add NOTHING. See [[open-questions]].
## The four systemic checks (every subsystem) ## The four systemic checks (every subsystem)
See [[reconstruction-gotchas]]: (1) shadowing (re-declared engine-base fields), (2) the `Wword` trap, See [[reconstruction-gotchas]]: (1) shadowing (re-declared engine-base fields), (2) the `Wword` trap,
(3) message-handler chaining, (4) entity validity. Plus resource-struct layout (must mirror the class (3) message-handler chaining, (4) entity validity. Plus resource-struct layout (must mirror the class
+67
View File
@@ -669,6 +669,12 @@ struct BTPfxEmitter
float emitAccum; // fractional particles owed (rate * dt integration) float emitAccum; // fractional particles owed (rate * dt integration)
int issued; int issued;
int active; int active;
// ATTACHED effects (the @004d097c per-zone dispatcher): the emitter rides
// the owner entity's SEGMENT -- zone smoke follows the walking mech, and
// StopAllEntityEffects (@004d0c14: the respawn cleanup) kills every
// emitter tagged to the entity. ownerTag==0 = a free world effect.
void *ownerTag;
int followSeg;
}; };
struct BTPfxParticle struct BTPfxParticle
{ {
@@ -798,6 +804,8 @@ void BTStartPfxFrame(int effect_number, float x, float y, float z,
e.emitAccum = 1.0f; // first particle immediately e.emitAccum = 1.0f; // first particle immediately
e.issued = 0; e.issued = 0;
e.active = 1; e.active = 1;
e.ownerTag = 0;
e.followSeg = -1;
gBTPfxEmitters.push_back(e); gBTPfxEmitters.push_back(e);
} }
@@ -806,6 +814,52 @@ void BTStartPfx(int effect_number, float x, float y, float z)
BTStartPfxFrame(effect_number, x, y, z, 0, 0, 0); BTStartPfxFrame(effect_number, x, y, z, 0, 0, 0);
} }
// ATTACHED effect start (the @004d097c per-zone dispatcher): like
// BTStartPfxFrame, but the emitter rides the owner entity's segment -- the
// per-frame sim re-resolves the segment's world transform through the
// game-side bridge below, so a damage-band smoke plume TRAILS the walking
// mech instead of hanging at the hit point. rows9 = the initial 3x3 basis
// (rows = local axes in world), pos = the segment's current world position.
extern int BTResolveSegmentWorld(void *entity, int seg_index,
float *pos3, float *rows9); // mech4.cpp (game side)
void BTStartPfxAttached(int effect_number, void *owner, int seg_index,
float x, float y, float z, const float *rows9)
{
if (effect_number < 0 || effect_number >= BT_PFX_SLOTS)
return;
const BTPfxDef &d = gBTPfxDefs[effect_number];
if (!d.valid)
return;
if (gBTPfxEmitters.size() > 256)
return;
BTPfxEmitter e;
e.def = &d;
e.pos = D3DXVECTOR3(x, y, z);
e.ax = rows9 ? D3DXVECTOR3(rows9[0], rows9[1], rows9[2]) : D3DXVECTOR3(1, 0, 0);
e.ay = rows9 ? D3DXVECTOR3(rows9[3], rows9[4], rows9[5]) : D3DXVECTOR3(0, 1, 0);
e.az = rows9 ? D3DXVECTOR3(rows9[6], rows9[7], rows9[8]) : D3DXVECTOR3(0, 0, 1);
e.emitAccum = 1.0f;
e.issued = 0;
e.active = 1;
e.ownerTag = owner;
e.followSeg = seg_index;
gBTPfxEmitters.push_back(e);
}
// StopAllEntityEffects (@004d0c14 analog): the firmware killed every effect
// instance tagged (playerIdx<<16 .. |0xffff); the port kills every emitter
// tagged to the entity (the respawn cleanup -- a respawned mech must not
// trail its corpse's zone smoke). Emitted particles fade out naturally.
void BTStopEntityPfx(void *owner)
{
if (owner == 0)
return;
for (size_t i = 0; i < gBTPfxEmitters.size(); ++i)
if (gBTPfxEmitters[i].ownerTag == owner)
gBTPfxEmitters[i].active = 0;
}
// Spawn a few particles of a slot's effect DIRECTLY at a moving point (no // Spawn a few particles of a slot's effect DIRECTLY at a moving point (no
// emitter instance) -- the per-frame projectile SMOKE TRAIL (psfx 0 = dsrm, // emitter instance) -- the per-frame projectile SMOKE TRAIL (psfx 0 = dsrm,
// "the lrm smoke trail": its velocities stream +Z = BEHIND the round, so the // "the lrm smoke trail": its velocities stream +Z = BEHIND the round, so the
@@ -832,6 +886,7 @@ void BTPfxTrailPuff(int effect_number, float x, float y, float z,
D3DXVECTOR3 ay; D3DXVec3Cross(&ay, &az, &ax); D3DXVECTOR3 ay; D3DXVec3Cross(&ay, &az, &ax);
e.ax = ax; e.ay = ay; e.az = az; e.ax = ax; e.ay = ay; e.az = az;
e.emitAccum = 0.0f; e.issued = 0; e.active = 0; e.emitAccum = 0.0f; e.issued = 0; e.active = 0;
e.ownerTag = 0; e.followSeg = -1;
for (int i = 0; i < count; ++i) for (int i = 0; i < count; ++i)
BTPfxSpawn(e); BTPfxSpawn(e);
} }
@@ -1064,6 +1119,18 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
{ {
BTPfxEmitter &e = gBTPfxEmitters[ei]; BTPfxEmitter &e = gBTPfxEmitters[ei];
if (!e.active) continue; if (!e.active) continue;
// attached emitters ride their owner's segment (@004d097c dispatcher)
if (e.ownerTag != 0)
{
float p3[3], r9[9];
if (BTResolveSegmentWorld(e.ownerTag, e.followSeg, p3, r9))
{
e.pos = D3DXVECTOR3(p3[0], p3[1], p3[2]);
e.ax = D3DXVECTOR3(r9[0], r9[1], r9[2]);
e.ay = D3DXVECTOR3(r9[3], r9[4], r9[5]);
e.az = D3DXVECTOR3(r9[6], r9[7], r9[8]);
}
}
const BTPfxDef &d = *e.def; const BTPfxDef &d = *e.def;
// CONTINUOUS emission at `rate` particles/second until maximum_issue is // CONTINUOUS emission at `rate` particles/second until maximum_issue is
// exhausted. Data-verified semantics: in EVERY shipped .PFX, // exhausted. Data-verified semantics: in EVERY shipped .PFX,
+66 -3
View File
@@ -6,6 +6,11 @@
// (Authentic path = the gyro-driven eye-joint DCS chain -- deferred.) // (Authentic path = the gyro-driven eye-joint DCS chain -- deferred.)
float gBTEyeBobY = 0.0f; float gBTEyeBobY = 0.0f;
float gBTEyeSwayX = 0.0f; // lateral weight-shift (the walk "swagger"), same source float gBTEyeSwayX = 0.0f; // lateral weight-shift (the walk "swagger"), same source
// task #58: the player torso's live twist (rad), published by mech4.cpp's HUD
// tick. NOT consumed by the eye anymore -- the cockpit eye inherits the twist
// through the jointtorso HingeRenderable in the draw traversal (see the note in
// DPLEyeRenderable::Execute). Kept published for diagnostics/correlation.
float gBTEyeTwist = 0.0f;
#pragma hdrstop #pragma hdrstop
#include "l4vidrnd.h" #include "l4vidrnd.h"
@@ -1340,6 +1345,21 @@ void
// //
if(oldHinge != *myHinge) if(oldHinge != *myHinge)
{ {
// task #58 probe (BT_HINGE_LOG): which hinge renderables actually see
// their joint move (standing + BT_FORCE_TORSO => only the torso hinge
// and any doors change). kids = child renderables that would inherit
// the rotation through the stack.
{
static const int s_hl = getenv("BT_HINGE_LOG") ? 1 : 0;
static int s_hln = 0;
if (s_hl && (s_hln++ % 60) == 0)
DEBUG_STREAM << "[hinge] this=" << (void*)this
<< " hinge=" << (void*)myHinge
<< " rot=" << (float)(Radian)(myHinge->rotationAmount)
<< " kids=" << (int)(End() - Enumerate())
<< " par=" << (void*)GetParentProbe()
<< "\n" << std::flush;
}
oldHinge = *myHinge; oldHinge = *myHinge;
hingeOffsetMatrix.BuildIdentity(); hingeOffsetMatrix.BuildIdentity();
//#if SINGLE_AXIS_HINGE //#if SINGLE_AXIS_HINGE
@@ -5604,6 +5624,24 @@ void
// that assumed +Z=forward/+Y=up (which mis-framed the cockpit); the look/up axes now // that assumed +Z=forward/+Y=up (which mis-framed the cockpit); the look/up axes now
// fall out of the eye's own basis. pos/at/up above are kept only for the aim-boresight. // fall out of the eye's own basis. pos/at/up above are kept only for the aim-boresight.
D3DXMATRIX eyeW = mat4.ToD3DMatrix(); D3DXMATRIX eyeW = mat4.ToD3DMatrix();
// task #58 NOTE (torso twist vs the camera): the COCKPIT eye needs
// NO explicit twist compose -- it executes inside the mech's draw
// traversal under jointtorso's HingeRenderable (Torso::PushTwist ->
// Joint::SetRotation -> hinge rotationAmount; HingeRenderable
// multiplies the live hinge into the matrix stack), so `prev` above
// already carries the twist and the view yaws authentically with
// the canopy. VERIFIED live: with the real jointtorso swept, the
// active cockpit [eyefwd] swings ~60 deg and the torso hinge sits
// in the eye's parent chain (kids=2). An explicit
// W' = W*E^-1*R_y(twist)*E compose was briefly added here and
// DOUBLE-ROTATED the cockpit view (2x view vs 1x canopy: "the HUD
// passes through the cockpit frame") and swung the CHASE camera
// (parented on the root, same class) with the twist ("torso fixed,
// legs twisting"). Both "eye is frozen" measurements that motivated
// it were instrumentation artifacts: (1) the chase eye was the
// active camera in the headless default view; (2) BT_FORCE_TORSO
// used to redirect the sweep into the SHADOW hinge (see the hook's
// trap note in torso.cpp).
D3DXMATRIX view; D3DXMATRIX view;
D3DXMatrixInverse(&view, NULL, &eyeW); D3DXMatrixInverse(&view, NULL, &eyeW);
@@ -5618,6 +5656,30 @@ void
D3DXMatrixInverse(&view, NULL, &eyeW); D3DXMatrixInverse(&view, NULL, &eyeW);
} }
} }
// TWIST-FOLLOW PROBE (task #58, BT_EYE_LOG): does the ACTIVE eye's
// forward yaw with the torso twist? Correlate with [torso] twist.
// Also print the STACK TOP the eye composed with (prev) -- its
// translation tells whether the eye executed inside the full
// parent-chain traversal (t ~= eye rest position) or with a bare
// stack (t == entity origin / zero).
{
static const int s_eyeLog = getenv("BT_EYE_LOG") ? 1 : 0;
static int s_eyeLogN = 0;
if (s_eyeLog && myRenderer->mCamera == this && (s_eyeLogN++ % 120) == 0)
{
DEBUG_STREAM << "[eyefwd] this=" << (void*)this
<< " fwd=(" << eyeFwdW.x << "," << eyeFwdW.y
<< "," << eyeFwdW.z << ") stackT=(" << prev(3,0) << ","
<< prev(3,1) << "," << prev(3,2) << ") chain:";
HierarchicalDrawComponent *p = GetParentProbe();
for (int pc = 0; p != NULL && pc < 8; ++pc)
{
DEBUG_STREAM << " " << (void*)p;
p = p->GetParentProbe();
}
DEBUG_STREAM << "\n" << std::flush;
}
}
if (dbgEyeExec < 8) if (dbgEyeExec < 8)
{ {
DEBUG_STREAM << "[EYECHK] eyeW=(" << eyeW._41 << "," << eyeW._42 << "," << eyeW._43 DEBUG_STREAM << "[EYECHK] eyeW=(" << eyeW._41 << "," << eyeW._42 << "," << eyeW._43
@@ -5650,9 +5712,10 @@ void
// on a slope (body pitched ~8 deg to conform to the ground) aim // on a slope (body pitched ~8 deg to conform to the ground) aim
// its pick ray INTO the ground, short of a distant target // its pick ray INTO the ground, short of a distant target
// (mechPicks=0). Level the boresight: drop the view direction's // (mechPicks=0). Level the boresight: drop the view direction's
// pitch and rebuild an upright basis (world +Y up). The reticle // pitch and rebuild an upright basis (world +Y up). The torso
// X still carries the torso twist (BTTwistToReticleX); the reticle // twist rides IN eyeFwdW now (task #58 eye compose above) --
// Y carries any aim elevation. Falls back to the raw basis only // yaw survives the leveling, so the centered crosshair's pick
// ray follows the twist. Falls back to the raw basis only
// if the view is (degenerately) near-vertical. // if the view is (degenerately) near-vertical.
// Derive the boresight from the AUTHORITATIVE view matrix // Derive the boresight from the AUTHORITATIVE view matrix
// (eyeFwdW = the world look direction from the inverse view), // (eyeFwdW = the world look direction from the inverse view),
+3
View File
@@ -69,6 +69,9 @@ public:
virtual bool IsStatic() { return false; } virtual bool IsStatic() { return false; }
// task #58 diagnostics: expose the parent link so probes can walk the tree.
HierarchicalDrawComponent *GetParentProbe() const { return m_parent; }
d3d_OBJECT *GetDrawObj() { return this->graphicalObject; } d3d_OBJECT *GetDrawObj() { return this->graphicalObject; }
// Swap this component's drawable in place. Execute() re-reads graphicalObject // Swap this component's drawable in place. Execute() re-reads graphicalObject
// every frame, so changing it makes the segment draw a different mesh next // every frame, so changing it makes the segment draw a different mesh next
+14 -5
View File
@@ -52,7 +52,8 @@
// FUN_0044a5b4/5dc GraphicsViewRecord ctor/dtor // FUN_0044a5b4/5dc GraphicsViewRecord ctor/dtor
// FUN_0044a650/630 GraphicsViewRecord Erase / Reset // FUN_0044a650/630 GraphicsViewRecord Erase / Reset
// DAT_00524e20 DebugStream (warning channel) // 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): // GraphicsView vtable slots used below (this+0x48):
// +0x08 SetExtent +0x10 SetOrigin +0x18 SetColor +0x24 MoveTo // +0x08 SetExtent +0x10 SetOrigin +0x18 SetColor +0x24 MoveTo
@@ -363,9 +364,12 @@ void
// //
// HeatConnection -- @004c3664 ctor / @004c3720 Transfer. // HeatConnection -- @004c3664 ctor / @004c3720 Transfer.
// ctor: source = the heat subsystem; flag@0x14 records whether it is the // ctor: source = the heat subsystem; flag@0x14 records whether it is the
// "primary" heat class (IsDerivedFrom 0x50e3ec) vs. the alternate (0x50e604); // "primary" heat class (IsDerivedFrom 0x50e3ec HeatableSubsystem) vs. the
// destination@0x18. Transfer: no subsystem -> 100 ; subsystem operational // alternate (0x50e604 = HEATWATCHER, task #57 relabel); destination@0x18.
// (+0x40 == 1) -> 100 ; else Round( Resolve(subsys+0x114 currentTemperature) ). // 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. // Used by ColorMapperHeat -- THIS is the heat gauge's data feed.
// //
// All Transfer bodies are reproduced inside the owning gauge's notes; the // 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 // The subsystem MUST be heat-bearing (one of the two heat class
// derivation tables) or the data feed is meaningless. // 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) 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 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. // ColorMapperHeat -- the cockpit heat tint. ANCHOR @004c3f6c.
// Connection: @004c3664 (reads HeatableSubsystem::currentTemperature@0x114 // Connection: @004c3664 (reads HeatableSubsystem::currentTemperature@0x114
// as a 0..100 percentage). The named subsystem must derive from a heat // 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 : class ColorMapperHeat :
public ColorMapper public ColorMapper
+129
View File
@@ -49,6 +49,9 @@
#if !defined(MECHWEAP_HPP) #if !defined(MECHWEAP_HPP)
# include <mechweap.hpp> // MechWeapon::GetClassDerivations (reticle pip) # include <mechweap.hpp> // MechWeapon::GetClassDerivations (reticle pip)
#endif #endif
#if !defined(MECHDMG_HPP)
# include <mechdmg.hpp> // Mech__DamageZone::segmentIndex (the @004d097c dispatcher)
#endif
#if !defined(NOTATION_HPP) #if !defined(NOTATION_HPP)
# include <notation.hpp> # include <notation.hpp>
#endif #endif
@@ -368,6 +371,22 @@ HierarchicalDrawComponent*
LinearMatrix offset_matrix; LinearMatrix offset_matrix;
offset_matrix = segment->GetBaseOffset(); // [0x74] 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 // Parent renderable: root if the segment has no parent, else the
// renderable already built for its parent segment. // 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) // SwapToWreck (ExplosionScripts effect 104, reconstructed)
//############################################################################# //#############################################################################
+20
View File
@@ -623,6 +623,26 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
Logical Logical
TestInstance() const; 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). // Material substitution (mirrors RPL4VideoRenderer).
// //
+29 -11
View File
@@ -1494,12 +1494,31 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat
{ {
return; return;
} }
BTPlayer *local_player = (BTPlayer *)application->GetMissionPlayer(); // OBSERVED-KILL credit (MP KILLS fix, 2026-07-13; the symmetric twin of
if (local_player != 0) // 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 Entity *killer = application->GetHostManager()->GetEntityPointer(
// the local player is credited via `this->killCount++` -- if it were our own ((Mech *)victim)->lastInflictingID);
// mech, ScoreMessageHandler takes the suicide branch (award negated, no kill). 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( BTPlayer::ScoreMessage kill(
Player::ScoreMessageID, // 0x12 Player::ScoreMessageID, // 0x12
sizeof(BTPlayer::ScoreMessage), 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) 0.0f, // scoreAward (killBonus; 0 for bring-up)
damage, // damageAmount (killing-blow) damage, // damageAmount (killing-blow)
victim->GetEntityID()); // senderMechID = victim victim->GetEntityID()); // senderMechID = victim
local_player->Dispatch(&kill); killer_player->Dispatch(&kill);
if (getenv("BT_SCORE_LOG")) if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] *** KILL *** localPlayer=" << (void *)local_player DEBUG_STREAM << "[score] *** KILL *** killerPlayer=" << (void *)killer_player
<< " killCount=" << local_player->GetKillCount() << " killCount=" << killer_player->GetKillCount()
<< " deaths=" << local_player->GetDeaths() << " deaths=" << killer_player->GetDeaths()
<< " score=" << (Scalar)local_player->GetScore() << " score=" << (Scalar)killer_player->GetScore() << std::endl;
<< " rank=" << local_player->GetRanking() << "\n" << std::flush;
} }
// MP DEATH: credit a death to the VICTIM's own player. NULL for the solo // 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) HeatableSubsystem *watched = (HeatableSubsystem *)watchedLink.Resolve(); // FUN_00417ab4(this+0x114)
// BRING-UP GUARD (marked deviation): in the shipped game watchedLink ALWAYS // The watch link is now BOUND by the authentic factory connect pass (task
// resolves -- the HeatWatcher resource's "WatchedSubsystem" name binds to a // #57: the mech factory's post-roster loop calls the binary's vtable slot
// roster segment at load -- so the binary (FUN_004aeac4) derefs `watched` // +0x38 -- FUN_004aee2c / FUN_004b1a40 -> BTWatcherBindTarget below), so
// unconditionally. Our port does not yet resolve the watch link, so Resolve() // Resolve() succeeds on every master-node watcher whose resource named a
// returns null; the deref AVs the moment a HeatWatcher-derived subsystem's // WatchedSubsystem. The binary derefs `watched` unconditionally; the null
// Performance actually runs (exposed by the WAVE 4 un-stub: AmmoBinSimulation // guard stays for replicant nodes (the binary never binds there either --
// calls this directly; PowerWatcher::Simulation for Searchlight/ThermalSight). // the +0x38 body is master-gated -- and their watcher Performances don't
// With no resolved source, hold the heat alarm at Normal (same established // run) and for bring-up safety.
// pattern as Emitter::TrackSeekVoltage's null-source guard). FAITHFUL
// FOLLOW-UP = resolve watchedLink so the alarm tracks the real temperature.
if (watched == 0) if (watched == 0)
{ {
heatAlarm.SetLevel(0); // NormalHeat (no watched source yet) 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 // @4aec54 (468 bytes) -- parse the HeatWatcher resource. Stamps classID 0x0BBF
// / size 0xF0; defaults the watched index to -1; requires DegradationTemperature, // / 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. // 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]; extern int BTWatcherWatchedIndex(Subsystem *sub); // heatfamily_reslice.cpp
if (s != 0 && s->IsDerivedFrom(0x50e604)) // FUN_0041a1a4 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 for (int id = 2; id < subsystemCount; ++id) // heatable roster
+56
View File
@@ -570,6 +570,20 @@ Scalar
switch (legAnimationState) // this+0x3b0 switch (legAnimationState) // this+0x3b0
{ {
case StandingAnimation: // 0 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 -> // RAW (part_012.c FUN_004a5028 case 0): standSpeed < commandedSpeed ->
// begin WALKING (state 5); 0 <= commanded < standSpeed -> stay standing; // begin WALKING (state 5); 0 <= commanded < standSpeed -> stay standing;
// commanded < 0 -> stand-to-reverse (0x10). (The earlier draft had the // commanded < 0 -> stand-to-reverse (0x10). (The earlier draft had the
@@ -823,6 +837,20 @@ Scalar
switch (bodyAnimationState) // this+0x728 switch (bodyAnimationState) // this+0x728
{ {
case StandingAnimation: // 0 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 // RAW (FUN_004a5678 case 0): standSpeed < bodyTargetSpeed -> begin WALKING
// (5); 0 <= target < standSpeed -> stay standing; target < 0 -> reverse // (5); 0 <= target < standSpeed -> stay standing; target < 0 -> reverse
// (0x10). (The earlier draft had the comparison INVERTED.) // (0x10). (The earlier draft had the comparison INVERTED.)
@@ -979,6 +1007,20 @@ Scalar
switch (bodyAnimationState) switch (bodyAnimationState)
{ {
case StandingAnimation: // 0 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 if (bodyTargetSpeed <= standSpeed) // 0x6b4 <= 0x530
{ {
distance = 0.0f; distance = 0.0f;
@@ -1166,6 +1208,20 @@ Scalar
switch (legAnimationState) switch (legAnimationState)
{ {
case StandingAnimation: // 0 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 if (motionSource->commandedSpeed <= standSpeed) // +0x128 <= 0x530
{ {
distance = 0.0f; 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 gBTPPCKey = 0;
static int gBTMissileKey = 0; static int gBTMissileKey = 0;
static int gBTPinkyKey = 0; // key '4' = the pod's 4th fire button (Pinky 0x45) 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 gBTConfigKey = 0; // task #6: HOLD 'G' = the weapon-configure button
static int gBTGenSelKey = 0; // task #12: F5..F8 = SelectGeneratorA..D, F9 = mode toggle static int gBTGenSelKey = 0; // task #12: F5..F8 = SelectGeneratorA..D, F9 = mode toggle
// (0 idle; else the message id 4..8) // (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); 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 // PER-ROUND DETONATION (the binary's Missile::MoveAndCollide @004bef78: every
// round spawns ITS OWN ExplosionModelFile at its impact point, resource // round spawns ITS OWN ExplosionModelFile at its impact point, resource
@@ -1299,6 +1365,11 @@ void
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f); frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f);
ramLastVictim = 0; ramLastVictim = 0;
ramContactLinger = 0.0f; 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 // --- our RELOCATED gait/motion accumulators -> identity (the 1995 offsets
// the decomp zeroes map to these named members in our layout) --- // 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 // task #13: 'C' cycles the coolant valve (BT_VALVE_SLOT
// picks the condenser roster slot; default = Condenser1). // picks the condenser roster slot; default = Condenser1).
gBTValveKey = focused && (pAsync('C') & dn) ? 1 : 0; 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 // gBTDrive.fire = "any weapon trigger down" (feeds the bring-up
// damage dispatcher + the beam-visual keepalive) // damage dispatcher + the beam-visual keepalive)
gBTDrive.fire = (gBTLaserKey || gBTPPCKey || gBTMissileKey || gBTPinkyKey) ? 1 : 0; gBTDrive.fire = (gBTLaserKey || gBTPPCKey || gBTMissileKey || gBTPinkyKey) ? 1 : 0;
@@ -1842,18 +1963,24 @@ void
} }
sPrevV = vNow; sPrevV = vNow;
// RETICLE = TORSO BORESIGHT (task #39 correction): the pod had // RETICLE = SCREEN CENTER (task #58 correction, supersedes the
// NO free-aim cursor -- the binary's HudSimulation computes // task #39 "body-mounted view" model): the CAMERA yaws with
// reticlePosition from the mech's POSE quaternions // the torso (the cockpit sits on jointtorso -- see the
// (part_013.c:5652+, a rate-limited quat->euler of the torso // gBTEyeTwist publisher), so screen center always IS the gun
// aim), i.e. the crosshair marks where the TORSO GUNS point // boresight and the crosshair stays centered while the WORLD
// relative to the view, and you aim by steering the mech / // rotates past it -- the twist reads on the bottom tape
// twisting the torso (the pod's right stick). The earlier // carets / compass / radar wedge, not the crosshair. [T1:
// mouse-cursor slew was a mis-sourced stand-in and is // jointtorso->jointeye->siteeyepoint chain + FUN_004c22c4
// REMOVED. Our cockpit view is body-mounted, so the // inverse-chain view; crosshair-twist forensics 2026-07-13.]
// crosshair deflection = the torso twist projected to screen // The old gBTAimX = tan(twist) slew was a port invention on
// -- identically ZERO on the fixed-torso BLH (dead-centre // the falsified body-mounted premise -- with the yawing eye
// boresight). BT_AIM="x y" remains as the headless harness. // 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 int sAimEnv = -1;
static float sAimEnvX = 0.0f, sAimEnvY = 0.0f; static float sAimEnvX = 0.0f, sAimEnvY = 0.0f;
@@ -1870,10 +1997,10 @@ void
} }
else else
{ {
// boresight = torso twist vs the body-mounted view, // Boresight = screen center: the eye carries the
// projected through the live per-axis projection. // twist (task #58), the pick ray inherits it from
extern float BTTwistToReticleX(float twist_rad); // the published eye basis.
gBTAimX = BTTwistToReticleX(gBTHudTwist); // BLH: 0 gBTAimX = 0.0f;
gBTAimY = 0.0f; gBTAimY = 0.0f;
} }
} }
@@ -3299,6 +3426,15 @@ void
gBTHudTwistLimit = (float)GetHorizontalFiringReach();// HorizontalTorsoLimit (attrs 5/6) gBTHudTwistLimit = (float)GetHorizontalFiringReach();// HorizontalTorsoLimit (attrs 5/6)
gBTHudGroupMask = (int)targetReticle.reticleElementMask & 0xF; gBTHudGroupMask = (int)targetReticle.reticleElementMask & 0xF;
gBTHudPrimary = ((int)targetReticle.reticleElementMask & 0x20) != 0; 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 // 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) if (d != 0)
{ {
BTSpawnDamageEffect(owner, d->effectResource, // explosion AT the zone's // THE AUTHENTIC CHAIN (@004d097c dispatcher, landed 2026-07-13):
zone->segmentIndex); // segment (world position) // 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) zone->ApplyDamageGraphicState(d->graphicState); // destroyed skin (graphic state)
// A graphic-state change means the segment's MODEL changed (intact -> // A graphic-state change means the segment's MODEL changed (intact ->
// destroyed variant, keyed by GetVideoObjectName(skl, gstate)). Fire // destroyed variant, keyed by GetVideoObjectName(skl, gstate)). Fire
+6
View File
@@ -211,6 +211,12 @@
int int
segmentIndex; // @0x194 GetSegmentIndex() result 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 // Damage Support
// //
+80 -16
View File
@@ -440,6 +440,18 @@ void
Check(message); Check(message);
if (message->dataContents > 0) 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); controlMode = (ControlMode)(controlMode + 1);
if (controlMode > VeteranMode) if (controlMode > VeteranMode)
@@ -448,23 +460,28 @@ void
} }
NotifyOfControlModeChange(controlMode); // vtable+0x48 NotifyOfControlModeChange(controlMode); // vtable+0x48
Mech *mech = GetMech(); // TYPED torso reconfiguration (2026-07-13): the raw block this
int torso = *(int *)((int)mech + 0x438); // replaces wrote the BINARY's offsets (torso+0x1f0/0x274/0x220...)
int cockpit = *(int *)((int)mech + 0x5b4); // straight onto OUR compiled Torso -- the databinding trap: garbage
// writes into whatever members live there in this build. The
if (controlMode == BasicMode) // 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 if (controlMode == BasicMode)
*(LWord *)(torso + 0x274) = 1; // auto-center on {
*(LWord *)(torso + 0x220) = *(LWord *)(torso + 0x228); // recenter yaw torso->SetAnalogTwistAxis(0.0f);
*(LWord *)(torso + 0x224) = *(LWord *)(torso + 0x22c); // recenter pitch torso->SetAnalogElevationAxis(0.0f);
*(LWord *)(cockpit + 0x2a0) = 1; torso->CommandRecenter();
} }
else if ((unsigned)(controlMode - 1) < 2) // Standard / Veteran // Standard/Veteran: nothing to force -- the sim's limits govern.
{
*(LWord *)(torso + 0x220) = *(LWord *)(torso + 0x230); // yaw -> limit
*(LWord *)(torso + 0x224) = *(LWord *)(torso + 0x234); // pitch -> limit
} }
DEBUG_STREAM << "[mode] control mode -> " << (int)controlMode
<< " (0=Basic 1=Standard 2=Veteran)" << std::endl;
} }
Check_Fpu(); Check_Fpu();
} }
@@ -655,7 +672,54 @@ void
} }
throttlePosition = (key_throttle >= 0.0f) ? key_throttle : -key_throttle; throttlePosition = (key_throttle >= 0.0f) ? key_throttle : -key_throttle;
reverseThrust = (key_throttle < 0.0f) ? 1 : 0; 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; stickPosition.y = 0.0f;
} }
} }
+5
View File
@@ -104,6 +104,11 @@ class Pilot;
CycleControlModeMessageHandler( // @004afbe0 CycleControlModeMessageHandler( // @004afbe0
ReceiverDataMessageOf<ControlsButton> *message ReceiverDataMessageOf<ControlsButton> *message
); );
// The shared mode-cycle body (the console-button handler above + the
// desktop 'M' key both drive it).
void
CycleControlModeNow();
void void
CycleDisplayModeMessageHandler( // @004afcac CycleDisplayModeMessageHandler( // @004afcac
ReceiverDataMessageOf<ControlsButton> *message 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 RatedVoltageRef = 1.0f; // _DAT_004b1d10 (myomer rated reference)
static const Scalar DegradedDriveScale = 0.5f; // _DAT_004b1d14 (heat-degradation throttle) 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 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* Derivation*
PowerWatcher::GetClassDerivations() PowerWatcher::GetClassDerivations()
{ {
// Real base ctor is @004aeb40 (HeatWatcher); HeatableSubsystem stands in. // Chains to the REAL base HeatWatcher (@004aeb40) -- task #57: the old
static Derivation classDerivations(HeatableSubsystem::GetClassDerivations(), "PowerWatcher"); // 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; 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 void
PowerWatcher::Simulation(Scalar time_slice) PowerWatcher::ResetToInitialState(Logical /*powered*/)
{ {
HeatWatcher::ResetToInitialState(True); // FUN_004aea9c = HeatWatcher::ResetToInitialState HeatWatcher::ResetToInitialState(True); // FUN_004aea9c
(void)time_slice; }
//
// @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 Logical
HandleMessage(int message); // @004b179c (chains to base @004aea84) HandleMessage(int message); // @004b179c (chains to base @004aea84)
void 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: public:
// BASE-CHAIN RE-BASE: the shadow watchedLink was DELETED -- the binary has a // BASE-CHAIN RE-BASE: the shadow watchedLink was DELETED -- the binary has a
+101 -14
View File
@@ -94,6 +94,9 @@ namespace {
inline Scalar inline Scalar
RecordField(Simulation::UpdateRecord *record, int byte_offset) RecordField(Simulation::UpdateRecord *record, int byte_offset)
{ return *(const Scalar*)((const char*)record + 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. // rate. TorsoSimulation drives the sweep (below). Remove after verification.
if (isDamagedCopy == 0 && getenv("BT_FORCE_TORSO")) 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 horizontalEnabled = True; // @0x250
const char *mj = getenv("BT_FORCE_TORSO_JOINT"); if (horizontalJointNode == 0)
if (mj == 0 || *mj == '\0') mj = "jointshakey2"; {
horizontalJointNode = ResolveJoint(mj); // torso body (ball) const char *mj = getenv("BT_FORCE_TORSO_JOINT");
horizontalShadowJointNode = ResolveJoint("jointtshadow"); // shadow twist (hingey) if (mj == 0 || *mj == '\0') mj = "jointshakey2";
horizontalLimitLeft = 0.7f; // @0x1E0 ~40 deg horizontalJointNode = ResolveJoint(mj); // torso body (ball)
horizontalLimitRight = -0.7f; // @0x1DC horizontalShadowJointNode = ResolveJoint("jointtshadow"); // shadow twist (hingey)
horizontalLimitLeft = 0.7f; // @0x1E0 ~40 deg
horizontalLimitRight = -0.7f; // @0x1DC
}
baseTwistRate = 1.0f; // @0x23C rad/s slew baseTwistRate = 1.0f; // @0x23C rad/s slew
if (getenv("BT_TORSO_LOG")) 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(); 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(); if (horizontalShadowJointNode) DEBUG_STREAM << " type=" << (int)horizontalShadowJointNode->GetJointType();
DEBUG_STREAM << "\n" << std::flush; 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 // clock (FUN_00414b60) into lastUpdateTime, biasing it forward by one interval
// when the elapsed window is below MinSlewMs, chains to Subsystem::WriteUpdateRecord // when the elapsed window is below MinSlewMs, chains to the base apply
// (FUN_0041bd34), then writes twistAtUpdate / twistVelocity / twistRate from the // (FUN_0041bd34 = ReadUpdateRecord, 2 args), then applies twistAtUpdate /
// record fields (record +0x10 / +0x14 / +0x18). // 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 void
Torso::WriteUpdateRecord(UpdateRecord *message, int update_model) Torso::ReadUpdateRecord(UpdateRecord *message)
{ {
lastUpdateTime = GetCurrentTime(); // @0x254 lastUpdateTime = GetCurrentTime(); // @0x254
if ((Scalar)(lastUpdateTime - GetCreationTime()) / MsPerSecond < MinSlewMs) if ((Scalar)(lastUpdateTime - GetCreationTime()) / MsPerSecond < MinSlewMs)
@@ -402,13 +418,41 @@ void
lastUpdateTime += (lastUpdateTime - GetCreationTime()); // stretch tiny windows lastUpdateTime += (lastUpdateTime - GetCreationTime()); // stretch tiny windows
} }
Subsystem::WriteUpdateRecord(message, update_model); // FUN_0041bd34 Subsystem::ReadUpdateRecord(message); // FUN_0041bd34
twistAtUpdate = RecordField(message, 0x10); // @0x21C twistAtUpdate = RecordField(message, 0x10); // @0x21C
twistVelocity = RecordField(message, 0x14); // @0x1E8 twistVelocity = RecordField(message, 0x14); // @0x1E8
twistRate = RecordField(message, 0x18); // @0x238 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 // Per-frame simulation
@@ -464,6 +508,34 @@ void
analogTwistAxis = ((++s_sweep / 90) & 1) ? -1.0f : 1.0f; // +/- every ~90 frames 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 twistStep = effectiveTwistRate * time_slice;
Scalar elevStep = effectiveElevationRate * time_slice; Scalar elevStep = effectiveElevationRate * time_slice;
@@ -616,6 +688,21 @@ void
// external joint pass as the master path (see TorsoSimulation for the @004b67ec // external joint pass as the master path (see TorsoSimulation for the @004b67ec
// note). Harmless in single-player bring-up (no copies); correct for MP. // note). Harmless in single-player bring-up (no copies); correct for MP.
UpdateJoints(); // FUN_004b67ec 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(); } void SetMovedFlag() { ForceUpdate(); }
// Local helper: "is value pinned at this software limit?". // Local helper: "is value pinned at this software limit?".
Logical AtLimit(Scalar value, Scalar limit) const { return fabsf(value - limit) <= 0.0001f; } 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 // CROSS-FAMILY delegate shims (STEP-4): UpdateWatch now chains to the real
// chain them to the real PowerWatcher/HeatWatcher base once the Torso is live). // 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() {} void WatcherResetToInitialState() {}
Logical WatcherHandleDeathMessage(Message & /*m*/) { return False; } Logical WatcherHandleDeathMessage(Message & /*m*/) { return False; }
void WatcherUpdateWatch() {} void WatcherUpdateWatch() { UpdateWatch(); } // FUN_004b181c (body: powersub.cpp)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Test Class Support // 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. // Controls (@0x1F0 twist, @0x1F4 elevation); proportional, no button ramp.
void SetAnalogTwistAxis(Scalar v) { analogTwistAxis = v; } void SetAnalogTwistAxis(Scalar v) { analogTwistAxis = v; }
void SetAnalogElevationAxis(Scalar v) { analogElevationAxis = 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) Logical GetHorizontalEnabled() const { return horizontalEnabled; } // @0x250 (mapper free-aim gate @004afd10)
// Reachable horizontal (yaw) half-arc the guns can be brought to bear by // 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) // Subsystem virtual overrides (slots on vtable @0051103c)
// //
public: 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 Logical HandleDeathMessage(Message &message); // slot 9, @004b5be0 -> @004b179c
void ResetToInitialState(); // slot 10, @004b5bf8 void ResetToInitialState(); // slot 10, @004b5bf8
+5 -2
View File
@@ -9,8 +9,11 @@ the BLH (3 lasers + 2 PPCs + 2 MissileLaunchers), AddWeapon @004cdac0 store map
(part_014.c:4827-4837; both state attrs are named "SimulationState", strings @51d526/51d577). (part_014.c:4827-4837; both state attrs are named "SimulationState", strings @51d526/51d577).
Draw hook = `BTDrawReticle(device)` after the 3D scene, cockpit view only. The binary Execute Draw hook = `BTDrawReticle(device)` after the 3D scene, cockpit view only. The binary Execute
@004cdcf0 is an UN-EXPORTED gap → Draw dynamics are best-effort [T3] (caret slide from live @004cdcf0 is an UN-EXPORTED gap → Draw dynamics are best-effort [T3] (caret slide from live
target range; pip A lit on TargetWithinRange else ring B). Step 6 (blx_cop canopy shell) and the target range; pip A lit on TargetWithinRange else ring B). **[SUPERSEDED: Execute @004cdcf0 was
PNAME1-8.bgf 3D pip meshes remain DEFERRED — tracked in context/open-questions.md. RECOVERED task #37 (capstone disasm → reference/decomp/reticle_execute_004cdcf0.disasm.txt) —
Draw dynamics are now [T1]; pip lighting is FIRE-CYCLE state, not TargetWithinRange; crosshair
positioning re-corrected task #58 — see context/gauges-hud.md.]** Step 6 (blx_cop canopy shell)
and the PNAME1-8.bgf 3D pip meshes remain DEFERRED — tracked in context/open-questions.md.
## The recovered dpl2d recorder API (libDPL 2D display lists) [T1] ## The recovered dpl2d recorder API (libDPL 2D display lists) [T1]