diff --git a/context/cockpit-view.md b/context/cockpit-view.md index ba6d3f0..f787911 100644 --- a/context/cockpit-view.md +++ b/context/cockpit-view.md @@ -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 diff --git a/context/combat-damage.md b/context/combat-damage.md index b872df0..8370754 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -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). diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 256f868..890bf31 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -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=` → 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) diff --git a/context/gauges-hud.md b/context/gauges-hud.md index 879ef86..2b13a77 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -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.) diff --git a/context/locomotion.md b/context/locomotion.md index 4d9b7bd..d1111af 100644 --- a/context/locomotion.md +++ b/context/locomotion.md @@ -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 diff --git a/context/multiplayer.md b/context/multiplayer.md index 99828e6..2e3561f 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -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] diff --git a/context/open-questions.md b/context/open-questions.md index cb6ea3a..99bb7b9 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -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 diff --git a/context/subsystems.md b/context/subsystems.md index 6525538..6ae46c2 100644 --- a/context/subsystems.md +++ b/context/subsystems.md @@ -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=` → 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 diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index a3aa3f1..855a376 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -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, diff --git a/engine/MUNGA_L4/L4VIDRND.cpp b/engine/MUNGA_L4/L4VIDRND.cpp index 5e51747..d8624b6 100644 --- a/engine/MUNGA_L4/L4VIDRND.cpp +++ b/engine/MUNGA_L4/L4VIDRND.cpp @@ -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), diff --git a/engine/MUNGA_L4/L4VIDRND.h b/engine/MUNGA_L4/L4VIDRND.h index 5ddc47c..a1c1057 100644 --- a/engine/MUNGA_L4/L4VIDRND.h +++ b/engine/MUNGA_L4/L4VIDRND.h @@ -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 diff --git a/game/reconstructed/btl4gaug.cpp b/game/reconstructed/btl4gaug.cpp index 8b57f71..a01ae2f 100644 --- a/game/reconstructed/btl4gaug.cpp +++ b/game/reconstructed/btl4gaug.cpp @@ -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 } diff --git a/game/reconstructed/btl4gaug.hpp b/game/reconstructed/btl4gaug.hpp index 799dd94..864bcec 100644 --- a/game/reconstructed/btl4gaug.hpp +++ b/game/reconstructed/btl4gaug.hpp @@ -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 diff --git a/game/reconstructed/btl4vid.cpp b/game/reconstructed/btl4vid.cpp index e631f9d..66f8deb 100644 --- a/game/reconstructed/btl4vid.cpp +++ b/game/reconstructed/btl4vid.cpp @@ -49,6 +49,9 @@ #if !defined(MECHWEAP_HPP) # include // MechWeapon::GetClassDerivations (reticle pip) #endif +#if !defined(MECHDMG_HPP) +# include // Mech__DamageZone::segmentIndex (the @004d097c dispatcher) +#endif #if !defined(NOTATION_HPP) # include #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) //############################################################################# diff --git a/game/reconstructed/btl4vid.hpp b/game/reconstructed/btl4vid.hpp index 33b6216..5ad72e5 100644 --- a/game/reconstructed/btl4vid.hpp +++ b/game/reconstructed/btl4vid.hpp @@ -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). // diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index 54395f9..8d35ae7 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -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 diff --git a/game/reconstructed/heatfamily_reslice.cpp b/game/reconstructed/heatfamily_reslice.cpp index 2172b47..42dca35 100644 --- a/game/reconstructed/heatfamily_reslice.cpp +++ b/game/reconstructed/heatfamily_reslice.cpp @@ -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, diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 28143e3..b8eda0b 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -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 diff --git a/game/reconstructed/mech2.cpp b/game/reconstructed/mech2.cpp index 4e07258..cdce518 100644 --- a/game/reconstructed/mech2.cpp +++ b/game/reconstructed/mech2.cpp @@ -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; diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 5002843..bf2cbc6 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -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 diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index 1a365a1..6ec0513 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -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 diff --git a/game/reconstructed/mechdmg.hpp b/game/reconstructed/mechdmg.hpp index 0397b55..1b36416 100644 --- a/game/reconstructed/mechdmg.hpp +++ b/game/reconstructed/mechdmg.hpp @@ -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 // diff --git a/game/reconstructed/mechmppr.cpp b/game/reconstructed/mechmppr.cpp index e1acd0f..ad0f3a6 100644 --- a/game/reconstructed/mechmppr.cpp +++ b/game/reconstructed/mechmppr.cpp @@ -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; } } diff --git a/game/reconstructed/mechmppr.hpp b/game/reconstructed/mechmppr.hpp index b848e87..5ba8f0b 100644 --- a/game/reconstructed/mechmppr.hpp +++ b/game/reconstructed/mechmppr.hpp @@ -104,6 +104,11 @@ class Pilot; CycleControlModeMessageHandler( // @004afbe0 ReceiverDataMessageOf *message ); + + // The shared mode-cycle body (the console-button handler above + the + // desktop 'M' key both drive it). + void + CycleControlModeNow(); void CycleDisplayModeMessageHandler( // @004afcac ReceiverDataMessageOf *message diff --git a/game/reconstructed/powersub.cpp b/game/reconstructed/powersub.cpp index d134913..95a1a03 100644 --- a/game/reconstructed/powersub.cpp +++ b/game/reconstructed/powersub.cpp @@ -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(); } // diff --git a/game/reconstructed/powersub.hpp b/game/reconstructed/powersub.hpp index 2c8009a..d764bb3 100644 --- a/game/reconstructed/powersub.hpp +++ b/game/reconstructed/powersub.hpp @@ -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 diff --git a/game/reconstructed/torso.cpp b/game/reconstructed/torso.cpp index cdb0a46..232b5dd 100644 --- a/game/reconstructed/torso.cpp +++ b/game/reconstructed/torso.cpp @@ -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; + } } diff --git a/game/reconstructed/torso.hpp b/game/reconstructed/torso.hpp index f44cdb1..25b1cb8 100644 --- a/game/reconstructed/torso.hpp +++ b/game/reconstructed/torso.hpp @@ -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 diff --git a/phases/phase-02-dpl2d-reticle.md b/phases/phase-02-dpl2d-reticle.md index 49ffb16..ed659f0 100644 --- a/phases/phase-02-dpl2d-reticle.md +++ b/phases/phase-02-dpl2d-reticle.md @@ -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]