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