From 0bcba2621346b54d1c1c42ea6d85a31ca0ed4c3a Mon Sep 17 00:00:00 2001 From: arcattack Date: Sat, 18 Jul 2026 23:20:51 -0500 Subject: [PATCH 01/17] KB: land the EXPERIENCE-LEVELS decode from glass-cockpit (Cyd) Brings Cyd's experience-level / simulation-mode research onto master (the KB-decode portion of glass-cockpit 4e01e83; the bundled BT410 source manifest is left for the full glass-cockpit merge). - context/experience-levels.md (NEW): the egg per-pilot 'experience' field (novice/standard/veteran/expert) is the pod's SIMULATION-FIDELITY tier, decoded end-to-end. FUN_004c0bc8 reads btMission->experienceLevel(+0xe4) and fans it into the +0x25c/+0x260/+0x26c/+0x270/+0x274 flag block: +0x25c 'sim live' (novice lockout: jams/searchlight/powersub), +0x260 the HEAT-MODEL master switch (veteran+expert; FUN_004ad7d4), +0x274 raw level (FUN_004ac9c8 = ==0 novice predicate; the valve/advanced-cockpit lockout). 4.0->4.10 drift: the viewscreen hunting-aid gate is GONE in 4.10 (HUD reads none of these flags), and movement heat is veteran+expert not expert-only. - btplayer.cpp/.hpp: CORRECTED comments/labels -- the +0x25c..+0x274 block is seeded from btMission experienceLevel/advancedDamageOn, NOT the scenario role's returnFromDeath; the 'roleClassIndex/showKills' names are scoring-era mislabels. Code logic UNCHANGED (comment-only): the ctor still reads scenarioRole (STAND-IN, defaults 2 = veteran) pending the wiring task. - Corrections swept into gauges-hud.md (ROOKIE->novice lockout), open-questions.md (player+0x260/0x274 semantics now PINNED), pod-hardware.md, subsystems.md, decomp-reference.md; CLAUDE.md router row. No code-logic change -> no rebuild needed (comment + markdown only). The runtime wiring (seed from BTMission::ExperienceLevel()) remains TODO. Co-Authored-By: Cyd Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + context/decomp-reference.md | 16 ++++- context/experience-levels.md | 118 ++++++++++++++++++++++++++++++++ context/gauges-hud.md | 9 +-- context/open-questions.md | 49 +++++++------ context/pod-hardware.md | 5 +- context/subsystems.md | 4 +- game/reconstructed/btplayer.cpp | 44 +++++++----- game/reconstructed/btplayer.hpp | 24 ++++--- 9 files changed, 216 insertions(+), 54 deletions(-) create mode 100644 context/experience-levels.md diff --git a/CLAUDE.md b/CLAUDE.md index b8ae2ea..6831605 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ precise than anything you can infer. | Walking, gait, ground model, collision | `context/locomotion.md` | | Subsystems, the factory, heat/weapons/power | `context/subsystems.md` | | Damage zones, targeting, firing, death | `context/combat-damage.md` | +| Experience / simulation modes (novice/standard/veteran/expert), heat-model + novice gates | `context/experience-levels.md` | | Rendering, LODs, materials, sky, shadows, beams | `context/rendering.md` | | The death/respawn translocation warp (tsphere vortex) | `context/translocation-warp.md` | | First-person cockpit canopy (*_cop) + the eyepoint camera | `context/cockpit-view.md` | diff --git a/context/decomp-reference.md b/context/decomp-reference.md index d613d78..3da8294 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -113,7 +113,7 @@ valid for reading the decomp, NOT for our compiled layout (see [[reconstruction- |---|---|---| | +0x11c / +0x120 | `damageZoneCount` / `damageZones[]` | inherited Entity; Mech ctor populates the array | | +0x124 / +0x128 | `subsystemCount` / `subsystemArray[]` | the [[subsystem-roster]] (NOT the segment table) | -| +0x190 | **owning `BTPlayer`** (`GetPlayerLink()`) | set by `FUN_0049f624` (mech↔player bind; also `player->playerVehicle(+0x1fc)=mech`). The valve guard (`FUN_004ac9c8 →player+0x274`) + Myomers gate (`FUN_004ad7d4 →player+0x260`) read the owning player's game-mode flags (NOT the messmgr @0x434). `MECH_OWNING_PLAYER`. | +| +0x190 | **owning `BTPlayer`** (`GetPlayerLink()`) | set by `FUN_0049f624` (mech↔player bind; also `player->playerVehicle(+0x1fc)=mech`). The valve guard (`FUN_004ac9c8 →player+0x274` = novice lockout) + heat-model gate (`FUN_004ad7d4 →player+0x260` = veteran/expert) read the owning player's EXPERIENCE flags — see [[experience-levels]] + the BTPlayer flag block below. `MECH_OWNING_PLAYER`. | | +0x434 (word 0x10d) | `SubsystemMessageManager` cache | the 0xBD3 damage/explosion hub (factory @10142). ✅ task #7 (`afefaee`): `mech.hpp` names this `messageManager`; the `controlsMapper` mislabel is swept — the real mapper is roster slot 0. | | +0x260 / +0x26c | `localOrigin` = { Point3D linearPosition; Quaternion angularPosition } | ORIGIN.h:15; position@+0x100 in some subsystem views | | +0x300 | segment table | skeleton `EntitySegment`s (muzzle sites, joints) | @@ -128,6 +128,20 @@ valid for reading the decomp, NOT for our compiled layout (see [[reconstruction- `mech+0x100` (a subsystem-view of localOrigin.linearPosition): `.x@+0x100, .z@+0x108`. [T1] +### BTPlayer (0x294) experience/game-mode flag block (ctor @4c0bc8; see [[experience-levels]]) [T1] + +Seeded on the master branch from `btMission(+0x1f8)->experienceLevel(+0xe4)` (egg `experience`, +parse @4d2f3e → `BTMission+0xe4`) and `advancedDamageOn(+0xf0)`: + +| Offset | Value by level (nov/std/vet/exp) | Verified readers | +|---|---|---| +| +0x25c | 0/1/1/1 — "sim live" (novice lockout) | jam roll @4bbfcc, searchlight ToggleLamp @4b860c, powersub handler @4b0efc, mech-ineffective eval part_012.c:9364 | +| +0x260 | 0/0/1/1 — **heat-model master switch** | `FUN_004ad7d4` → Myomers @4b8d18, Emitter, heat.cpp, mislanch, projweap heat | +| +0x264/+0x268 | = mission `advancedDamageOn` (both) | heat-family sim part_013.c:8757 | +| +0x26c | 0/1/1/0 | none located (supercharge-clamp hypothesis [T4]) | +| +0x270 | 0/1/1/1 | none located | +| +0x274 | raw level 0..3 | `FUN_004ac9c8` = (==0) novice predicate (valve guard), powersub @4b21d0 | + ### Binary ATTRIBUTE TABLES (2026-07-16, walked from the image) [T1] **Row format: 16-byte `{id, namePtr, memberOffset+1, 0}`** (MIND THE ALIGNMENT — an 8-off walk diff --git a/context/experience-levels.md b/context/experience-levels.md new file mode 100644 index 0000000..ac0e208 --- /dev/null +++ b/context/experience-levels.md @@ -0,0 +1,118 @@ +--- +id: experience-levels +title: "Experience levels / simulation modes — novice, standard, veteran, expert" +status: established +source_sections: "decomp part_013.c:10816-10871 (@4c0bc8), part_014.c:7830-7900 (@4d2f3e), FUN_004ad7d4/FUN_004ac9c8/FUN_004bbfcc; BTMSSN.HPP (survived); Tesla40_BT_manual.pdf pp.7,11-14,21,22,24" +related_topics: [subsystems, combat-damage, gauges-hud, pod-hardware, multiplayer, decomp-reference, open-questions] +key_terms: [master, replicant] +open_questions: + - "player+0x26c (std+vet only) and +0x270 (all but novice) consumers not located — 0x26c hypothesis: myomer supercharge clamp (manual p22) [T4]" + - "does a remote pilot's experience matter cross-pod? flags are computed only on the master branch of the BTPlayer ctor [T4]" + - "movement-heat magnitude veteran vs expert (4.0 manual says expert-only; 4.10 gate is vet+expert) [T4]" +--- + +# Experience levels / simulation modes + +The egg's per-pilot `experience` field is the pod's **simulation-fidelity tier** — the thing the +1995 site operator set per player at sign-up. It is NOT the BAS/MID/ADV control mode (a free +in-cockpit choice, [[locomotion]]/[[pod-hardware]]) and NOT the scenario role (scoring modifiers). +Researched end-to-end 2026-07-18; this file supersedes the older "role display toggles" / +"ROOKIE-role lockout" readings (see Corrections below). + +## The data path [T1] + +1. **Egg**: pilot page key `experience` ∈ {`novice`,`standard`,`veteran`,`expert`}; required unless + `vehicle=camera`; unknown value = hard abort. Parse @4d2f3e (part_014.c:7830-7900, strings + @51e6f1); `tools/eggmodel.py` knows the value set. +2. **BTMission** (`BTMSSN.HPP` survived, T0): stored at `mission+0xe4` (`experienceLevel`, accessor + `ExperienceLevel()`); the neighboring egg key `advancedDamage` → `mission+0xf0` + (`advancedDamageOn`) — the separate site-technician splash/collision switch (manual p13). +3. **BTPlayer ctor @4c0bc8** (part_013.c:10816-10871): on the MASTER branch + (`(simulationFlags & 0xc) != 4`; the replicant branch instead swaps in the console-update + Performance) it reads `btMission(+0x1f8)->experienceLevel(+0xe4)` and fans it out: + +| egg value | +0x25c | +0x260 | +0x26c | +0x270 | +0x274 (raw) | +|---|---|---|---|---|---| +| novice | 0 | 0 | 0 | 0 | 0 | +| standard | 1 | 0 | 1 | 1 | 1 | +| veteran | 1 | 1 | 1 | 1 | 2 | +| expert | 1 | 1 | 0 | 1 | 3 | + +then unconditionally `+0x264 = +0x268 = mission->advancedDamageOn(+0xf0)` (both copies). [T1] + +## What each flag gates (binary consumers, all reached via `mech+0x190` → BTPlayer) [T1] + +- **+0x25c — "sim live", off only for novice.** Consumers: ballistic jam roll `CheckForJam` + @4bbfcc early-returns NO-JAM when 0 (projweap.cpp calls this `LiveFireEnabled`); Searchlight + `ToggleLamp` @4b860c (searchlight.cpp `ControlsAllowLights`); PoweredSubsystem message handler + @4b0efc (powersub.cpp's "message-manager short-event flag" — same flag, misattributed); the mech + combat-ineffective evaluation (part_012.c:9364: mech state ∈ {3,4} && novice is an extra OR-term + setting `mech+0x414`). The `#if 0` block in surviving PPC.CPP:63-95 (novice PPCs deal no damage) + shows the original intent of the tier. +- **+0x260 — the HEAT-MODEL master switch, ON for veteran+expert.** This is `FUN_004ad7d4` + (`*(*(sub+0xD0)+0x190)+0x260`), the single most-called gate in the subsystem family: Myomers + work→heat (movement heat, MyomersSimulation @4b8d18), Emitter/energy-weapon firing heat, missile + launch heat, the heat.cpp simulation, projectile-weapon heat, and the heat-scaled part of the jam + chance. Standard mode has NO heat consequences; that is authentic, not a port gap. +- **+0x274 — the raw level.** `FUN_004ac9c8` = (level == 0), the NOVICE lockout used as the + condenser-valve guard ([[gauges-hud]] task #13) and by the PoweredSubsystem handler @4b21d0 + (not-novice gate). The port's `BTPlayerRoleLocksAdvanced` bridge implements this predicate + (correct behavior, wrong seeding — see Port state). +- **+0x264/+0x268 — advancedDamage copies.** Read in the heat-family sim (part_013.c:8757). + Independent of experience. +- **+0x26c / +0x270 — consumers NOT located** via the mech→player hop. 0x26c (standard+veteran + only) best fits the manual-p22 myomer supercharge clamp (standard locked, expert unlocked) [T4]. + +## Player-facing behavior (Tesla 4.0 manual) [T1 primary source] + +Manual pp. 7, 11-14, 21, 22, 24 (the dedicated Simulation Modes page, manual p2, is MISSING from +the `Tesla40_BT_manual.pdf` scan along with p3): +- **Standard**: armor-only damage — no criticals, no generator outages, no jams, no heat concerns; + MechTech limited to the leg-damage audio warning; viewscreen hunting aids (red hot box + guide + arrow, 60° FOV) on; myomer supercharge locked. +- **Veteran**: critical hits (any hit can go internal; likelihood rises as armor thins; a crit can + kill the mech), generators take damage/go offline (reroute power), ballistic jams (manual + clearing), full MechTech audio + smart buttons; viewscreen hunting aids OFF (radar hot box stays). +- **Expert**: veteran + heat as a core mechanic: movement generates heat (accel/decel worst), + heat slows recharge/top speed, incoming fire causes coolant leaks, ammo-bay fires/cook-off; + myomer supercharge allowed. +- Scoring identical in all modes (p4). Splash/collision damage = a separate technician setting. + +## 4.0 → 4.10 drift [T1] + +- **The viewscreen hunting-aid gate is GONE in 4.10**: part_014 (HUD/btl4vid TU) reads none of the + player flags — the target hotbox/edge arrows (@4cdf6f) are gated on lock state only, every mode. + Fits the 4.10 arcade-cities positioning. +- **Movement heat is veteran+expert in 4.10** (the +0x260 gate), not expert-only as the 4.0 manual + states. Whether a coefficient still differentiates them is open. + +## Port state (as of 2026-07-18) — wiring needed [T3] + +The reconstruction met this system piecemeal and stubbed the same gates inconsistently: +- `btplayer.cpp` ctor seeds `roleClassIndex` from `scenarioRole->GetReturnFromDeath()` (default 2) + — the binary seeds it from `btMission->ExperienceLevel()`. Bring-up therefore runs as VETERAN. + Two slot errors in the port's switch vs the binary: the binary's switch never touches +0x264 + (that's advancedDamage, set after), and expert's `0` lands on +0x26c, not +0x270. +- `HeatModelActive()` stubbed permissive `return 1` (emitter/heat/mislanch/projweap) while + `Myomers::OwnerAdvancedDamage()` returns `False` — the SAME binary gate (+0x260) answered both + ways: heat accrues from weapons but never from movement, and no experience below veteran can + switch it off. +- `LiveFireEnabled` / `ControlsAllowLights` / powersub's short-event flag = the +0x25c not-novice + flag under three names. +- **The fix**: one accessor family on BTPlayer named members (databinding trap — never raw-read + `player+0x260`), seeded from `BTMission::ExperienceLevel()` + `AdvancedDamageOn()`, with all five + stub sites pointed at it; then an egg `experience` line actually selects the sim tier as shipped. + +## Corrections (swept 2026-07-18) + +- `btplayer.hpp` "per-role display toggles from returnFromDeath" for 0x25c-0x274 was WRONG — the + source is `btMission+0xe4/+0xf0`, not the role; the `showKills`/`roleClassIndex` names date from + the scoring work and don't match the gate semantics (kept in code for now, comments corrected). +- `gauges-hud.md` "ROOKIE-role lockout" for FUN_004ac9c8 → novice-EXPERIENCE lockout. +- `open-questions.md` "true semantic of player+0x260/0x274 not yet pinned" → pinned here. + +## Key Relationships +- Feeds: [[subsystems]] (the heat model + jam/valve gates) · [[combat-damage]] (criticals tier intent) · [[gauges-hud]] (valve guard) +- Sourced from: the egg ([[multiplayer]] egg format) · `BTMission` (BTMSSN.HPP, T0) +- Offsets: [[decomp-reference]] §3 (mech+0x190 row + BTPlayer flag block) +- Manual audit: [[pod-hardware]] §Manual diff --git a/context/gauges-hud.md b/context/gauges-hud.md index f64f9fa..eee5d7e 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -150,10 +150,11 @@ moves. Diag: `BT_RADAR_LOG` ([radar] ctor probe + 1 Hz [radar-wedge] rot trace). ## Remaining = DATA FEEDS, not widgets (deferred systems) **✅ The condenser valve CONTROL is LIVE (task #13, 2026-07-11) [T2]:** `MoveValve` (id 4, the Condenser handler table @0x50E52C — exactly one entry) registered + guarded by the REAL -FUN_004ac9c8 = `player+0x274 roleClassIndex == 0` (the ROOKIE-role lockout; task #12's -`BTPlayerRoleLocksAdvanced` bridge — the old "game-mode flag / likely off in a basic mission" -hedge is superseded: bring-up role = 2 = UNLOCKED, verified live: press → valveState 1→5 → -flow redistribution 1/6 → 5/10). Desktop: 'C' cycles the selected condenser (BT_VALVE_SLOT). +FUN_004ac9c8 = `player+0x274 == 0` (2026-07-18 correction: +0x274 = the egg EXPERIENCE level, +so this is the NOVICE lockout, not a "ROOKIE role" — see [[experience-levels]]; task #12's +`BTPlayerRoleLocksAdvanced` bridge; bring-up seeding = 2 ≈ veteran = UNLOCKED, verified live: +press → valveState 1→5 → flow redistribution 1/6 → 5/10). Desktop: 'C' cycles the selected +condenser (BT_VALVE_SLOT). The **MessageBoard** feed needs **StatusMessagePool** (a NULL stub) + the per-player status queue. `SeekVoltageGraph`'s 4 Seek* attrs are a cluster-child (not config-called). [T2] diff --git a/context/open-questions.md b/context/open-questions.md index 094f01b..2baea80 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -151,7 +151,8 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR btl4vid.cpp `MakeMechRenderables` reticle-build/inside pass (== arcade part_014.c:5173, Dynamic, bound per Searchlight `lightState` via a new `LightStatePtr()` accessor); (3) a toggle input (the authentic cockpit button-5→`ToggleLamp` dispatch is a controls-family reconstruction; a dev key - stands in). Also flags a deferred **`Mech::ControlsAllowLights()`** (searchlight.hpp:158 stub). + stands in). Also flags a deferred **`Mech::ControlsAllowLights()`** (searchlight.hpp:158 stub; + 2026-07-18: identified as the `player+0x25c` not-novice experience flag — [[experience-levels]]). See [[rendering]] fog section. Verified inert live: BT_FOG_LOG shows zero `SetFogStyle(2/3)`. - **Factory capability-roster loops 2-4 are STILL DEAD (task #57 discovery).** mech.cpp's post-roster loops add to `heatableSubsystems`(0x51155c)/`weaponRoster`(0x511830)/ @@ -187,20 +188,24 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR - **✅ `mech+0x190` IDENTIFIED (2026-07): it is the owning `BTPlayer`** (`Mech::GetPlayerLink()`, `MECH_OWNING_PLAYER`; ENTITY.h:430). Set by `FUN_0049f624` (the mech↔player bind: `mech+0x190 = player` AND `player->playerVehicle(+0x1fc) = mech`), which resolves the player from the mission player registry - (`app+0x2c+0x54`, `FUN_0041fd18`) by the pilot key. The valve/Myomers "gates" read the owning player's - fields at `player+0x260` and `player+0x274` (the reconstruction maps this block as `showDamageReceived@0x25c - / showKills@0x260 / showDamageInflicted@0x264 / roleClassIndex@0x274` in btplayer.hpp — from the SCORING - work, so the names may NOT match the gate semantics; the true meaning + the WRITER of `player+0x260/0x274` - are not yet pinned — part_013.c:4553-4660 is the TorsoSimulation, a different object, not the setter). So - there is NO new subsystem to build. **The wiring (small):** point `MechSubsystem::IsDamaged()` (`FUN_004ac9c8`, valve - guard) at `GetPlayerLink()->`(0x274) and `Myomers::OwnerAdvancedDamage()` (`FUN_004ad7d4`) at - `GetPlayerLink()->`(0x260) — via NAMED members (databinding trap: our BTPlayer layout ≠ binary; do NOT - raw-read `player+0x260`). ⚠ Resolve first: (a) a naming conflict — `FUN_004ad7d4` is labeled BOTH - `HeatModelActive` (heat.hpp) AND `OwnerAdvancedDamage` (myomers.cpp); re-verify which body is which; (b) - the true semantic of `player+0x260/0x274` vs the scoring-derived `showKills`/`roleClassIndex` names. NOTE: - these are MODE flags — in the basic test mission the inert Myomers / dormant valve is likely AUTHENTIC - (advanced damage off); wiring makes them RESPOND when a mission enables the mode, not necessarily change - the basic-mission behavior. The MessageBoard feed is separate (StatusMessagePool, below). + (`app+0x2c+0x54`, `FUN_0041fd18`) by the pilot key. + **✅ FLAG SEMANTICS PINNED (2026-07-18) [T1]: the `player+0x25c..0x274` block = the EXPERIENCE + LEVEL (egg `experience` novice/standard/veteran/expert), written by the BTPlayer ctor @4c0bc8 + from `btMission(+0x1f8)->experienceLevel(+0xe4)` — full mapping, consumers, and the 4.0-manual + behavior in [[experience-levels]] (+ [[decomp-reference]] §3 flag table).** Resolved en route: the + old "resolve first" items — `FUN_004ad7d4` (labeled both `HeatModelActive` and `OwnerAdvancedDamage`) + is ONE body reading `player+0x260` = the heat-model master switch (veteran+expert); `FUN_004ac9c8` + reads `player+0x274` = the novice lockout; the btplayer.hpp `showKills`/`roleClassIndex` scoring + names indeed do NOT match the gate semantics (comments corrected in place, member names kept). + **The wiring (small, still open):** ONE accessor family on BTPlayer named members seeded from + `BTMission::ExperienceLevel()`/`AdvancedDamageOn()` (databinding trap: do NOT raw-read + `player+0x260`), then point the five stub sites at it (`HeatModelActive` permissive-1 in + emitter/heat/mislanch/projweap vs `OwnerAdvancedDamage` False in myomers — currently the SAME gate + answered both ways; `LiveFireEnabled`/`ControlsAllowLights`/powersub short-event = the +0x25c + flag under three names; ctor seeding from role->returnFromDeath is a bring-up stand-in that runs + every mission as VETERAN). NOTE: these are MODE flags — an egg with `experience=standard` should + authentically have inert movement-heat/jams; wiring makes the tier selectable, not the basic + mission different. The MessageBoard feed is separate (StatusMessagePool, below). - **✅ Authentic target acquisition RECONSTRUCTED (tasks #36/#39, 2026-07-08)** — the `Reticle` pick-ray chain is LIVE (see [[combat-damage]] Targeting for the full port map): the crosshair = **torso boresight** (NO free-aim mouse — the pod stick twisted the torso; you steer to aim) → @@ -352,17 +357,19 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR tables must be function-local statics INSIDE the accessor (cross-TU static-init order emptied the table -- see [[reconstruction-gotchas]] #9 last bullet); (b) **FUN_004ac9c8 is NOT IsDamaged** -- raw body: `owner(+0xD0) -> mech+0x190 player - -> roleClassIndex(+0x274) == 0` = the ROOKIE-role lockout for advanced cockpit - systems (port bridge BTPlayerRoleLocksAdvanced, btplayer.cpp; NULL player = - unlocked [T3]; bring-up role defaults to 2 = unlocked). AUDIT TAIL: the other + -> +0x274 == 0` = the NOVICE-experience lockout for advanced cockpit systems + (2026-07-18 correction: +0x274 = the egg experience level, NOT a role class -- + see [[experience-levels]]; port bridge BTPlayerRoleLocksAdvanced, btplayer.cpp; + NULL player = unlocked [T3]; bring-up seeding defaults to 2 ≈ veteran = unlocked). + AUDIT TAIL: the other powersub.cpp sites annotated FUN_004ac9c8 (the coolant-draw gate ~:427, ForceShortRecovery ~:444, the Generator/PowerWatcher site ~:1085) still call the heat-family IsDamaged (simulationState != 0) stand-in -- each needs its raw fn re-checked and swapped to the role bridge where the binary calls 4ac9c8. - **Myomers authentic coupling** — the structural un-stub is INERT (mover feed + heat-gen no-op). - Real coupling needs the advanced-damage gate (`OwnerAdvancedDamage`/`FUN_004ad7d4` → the owning - **BTPlayer** `mech+0x190`+0x260, NOT 0xBD3 — see the mech+0x190 item above) + `MoverAttach` routing - into the LIVE JointedMover (must be reconciled with the gait cutover first). + Real coupling needs the heat-model gate (`OwnerAdvancedDamage`/`FUN_004ad7d4` → the owning + **BTPlayer** `mech+0x190`+0x260 = veteran/expert experience, NOT 0xBD3 — see [[experience-levels]]) + + `MoverAttach` routing into the LIVE JointedMover (must be reconciled with the gait cutover first). - **SeekVoltageGraph** — 4 Seek* attrs unpublished (a cluster-child, not config-called; non-blocking). - **✅ DAMAGE ECONOMY — AUTHENTIC (task #8, 2026-07-11) [T1/T2].** The whole economy closes: diff --git a/context/pod-hardware.md b/context/pod-hardware.md index 501cab5..98117d6 100644 --- a/context/pod-hardware.md +++ b/context/pod-hardware.md @@ -154,7 +154,10 @@ every checked control behavior: button 8 → ChooseNearestPilot); radar = travel-oriented, 60° vision wedge sweeps with twist, red hot box + callsign on contacts; **map zoom ± buttons** flank the secondary screen. - **Experience gates the hunting aids**: viewscreen hot box + hunting-guide arrow appear in - STANDARD sim mode only (not veteran/expert); expert mode adds movement heat. + STANDARD sim mode only (not veteran/expert); expert mode adds movement heat. (2026-07-18 [T1]: + 4.0→4.10 drift — in OUR 4.10 binary the HUD reads NO experience flag (hotbox/arrows are + lock-gated in every mode) and movement heat is veteran+expert; full wiring in + [[experience-levels]].) - Cockpit: 5 MFDs (Coolant UL, Engineering top, Hot Box UR, Weapon ×2) + secondary screen with side button columns + **EJECT button** beside the joystick + per-mech COOLANT LOOP tables (weapons+generators per loop 1-6) and stat sheets (tonnage/armor/reservoir liters/heat diff --git a/context/subsystems.md b/context/subsystems.md index b1962fb..ed0abaa 100644 --- a/context/subsystems.md +++ b/context/subsystems.md @@ -57,8 +57,8 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER - **✅ DONE (task #7, `afefaee`)** — SubsystemMessageManager 0xBD3 (a **damage/explosion consolidation hub** cached to `Mech[0x10d]`=0x434; the factory builds the REAL class and `mech.hpp` names the slot `messageManager` — the old `controlsMapper` mislabel is swept, - the real mapper is roster slot 0. NOT the valve/advanced-damage gate — that's the owning **BTPlayer** - @mech+0x190, see [[combat-damage]]/[[open-questions]]). [T2] + the real mapper is roster slot 0. NOT the valve/heat-model gate — that's the owning **BTPlayer** + @mech+0x190 carrying the EXPERIENCE-level flags, see [[experience-levels]]). [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 diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index 8a82d46..96b8240 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -1303,12 +1303,21 @@ BTPlayer::BTPlayer( else { // - // Master: choose the HUD display toggles from the role class index. + // Master: derive the game-mode flags from the EXPERIENCE level. // - // TODO(bring-up): scenarioRole is NULL here because the role lookup above - // (btMission->GetRoleRegistry()->Lookup) is still stubbed out. Guard the deref - // so mission load proceeds; default index 2 = show all stats (freeforall). - roleClassIndex = scenarioRole ? scenarioRole->GetReturnFromDeath() : 2; // this[0x9d] <- role+0xe4 + // CORRECTED SOURCE (2026-07-18 [T1]): the binary reads + // btMission->experienceLevel (mission+0xe4, the egg's per-pilot + // "experience" 0..3 = novice/standard/veteran/expert) — NOT the role's + // returnFromDeath (the old reading). See context/experience-levels.md. + // + // STAND-IN [T3]: still seeded from the role (default 2 ≈ VETERAN) until + // the wiring task points this at BTMission::ExperienceLevel(). Known + // slot drift vs the binary switch: the binary never touches + // showDamageInflicted(0x264) here (that's the advancedDamage copy, set + // below), and expert's 0 lands on roleReturnDelay2(0x26c), not + // showScore(0x270) — binary rows: nov 0,0,0,0 / std 1,0,1,1 / + // vet 1,1,1,1 / exp 1,1,0,1 for (0x25c,0x260,0x26c,0x270). + roleClassIndex = scenarioRole ? scenarioRole->GetReturnFromDeath() : 2; // this[0x9d] <- mission+0xe4 in the binary switch (roleClassIndex) { case 0: @@ -1327,9 +1336,11 @@ BTPlayer::BTPlayer( break; } - // role+0xf0 ("returnDelay") has no field in the WinTesla ScenarioRole; - // initialised to 0 here. CROSS-FAMILY -- see report. BEST-EFFORT. - roleReturnDelay = roleReturnDelay2 = 0.0f; // this[0x99],[0x9a] <- role+0xf0 + // CORRECTED (2026-07-18 [T1]): the binary sets this[0x99]/[0x9a] + // (0x264/0x268) = btMission->advancedDamageOn (mission+0xf0, the egg + // "advancedDamage" technician flag) — an int pair, not role floats. + // 0 here = advanced damage OFF, which matches the bring-up eggs. [T3] + roleReturnDelay = roleReturnDelay2 = 0.0f; // this[0x99],[0x9a] <- mission+0xf0 in the binary } killCount = 0; // this[0x9f] @@ -1589,14 +1600,15 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat // // FUN_004ac9c8 [T1]: `return *(*(*(sub+0xD0) + 0x190) + 0x274) == 0` -- the // subsystem's owner Mech -> the owning BTPlayer (mech+0x190, GetPlayerLink) -// -> roleClassIndex (+0x274, role resource +0xE4). TRUE = the ROOKIE role -// (class 0) LOCKS the advanced cockpit systems (generator routing, coolant -// valves); nonzero role classes unlock them. The old reconstruction -// mislabeled this fn "Subsystem::IsDamaged" -- with healthy subsystems at -// simulationState==1, that stand-in gated the power-routing handlers OFF -// permanently. A NULL player (the target dummy / unbound solo mech) would AV -// in the binary (every pod mech has a player); the port reads NULL as -// UNLOCKED so dev rigs work [T3]. +// -> +0x274 = the raw EXPERIENCE level (egg "experience", mission+0xe4; the +// old "roleClassIndex / role resource" reading was wrong -- see +// context/experience-levels.md). TRUE = NOVICE experience LOCKS the advanced +// cockpit systems (generator routing, coolant valves); standard and above +// unlock them. The old reconstruction mislabeled this fn +// "Subsystem::IsDamaged" -- with healthy subsystems at simulationState==1, +// that stand-in gated the power-routing handlers OFF permanently. A NULL +// player (the target dummy / unbound solo mech) would AV in the binary (every +// pod mech has a player); the port reads NULL as UNLOCKED so dev rigs work [T3]. // int BTPlayerRoleLocksAdvanced(void *owner_mech) { diff --git a/game/reconstructed/btplayer.hpp b/game/reconstructed/btplayer.hpp index 1f2a7d0..548c292 100644 --- a/game/reconstructed/btplayer.hpp +++ b/game/reconstructed/btplayer.hpp @@ -356,23 +356,29 @@ class DropZone__ReplyMessage; suppressConsole; // @0x258 skip console notify for this score event // - // Per-role display toggles, selected from the role's "returnFromDeath" - // class index (scenarioRole resource +0xe4, values 0..3) at @004c0bc8. + // EXPERIENCE-LEVEL game-mode flags (2026-07-18 [T1] — the old "per-role + // display toggles / returnFromDeath" reading was WRONG). The ctor + // @004c0bc8 seeds this block from btMission(+0x1f8)->experienceLevel + // (+0xe4, the egg's per-pilot "experience" novice/standard/veteran/ + // expert) and ->advancedDamageOn(+0xf0) — NOT from the scenario role. + // The member NAMES below date from the scoring work and do not match + // the semantics; kept until the wiring task renames them. Binary + // mapping (nov/std/vet/exp) + consumers: context/experience-levels.md. // Logical - showDamageReceived; // @0x25c + showDamageReceived; // @0x25c 0/1/1/1 "sim live" (novice lockout: jams, searchlight, powersub @4b0efc) Logical - showKills; // @0x260 + showKills; // @0x260 0/0/1/1 HEAT-MODEL master switch (FUN_004ad7d4) Logical - showDamageInflicted;// @0x264 + showDamageInflicted;// @0x264 = mission advancedDamageOn (copy 1; NOT part of the level switch) Logical - showScore; // @0x270 + showScore; // @0x270 0/1/1/1 consumer not located Scalar - roleReturnDelay; // @0x268 (role resource +0xf0) + roleReturnDelay; // @0x268 = mission advancedDamageOn (copy 2; int in the binary) Scalar - roleReturnDelay2; // @0x26c + roleReturnDelay2; // @0x26c 0/1/1/0 consumer not located (supercharge-clamp hypothesis [T4]) int - roleClassIndex; // @0x274 (role resource +0xe4) + roleClassIndex; // @0x274 raw experience level 0..3 (FUN_004ac9c8 = ==0 novice predicate) friend int BTPlayerRoleLocksAdvanced(void *); // the FUN_004ac9c8 bridge (task #12) friend void BTPlayerCountObservedDeath(void *); // observed-death tally (MP DEATHS fix) From 53ac927d4b18916736337a999e1a03fe6a60bbab Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 09:23:02 -0500 Subject: [PATCH 02/17] Destructible props LIVE: revive StateInstanceSwitchRenderable (Gitea issue #3) Playtest report: trucks play a collision sound on impact but never change state -- no explosion swap, no destroyed model, collision volume persists. Investigation (BT_CULT_LOG census + BT_GOTO ram harness): the SIM was never broken. ARENA1 map-streams 124 CulturalIcon entities, each with a damage zone, explosion resource and removeOnDeath; the mech crunch dispatch (ProcessCollision -> BTDispatchCollisionDamage) reaches them; ~19 walking bumps burn the zone -> CulturalIcon::TakeDamageMessageHandler spawns the Explosion (psfx effect 1008 observed), posts the delayed BurningState and deletes the collision boxes. All of that ran correctly and invisibly. The ACTUAL bug: the 2007 WinTesla port fully stubbed StateInstanceSwitchRenderable ('STUBBED: DPL RB 1/14/07') -- ctor never registered on the state dial, Execute never toggled anything -- so the intact->rubble visual swap on BurningState NEVER fired, for every cultural icon in the game. The destroyed truck kept its intact model: exactly the reported 'no state change'. Revival (D3D9-native, 1995 semantics verbatim): - StateInstanceSwitchRenderable now controls the draw COMPONENT via the SetDrawObj in-place drawable swap (the mech RemakeEntity mechanism): visible = captured d3d_OBJECT, hidden = NULL. Initial state in the ctor, AddVideoWatcher on the SimulationState dial (SetState fires video watchers), toggle only on a real change. - L4VIDEO cultural case passes the draw component + object (the dead dpl_INSTANCE param is gone); [video] Object hides at BurningState, Rubble shows. - REQUIRED: cultural objects stay OUT of static-mesh consolidation (RecurseStaticObject, same exclusion class as banded LODs/shadows) -- a merged static draws forever regardless of DrawObj. - Permanent env diag BT_CULT_LOG: creation census (zones/explosion/flags/ pos), TakeDamage trace, death transition, [cultvis] switch actions. Verified live (BT_GOTO=-620,-328 ram, ARENA1): 19 crunches -> DYING -> explosion 1008 at the icon -> [cultvis] HIDE x2 (intact) + SHOW (rubble) -> contacts cease, mech walks into the former footprint. Boot + render un-regressed (statics consolidate as before minus the 124 icons). KB: context/rendering.md new section (the full chain + the consolidation gotcha). MP replicant path shares the same SetState watcher (cross-pod verification pending -- noted in the issue). Co-Authored-By: Claude Fable 5 --- context/rendering.md | 22 ++++++ engine/MUNGA/CULTURAL.cpp | 34 ++++++++ engine/MUNGA_L4/L4VIDEO.cpp | 32 +++++--- engine/MUNGA_L4/L4VIDRND.cpp | 145 +++++++++++++++++------------------ engine/MUNGA_L4/L4VIDRND.h | 33 ++++++-- 5 files changed, 177 insertions(+), 89 deletions(-) diff --git a/context/rendering.md b/context/rendering.md index b5852b3..aa01844 100644 --- a/context/rendering.md +++ b/context/rendering.md @@ -243,6 +243,28 @@ the user plays the DEBUG build, but the 10fps was NOT the debug build (RelWithDe split draw-vs-present + check the DEVICE flags before blaming the compiler. Diagnostics kept: `[spike]/[loadobj]/[rstat]`. [T2] +## Cultural-icon (map prop) DESTRUCTION — revived 2026-07-19 (Gitea issue #3) [T2] +Trucks/props/buildings are **CulturalIcon/Landmark** entities (`engine/MUNGA/cultural.h`, map- +streamed via INTEREST.cpp; ARENA1 = 124 icons, each with a damage zone, `ExplosionModelFile`, +`removeOnDeath`). The SIM was always complete [T0]: ram/weapon damage → `CulturalIcon:: +TakeDamageMessageHandler` → zone `damageLevel ≥ 1` → **Explosion entity** (Crunch variant for +collision) + `WaitingToBurn` (+`timeDelay`) → `BurningState` + `UpdateCollisionVolumes` (boxes +DELETED on `removeOnDeath`, else sunk by `destroyedYTranslation`). The mech-side crunch dispatch +(`mech4.cpp ProcessCollision` → `BTDispatchCollisionDamage`, ×0.001 ram economy) reaches icons +(≈19 walking-speed bumps kill the spawn-yard truck). What was BROKEN (the "no state change" +report): the 2007 port fully stubbed **`StateInstanceSwitchRenderable`** ("STUBBED: DPL RB +1/14/07") — the intact→rubble visual swap NEVER fired. Revived D3D9-native: the switch now +controls the draw component via the **`SetDrawObj` in-place drawable swap** (the mech +RemakeEntity mechanism); `[video] Object` hides at BurningState, `Rubble` shows; watcher = +`StateIndicator::AddVideoWatcher` (SetState DOES fire video watchers). ⚠ REQUIRED gotcha: +cultural objects must stay **OUT of static-mesh consolidation** (`RecurseStaticObject` — same +exclusion class as banded LODs/shadows), else the merged mesh draws the intact model forever +regardless of DrawObj. Explosion visual = the psfx route (effect 1008 observed). Verified live +(BT_GOTO ram): HIDE×2 + SHOW(rubble), contacts cease, mech occupies the footprint. Diags: +`BT_CULT_LOG` (census/damage/death + `[cultvis]`). MP: replicants transition via +`ReadUpdateRecord` → same SetState watcher path (untested cross-pod). + ## Key Relationships - Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]]. - Renders on: [[pod-hardware]] (main 3D view). +- Prop destruction feeds: [[combat-damage]] (crunch dispatch) · uses the psfx layer. diff --git a/engine/MUNGA/CULTURAL.cpp b/engine/MUNGA/CULTURAL.cpp index aa6675f..a2a4ceb 100644 --- a/engine/MUNGA/CULTURAL.cpp +++ b/engine/MUNGA/CULTURAL.cpp @@ -73,6 +73,16 @@ void { Check(damage_message); + // BT_CULT_LOG (issue #3): does damage ever reach a cultural icon? + if (getenv("BT_CULT_LOG")) + { + DEBUG_STREAM << "[cult] TakeDamage entity=" << (long)GetEntityID() + << " type=" << (int)damage_message->damageData.damageType + << " amount=" << (float)damage_message->damageData.damageAmount + << " zone=" << damage_message->damageZone + << " state=" << GetSimulationState() + << "\n" << std::flush; + } if (damage_message->damageZone == -1 ) { @@ -144,6 +154,15 @@ void Explosion::Make(&exp_message); Register_Object(new_exp); + // BT_CULT_LOG (issue #3): the death transition fired + if (getenv("BT_CULT_LOG")) + { + DEBUG_STREAM << "[cult] DYING entity=" << (long)GetEntityID() + << " explosion res=" << explosion_ID + << " delay=" << (float)timeDelay + << " -> WaitingToBurn\n" << std::flush; + } + Message dummy_message(SetBurningStateMessageID, sizeof(Message)); Check(application); Time now = Now(); @@ -335,6 +354,21 @@ CulturalIcon::CulturalIcon( } SetValidFlag(); dmg_res->Unlock(); + // BT_CULT_LOG: per-icon creation census (issue #3 -- truck destruction) + if (getenv("BT_CULT_LOG")) + { + DEBUG_STREAM << "[cult] made icon entity=" << (long)GetEntityID() + << " res=" << resourceID + << " zones=" << damageZoneCount + << " explode=" << explosionResourceID + << " crunch=" << crunchExplosionResourceID + << " removeOnDeath=" << (int)RemoveCollisionVolumeOnDeath() + << " stopping=" << (int)IsStoppingCollisionVolume() + << " pos=(" << localOrigin.linearPosition.x + << "," << localOrigin.linearPosition.y + << "," << localOrigin.linearPosition.z << ")" + << "\n" << std::flush; + } Check_Fpu(); } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index 5e028e4..7021838 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -3813,6 +3813,20 @@ void DPLRenderer::RecurseStaticObject(HierarchicalDrawComponent *obj) // (translucent + depth bias); merging them would draw them opaque. if (dobj->GetIsShadow()) banded = true; + // Issue #3 (cultural destruction): CulturalIcon/Landmark objects are + // DESTRUCTIBLE -- their intact model must vanish (and rubble appear) at + // BurningState via the revived StateInstanceSwitchRenderable's + // SetDrawObj swap. A model merged into the consolidated static mesh + // draws forever regardless of DrawObj, so destructibles stay OUT of + // consolidation and keep drawing individually (the same mechanism as + // the banded-LOD exclusion above). + { + Entity *owning = obj->GetOwningEntity(); + if (owning != NULL + && (owning->GetClassID() == CulturalIconClassID + || owning->GetClassID() == LandmarkClassID)) + banded = true; + } if (!banded) { //Only load it for a static object if it has a valid mesh- @@ -7086,12 +7100,6 @@ void case CulturalIconClassID: case LandmarkClassID: { - dpl_INSTANCE *this_instance = NULL; // BT bring-up: the per-object instance - // assignments below are DPL-stubbed - // (commented out) -> initialise so the - // /RTC1 uninitialised-variable check does - // not fault when this_instance is passed - // to StateInstanceSwitchRenderable. d3d_OBJECT *this_object; dpl_ISECT_MODE intersect_mode; HierarchicalDrawComponent *component = NULL; @@ -7204,7 +7212,10 @@ void // this_instance = another_instance->GetInstance(); } } - // add the new object to our renderables list + // add the new object to our renderables list. Keep the draw + // component in hand -- the revived state switch (issue #3) + // controls IT (SetDrawObj show/hide), not a dead DPL instance. + HierarchicalDrawComponent *draw_component = component; if (component) mRenderables.Add(component); component = NULL; @@ -7212,6 +7223,7 @@ void // Hook up the object to an instance switch renderable responsive to state // StateIndicator* simulation_state = (StateIndicator *)entity->GetAttributePointer("SimulationState"); + if (simulation_state != NULL && draw_component != NULL) switch(resource_type) { case L4VideoObject::Object: @@ -7219,7 +7231,8 @@ void component = new StateInstanceSwitchRenderable( entity, // Entity to attach the renderable to StateInstanceSwitchRenderable::Watcher, // How/when to execute the renderable - this_instance, // the instance to control + draw_component, // the draw component to control + this_object, // its drawable (restored on show) False, // true to turn on in this state, false for off simulation_state, // State dial we use to control the on/off CulturalIcon::BurningState); // State that we look for @@ -7230,7 +7243,8 @@ void component = new StateInstanceSwitchRenderable( entity, // Entity to attach the renderable to StateInstanceSwitchRenderable::Watcher, // How/when to execute the renderable - this_instance, // the instance to control + draw_component, // the draw component to control + this_object, // its drawable (restored on show) True, // true to turn on in this state, false for off simulation_state, // State dial we use to control the on/off CulturalIcon::BurningState); // State that we look for diff --git a/engine/MUNGA_L4/L4VIDRND.cpp b/engine/MUNGA_L4/L4VIDRND.cpp index 28b04d6..84b2661 100644 --- a/engine/MUNGA_L4/L4VIDRND.cpp +++ b/engine/MUNGA_L4/L4VIDRND.cpp @@ -4118,51 +4118,47 @@ void // Constructor for StateInstanceSwitchRenderable // StateInstanceSwitchRenderable::StateInstanceSwitchRenderable( - Entity *entity, // Entity to attach the renderable to - ExecutionType execution_type, // How/when to execute the renderable - dpl_INSTANCE *this_instance, // the instance to control - Logical sense, // true to turn on in this state, false for off - StateIndicator *state_dial, // State dial we use to control the on/off - unsigned trigger_state // State that we look for + Entity *entity, // Entity to attach the renderable to + ExecutionType execution_type, // How/when to execute the renderable + HierarchicalDrawComponent *controlled_component,// the draw component to show/hide + d3d_OBJECT *controlled_object, // the drawable it shows when visible + Logical sense, // true to turn on in this state, false for off + StateIndicator *state_dial, // State dial we use to control the on/off + unsigned trigger_state // State that we look for ): VideoRenderable(entity, execution_type) { - //STUBBED: DPL RB 1/14/07 - //// - //// Check incoming data for correctness - //// - //Check_Pointer(this_instance); - //Check(state_dial); - //// - //// Initialize variables - //// - //myInstance = this_instance; - //mySense = sense; - //myStateDial = state_dial; - //myTriggerState = trigger_state; - //// - //// Put the instance in the proper state and std::flush it - //// - //if(myStateDial->GetState() == myTriggerState) - //{ - // if(mySense == True) - // myLastInstanceState = True; - // else - // myLastInstanceState = False; - //} - //else - //{ - // if(mySense == True) - // myLastInstanceState = False; - // else - // myLastInstanceState = True; - //} - //dpl_SetInstanceVisibility ( myInstance, myLastInstanceState ); - //dpl_FlushInstance ( myInstance ); - //// - //// Connect us to the state dial's watcher hook - //// - //state_dial->AddVideoWatcher(this); + // REVIVED (issue #3): the 1995 body verbatim, retargeted from the dead DPL + // instance-visibility API onto the port's SetDrawObj in-place drawable swap. + // + // Check incoming data for correctness + // + Check_Pointer(controlled_component); + Check(state_dial); + // + // Initialize variables + // + myComponent = controlled_component; + myObject = controlled_object; + mySense = sense; + myStateDial = state_dial; + myTriggerState = trigger_state; + // + // Put the instance in the proper state + // + if(myStateDial->GetState() == myTriggerState) + { + myLastInstanceState = mySense ? True : False; + } + else + { + myLastInstanceState = mySense ? False : True; + } + myComponent->SetDrawObj(myLastInstanceState ? myObject : NULL); + // + // Connect us to the state dial's watcher hook + // + state_dial->AddVideoWatcher(this); } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Destructor for StateInstanceSwitchRenderable @@ -4178,7 +4174,7 @@ Logical { // Call our parent's TestInstance first VideoRenderable::TestInstance(); - Check_Pointer(myInstance); + Check_Pointer(myComponent); Check(myStateDial); return True; } @@ -4191,37 +4187,38 @@ Logical void StateInstanceSwitchRenderable::Execute() { - //STUBBED: DPL RB 1/14/07 - //unsigned - // new_state; - //// - //// Check our variables - //// - //Check(this); - //// - //// See what state we should go into - //// - //if(myStateDial->GetState() == myTriggerState) - //{ - // if(mySense == True) - // new_state = True; - // else - // new_state = False; - //} - //else - //{ - // if(mySense == True) - // new_state = False; - // else - // new_state = True; - //} - //if(new_state != myLastInstanceState) - //{ - // myLastInstanceState = new_state; - // dpl_SetInstanceVisibility ( myInstance, myLastInstanceState ); - // dpl_FlushInstance ( myInstance ); - //} - //// Call the next lower execute method + // REVIVED (issue #3): the 1995 body verbatim on the SetDrawObj swap. + unsigned + new_state; + // + // Check our variables + // + Check(this); + // + // See what state we should go into + // + if(myStateDial->GetState() == myTriggerState) + { + new_state = mySense ? True : False; + } + else + { + new_state = mySense ? False : True; + } + if((Logical)new_state != myLastInstanceState) + { + myLastInstanceState = (Logical)new_state; + myComponent->SetDrawObj(myLastInstanceState ? myObject : NULL); + if (getenv("BT_CULT_LOG")) + { + DEBUG_STREAM << "[cultvis] switch: entity=" << (long)myEntity->GetEntityID() + << " sense=" << (int)mySense + << " state=" << myStateDial->GetState() + << " -> " << (myLastInstanceState ? "SHOW" : "HIDE") + << "\n" << std::flush; + } + } + // Call the next lower execute method VideoRenderable::Execute(); } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/engine/MUNGA_L4/L4VIDRND.h b/engine/MUNGA_L4/L4VIDRND.h index a1c1057..1805ed9 100644 --- a/engine/MUNGA_L4/L4VIDRND.h +++ b/engine/MUNGA_L4/L4VIDRND.h @@ -72,6 +72,12 @@ public: // task #58 diagnostics: expose the parent link so probes can walk the tree. HierarchicalDrawComponent *GetParentProbe() const { return m_parent; } + // Issue #3 (cultural destruction): the consolidation pass must identify which + // entity a static component belongs to (destructible CulturalIcon/Landmark + // objects stay OUT of the merged static mesh so their visibility can switch). + // Base components have no entity link; VideoRenderable overrides with myEntity. + virtual Entity *GetOwningEntity() const { return NULL; } + 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 @@ -241,6 +247,8 @@ class VideoRenderable: virtual bool IsStatic() { return (this->myExecutionType == ExecutionType::Static); } + virtual Entity *GetOwningEntity() const { return myEntity; } // issue #3 (cultural destruction) + protected: Entity *myEntity; // The entity we are linked to ExecutionType myExecutionType; // How/when to execute the renderable @@ -1594,17 +1602,28 @@ class InstanceSwitchRenderable: //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // StateInstanceSwitchRenderable +// REVIVED 2026-07-19 (issue #3, cultural destruction): the 2007 port stubbed the +// whole class ("STUBBED: DPL RB 1/14/07"), so the intact->rubble visual swap on +// CulturalIcon/Landmark death NEVER fired -- destroyed trucks/buildings kept +// their intact model. The DPL instance-visibility mechanism is dead in the +// D3D9 renderer; the port-native equivalent is the SetDrawObj() in-place +// drawable swap (the same mechanism the mech damage-model RemakeEntity swap +// uses). The switch now controls a HierarchicalDrawComponent directly: +// visible = component draws its captured d3d_OBJECT, hidden = DrawObj is NULL. +// Semantics preserved verbatim from the commented 1995 body: initial state set +// in the ctor, AddVideoWatcher on the state dial, toggle only on a real change. class StateInstanceSwitchRenderable: public VideoRenderable { public: StateInstanceSwitchRenderable( - Entity *entity, // Entity to attach the renderable to - ExecutionType execution_type, // How/when to execute the renderable - dpl_INSTANCE *this_instance, // the instance to control + Entity *entity, // Entity to attach the renderable to + ExecutionType execution_type, // How/when to execute the renderable + HierarchicalDrawComponent *controlled_component,// the draw component to show/hide + d3d_OBJECT *controlled_object, // the drawable it shows when visible Logical sense, // true to turn on in this state, false for off StateIndicator *state_dial, // State dial we use to control the on/off - unsigned trigger_state); // State that we look for + unsigned trigger_state); // State that we look for ~StateInstanceSwitchRenderable(); @@ -1618,8 +1637,10 @@ class StateInstanceSwitchRenderable: Logical myLastInstanceState, mySense; - dpl_INSTANCE - *myInstance; + HierarchicalDrawComponent + *myComponent; // the controlled draw component + d3d_OBJECT + *myObject; // its drawable (restored on show) unsigned myTriggerState; StateIndicator From 28b01e3529a76b179ce53a0917142579bd95da70 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 09:32:54 -0500 Subject: [PATCH 03/17] KB: truck destruction NOT fixed -- playtest falsified the harness claim Human playtest (2026-07-19): ramming a truck makes it disappear entirely -- no rubble, no smoke/fire, no visible explosion. The [cultvis] log evidence verified the STATE MACHINE (pointer swaps), not pixels: the rubble child component (DCSInstanceRenderable/DPLStaticChildRenderable) is itself 2007-stubbed and never draws, and the routed psfx 1008 produces nothing visible. rendering.md section corrected from 'verified live' to PARTIAL with the three remaining gaps (rubble draw, visible explosion, burning fire). Gitea #3 reinstated as open/wip. Co-Authored-By: Claude Fable 5 --- context/rendering.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/context/rendering.md b/context/rendering.md index aa01844..df76811 100644 --- a/context/rendering.md +++ b/context/rendering.md @@ -259,10 +259,17 @@ RemakeEntity mechanism); `[video] Object` hides at BurningState, `Rubble` shows; `StateIndicator::AddVideoWatcher` (SetState DOES fire video watchers). ⚠ REQUIRED gotcha: cultural objects must stay **OUT of static-mesh consolidation** (`RecurseStaticObject` — same exclusion class as banded LODs/shadows), else the merged mesh draws the intact model forever -regardless of DrawObj. Explosion visual = the psfx route (effect 1008 observed). Verified live -(BT_GOTO ram): HIDE×2 + SHOW(rubble), contacts cease, mech occupies the footprint. Diags: -`BT_CULT_LOG` (census/damage/death + `[cultvis]`). MP: replicants transition via -`ReadUpdateRecord` → same SetState watcher path (untested cross-pod). +regardless of DrawObj. ⚠ **STATUS (human playtest 2026-07-19): PARTIAL — the intact model now +hides on death (the truck "disappears"), but the RUBBLE never appears and no explosion/fire is +visible.** The switch's SHOW sets DrawObj on the rubble CHILD component (DCSInstanceRenderable/ +DPLStaticChildRenderable) — those child classes are themselves 2007-stubbed and never draw their +object, so the swap is a no-op on screen; and the death Explosion routes effect 1008 (ddam5 +psfx) but nothing visible appears (+ the authentic BurningState fire visual is entirely +unreconstructed). REMAINING: make the rubble genuinely draw (or hang it off the drawn root), +a visible death explosion, the burning-fire state. LESSON: `[cultvis]` log lines verify the +STATE MACHINE, not pixels — only a human screen check verifies rendering. Diags: `BT_CULT_LOG` +(census/damage/death + `[cultvis]`). MP: replicants transition via `ReadUpdateRecord` → same +SetState watcher path (untested cross-pod). ## Key Relationships - Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]]. From 5e7b4e82321e4bb7a1e98c8cc152f73fdcc327fd Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 09:56:15 -0500 Subject: [PATCH 04/17] Cultural rubble RENDERS: additional objects were drawing at the world origin (issue #3) The playtest falsified the first revival: the truck vanished with no wreck. Cause: the rubble (and any additional cultural object) was built as a DPLStaticChildRenderable / DCSInstanceRenderable added at TOP LEVEL of mRenderables -- those classes read the bare matrix stack (identity at top level), so the rubble drew at the WORLD ORIGIN, not at the icon (and before the consolidation exclusion it was consolidated there permanently). Fix: every cultural video object (intact AND rubble) is a RootRenderable (Static) -- placed from myEntity->localToWorld, registered into the frame pass lists by the same per-frame Execute path the intact model draws through. The revived state switch then genuinely swaps them. PIXEL-VERIFIED via BT_SHOT screenshot timeline (full-speed BT_GOTO ram, ARENA1 MECHMOVR truck): intact truck visible -> ram/death -> the MECHMD wreck debris renders scattered at the truck's exact spot, intact model gone, mech walks the footprint. ARENA1 census: 57 rubble-typed objects across the 124 icons (MECHMD/bpip1d/ab07d_FR...). NOT yet closed (open on #3): (a) the wreck's scorch base quad draws as an opaque BLACK rectangle (13 plain ops on MECHMD -- material/ramp resolve, not a blend flag); (b) the death explosion (psfx 1008) still not visibly confirmed; (c) the authored burning-fire state visual unreconstructed. Diag added: [cultobj] census (BT_CULT_LOG) -- per-object file/type/op flags. Co-Authored-By: Claude Fable 5 --- engine/MUNGA_L4/L4VIDEO.cpp | 76 ++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index 7021838..6bf777d 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -7139,6 +7139,15 @@ void continue; // next object } + // BT_CULT_LOG (issue #3): per-object census -- name/type + draw-op + // flags (hunting the opaque-black scorch quad on rubble models). + if (getenv("BT_CULT_LOG")) + { + DEBUG_STREAM << "[cultobj] entity=" << (long)entity->GetEntityID() + << " file=" << object_filename + << " type=" << (int)resource_type << std::flush; + } + SET_VIDEO_LOAD_OBJECT(); // HACK !!! load the object as uncached so entity identification will work right // this_object = dpl_LoadObject(object_filename, dpl_load_normal); @@ -7153,6 +7162,21 @@ void continue; // next object } } + else if (getenv("BT_CULT_LOG")) + { + // [cultobj] tail: the loaded object's per-op flags + DEBUG_STREAM << " ops=" << this_object->GetDrawOpCount(); + for (int opn = 0; opn < this_object->GetDrawOpCount(); opn++) + { + const L4DRAWOP *op = this_object->GetDrawOp(opn); + DEBUG_STREAM << " [" << opn + << " aT=" << (int)op->alphaTest + << " dcl=" << (int)op->drawAsDecal + << " sky=" << (int)op->drawAsSky << "]"; + } + DEBUG_STREAM << " shadow=" << (int)this_object->GetIsShadow() + << "\n" << std::flush; + } if (first_object) { first_object = False; @@ -7177,40 +7201,24 @@ void } else { - if (renderer_modes | L4VideoObject::BillboardObject) - { - //------------------------------------------ - // attach additional object to separate DCS - // because it is billboarded - //------------------------------------------ - component = new DPLStaticChildRenderable( - entity, - false, - this_object, - intersect_mode, - intersect_mask, - offset_matrix, - NULL); -// this_DCS = this_child->GetDCS(); -// this_instance = this_child->GetInstance(); - } - else - { - //---------------------------------------- - // attach additional objects to root_DCS - // (HACK) temporary implementation (HACK) - //---------------------------------------- - component = new DCSInstanceRenderable( - entity, // Entity to attach the renderable to - DCSInstanceRenderable::Static, // How/when to execute the renderable - this_object, // object to connect to the instance - NULL, // the DCS to add the instance to - intersect_mode, // type of intersections to do on this object - intersect_mask, // intersection mask for the object - True); // initial visibility setting - //this_DCS = NULL; -// this_instance = another_instance->GetInstance(); - } + //------------------------------------------------------------ + // Issue #3 (cultural destruction): additional cultural objects + // (the RUBBLE model above all) used to go to + // DPLStaticChildRenderable / DCSInstanceRenderable -- but as + // TOP-LEVEL mRenderables entries those classes read the bare + // matrix stack (identity), so the rubble drew at the WORLD + // ORIGIN, not at the icon (and pre-revival it consolidated + // there permanently). RootRenderable places from + // myEntity->localToWorld -- the same path the intact (first) + // object draws through -- so every cultural object is one. + //------------------------------------------------------------ + component = new RootRenderable( + entity, // Entity to attach the renderable to + RootRenderable::Static, // How/when to execute the renderable + this_object, // the additional object (rubble etc.) + false, // DPL Zone (for culling) + intersect_mode, // type of intersections to do on this object + intersect_mask); // intersection mask for the object } // add the new object to our renderables list. Keep the draw // component in hand -- the revived state switch (issue #3) From df2a97fe20648a6ef0a30b9f168977165a9bae02 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 09:56:58 -0500 Subject: [PATCH 05/17] KB: rubble placement fix status (pixel-verified; scorch/explosion/fire still open) Co-Authored-By: Claude Fable 5 --- context/rendering.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/context/rendering.md b/context/rendering.md index df76811..74ef8bb 100644 --- a/context/rendering.md +++ b/context/rendering.md @@ -259,17 +259,19 @@ RemakeEntity mechanism); `[video] Object` hides at BurningState, `Rubble` shows; `StateIndicator::AddVideoWatcher` (SetState DOES fire video watchers). ⚠ REQUIRED gotcha: cultural objects must stay **OUT of static-mesh consolidation** (`RecurseStaticObject` — same exclusion class as banded LODs/shadows), else the merged mesh draws the intact model forever -regardless of DrawObj. ⚠ **STATUS (human playtest 2026-07-19): PARTIAL — the intact model now -hides on death (the truck "disappears"), but the RUBBLE never appears and no explosion/fire is -visible.** The switch's SHOW sets DrawObj on the rubble CHILD component (DCSInstanceRenderable/ -DPLStaticChildRenderable) — those child classes are themselves 2007-stubbed and never draw their -object, so the swap is a no-op on screen; and the death Explosion routes effect 1008 (ddam5 -psfx) but nothing visible appears (+ the authentic BurningState fire visual is entirely -unreconstructed). REMAINING: make the rubble genuinely draw (or hang it off the drawn root), -a visible death explosion, the burning-fire state. LESSON: `[cultvis]` log lines verify the -STATE MACHINE, not pixels — only a human screen check verifies rendering. Diags: `BT_CULT_LOG` -(census/damage/death + `[cultvis]`). MP: replicants transition via `ReadUpdateRecord` → same -SetState watcher path (untested cross-pod). +regardless of DrawObj. **RUBBLE PLACEMENT FIX (2026-07-19, pixel-verified via BT_SHOT):** the +rubble originally never appeared because additional cultural objects were built as +DPLStaticChildRenderable/DCSInstanceRenderable at TOP level — those read the bare matrix stack +(identity), so the rubble drew at the WORLD ORIGIN. Every cultural video object is now a +`RootRenderable` (places from `myEntity->localToWorld`). Screenshot timeline confirms: intact +MECHMOVR truck → ram death → MECHMD wreck debris scattered at the truck's spot. ⚠ STILL OPEN +(#3): (a) the wreck's scorch base quad draws as an opaque BLACK rectangle (MECHMD's own op — +material/ramp resolve); (b) the death Explosion (psfx 1008 ddam5) not visually confirmed; +(c) the authored BurningState fire visual unreconstructed. LESSON: `[cultvis]` log lines verify +the STATE MACHINE, not pixels — only a screen check (BT_SHOT / human) verifies rendering. +Diags: `BT_CULT_LOG` (census/damage/death + `[cultvis]` + `[cultobj]` per-object file/type/op +flags). MP: replicants transition via `ReadUpdateRecord` → same SetState watcher path (untested +cross-pod). ## Key Relationships - Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]]. From 83385b066114c3efb784aec9195bd791fbda44cf Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 10:06:05 -0500 Subject: [PATCH 06/17] KB: issue-3 pickup trail -- bvx9_mtl scorch quad + crunch-vs-full explosion Human-verified: wreck swap works live; small ram explosion visible. Remaining detail recorded for the next session. Co-Authored-By: Claude Fable 5 --- context/rendering.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/context/rendering.md b/context/rendering.md index 74ef8bb..8909a6d 100644 --- a/context/rendering.md +++ b/context/rendering.md @@ -267,7 +267,12 @@ DPLStaticChildRenderable/DCSInstanceRenderable at TOP level — those read the b MECHMOVR truck → ram death → MECHMD wreck debris scattered at the truck's spot. ⚠ STILL OPEN (#3): (a) the wreck's scorch base quad draws as an opaque BLACK rectangle (MECHMD's own op — material/ramp resolve); (b) the death Explosion (psfx 1008 ddam5) not visually confirmed; -(c) the authored BurningState fire visual unreconstructed. LESSON: `[cultvis]` log lines verify +(c) the authored BurningState fire visual unreconstructed. PICKUP TRAIL for (a): the black +square is MECHMD.bgf patch 0 — a 4-vtx NO-NORMAL quad (VTX_XYZ_UV), material `basev:bvx9_mtl` +(BASEV.BMF; scratchpad/dump_bmf.py truncates before its record) — the no-normal ramp/material +resolve path renders it void-black instead of the authored scorch. For (b): the ram fires the +CRUNCH explosion res 31 (small, human-seen); res 32 is the icon's non-crunch explosion — +check which the weapon-kill path should fire + psfx scale vs period footage. LESSON: `[cultvis]` log lines verify the STATE MACHINE, not pixels — only a screen check (BT_SHOT / human) verifies rendering. Diags: `BT_CULT_LOG` (census/damage/death + `[cultvis]` + `[cultobj]` per-object file/type/op flags). MP: replicants transition via `ReadUpdateRecord` → same SetState watcher path (untested From 557e9fd243e63ad5d45725f6602a6029c20053e9 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 10:12:17 -0500 Subject: [PATCH 07/17] Wreck scorch quad: kill the black-rectangle tint + cross-library textures (issue #3) The opaque BLACK rectangle under a destroyed truck was basev:bvx9_mtl (the MECHMD wreck's 4-vtx scorch base): the material authors NO diffuse, ambient (0,0,0), texture bexp9_tex (MAP 'bexp' slice 8, BEXP.BSL) + ramp cdusty. collectMaterials' AMBIENT fallback took the all-zero ambient as a REAL colour (hasDiffuse=true, black) and tinted the whole quad black regardless of its texture. An all-zero ambient means UNSET, not 'tint black' (a black tint renders geometry invisible -- never authored intent): the fallback now requires r+g+b > 0.001. Pixel-verified (BT_SHOT ram run): the scorch base draws as the authored charred burn patch under the wreck debris. Also: cross-library TEXTURE registry (globalTexMaps, BT_TEX_XLIB=0 disables) mirroring the existing globalRamps -- material libs reference textures DEFINED in other libs (bexp9_tex is defined in BTARENA/BTFX, not BASEV.BMF); a per-file texMaps miss left such batches untextured. Same first-wins sweep of the indexed BMFs on first miss. Regression scope: only materials authoring a zero ambient AND no diffuse change (they now ramp/texture instead of black); cross-lib refs that previously resolved keep their same-file definitions (checked first). Awaiting human verification in a live session (issue #3 stays open). Co-Authored-By: Claude Fable 5 --- engine/MUNGA_L4/bgfload.cpp | 59 ++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/engine/MUNGA_L4/bgfload.cpp b/engine/MUNGA_L4/bgfload.cpp index 0918420..28e6b08 100644 --- a/engine/MUNGA_L4/bgfload.cpp +++ b/engine/MUNGA_L4/bgfload.cpp @@ -421,6 +421,36 @@ struct MaterialResolver { return reg; } + // CROSS-LIBRARY TEXTURE REGISTRY (issue #3, the black scorch quad): material + // libs also reference TEXTURES defined in OTHER libs -- MECHMD.bgf's scorch + // base uses basev:bvx9_mtl whose texture `bexp9_tex` is defined in the FX + // libs (image in BEXP.BSL), not in BASEV.BMF. A per-file texMaps lookup + // left the quad untextured, drawn with its zero ambient = an opaque BLACK + // rectangle. Same shape as the ramp registry above: on first cross-file + // miss, sweep the indexed BMFs once and cache every TEXTURE definition + // (first stem in priority order wins; same-file defs are preferred before + // this registry is hit). BT_TEX_XLIB=0 disables. + static const std::map& globalTexMaps() { + static std::map reg; + static bool built = false; + if (!built) { + built = true; + const char* v = getenv("BT_TEX_XLIB"); + if (v == nullptr || v[0] != '0') { + for (const auto& kv : bmfIndex()) { + std::vector buf = readFileBytes(kv.second); + if (buf.size() <= 8 || std::memcmp(buf.data(), "DIV-BIZ2", 8) != 0) continue; + std::vector roots; + if (!parseRange(buf.data() + 8, buf.data() + buf.size(), roots)) continue; + std::map maps; + collectTextureMaps(roots, maps); + for (const auto& m : maps) reg.insert(m); // first wins + } + } + } + return reg; + } + static void collectMaterials(const std::vector& cs, const std::map& texMaps, const std::map >& ramps, @@ -438,7 +468,13 @@ struct MaterialResolver { info.color = packColor(rdF32(ch.data), rdF32(ch.data + 4), rdF32(ch.data + 8)); haveColor = true; info.hasDiffuse = true; - } else if (ch.id == TAG_AMBIENT && ch.len >= 12 && !haveColor) { + } else if (ch.id == TAG_AMBIENT && ch.len >= 12 && !haveColor + && (rdF32(ch.data) + rdF32(ch.data + 4) + rdF32(ch.data + 8)) > 0.001f) { + // AMBIENT fallback only when it carries a real colour: an + // all-zero ambient means "unset", not "tint black" -- e.g. + // basev:bvx9_mtl (the wreck scorch quad, issue #3) authors + // ambient (0,0,0) + texture + ramp; the black fallback + // painted the whole quad as an opaque BLACK rectangle. info.color = packColor(rdF32(ch.data), rdF32(ch.data + 4), rdF32(ch.data + 8)); haveColor = true; info.hasDiffuse = true; @@ -467,16 +503,25 @@ struct MaterialResolver { } } if (!name.empty() && (haveColor || !texName.empty() || info.hasRamp)) { + const TexRef* ref = nullptr; auto tm = texMaps.find(texName); - if (tm != texMaps.end()) { + if (tm != texMaps.end()) ref = &tm->second; + else if (!texName.empty()) { + // cross-library texture reference (see globalTexMaps): + // e.g. basev:bvx9_mtl -> bexp9_tex (defined in the FX libs) + const auto& reg = globalTexMaps(); + auto gt = reg.find(texName); + if (gt != reg.end()) ref = >->second; + } + if (ref != nullptr) { const auto& imgs = imageIndex(); - auto f = imgs.find(stemLower(tm->second.map)); + auto f = imgs.find(stemLower(ref->map)); if (f != imgs.end()) { info.texPath = f->second; - info.texChannel = tm->second.channel; - info.texScroll = tm->second.scroll; - info.texScrollU = tm->second.scrollU; - info.texScrollV = tm->second.scrollV; + info.texChannel = ref->channel; + info.texScroll = ref->scroll; + info.texScrollU = ref->scrollU; + info.texScrollV = ref->scrollV; } } out[name] = info; From 9fff079df4db1e7a0576c55847155e29ac51f3ea Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 10:39:43 -0500 Subject: [PATCH 08/17] Issue #3: wreck scorch quad ramp + RGBA4444 alpha cutout (the 'dark square') MECHMD's scorch base (basev:bvx9_mtl, bexp9_tex = BEXP.BSL RGBA4444 slice 8, ramp cdusty) drew as a hard-edged dark square for two stacked reasons: 1. The blanket 'truecolor BSL slice (channel >= 6) never ramps' gate blocked its cdusty ramp. Corpus scan: only 4 shipped textures use truecolor slices; bexp9/bdet9 are grayscale in RGB (100% / 98.8% r==g==b) and their materials author ramps -- only bdam8 (damage sheet) is truly coloured. bgfload rampableSlice() now probes the decoded slice: a truecolor slice ramps iff effectively gray (>=95%), keeping bdam8's colour protected. 2. The RGBA4444 authored alpha channel -- a binary 0/240 cutout mask (the splat silhouette, 78.5% transparent) -- was never alpha-tested, so the quad's transparent surround rendered as an opaque rectangle. New BgfDrawBatch.texAlpha (channel >= 8) routes these batches through the PUNCH alpha-test draw states, WITHOUT the black-texel keying (which would hole the near-black charred centre). Side benefit: tree9 (tree/leaf cards) + bdet9 (trans-rail lattice) get their authored cutouts too. Pixel-verified (ram-kill run, BT_SHOT): irregular char splat, dark brown (71,44,34) lifting to near-terrain tan (115,100,91 vs terrain 131,119,108), no rectangle; pre-death frames unregressed (vehicles stay lit/diffuse -- hasNormals gate untouched). Awaiting human verification. Co-Authored-By: Claude Fable 5 --- context/rendering.md | 24 ++++++++++++------ engine/MUNGA_L4/L4D3D.cpp | 12 +++++++-- engine/MUNGA_L4/bgfload.cpp | 50 ++++++++++++++++++++++++++++++++++--- engine/MUNGA_L4/bgfload.h | 10 ++++++++ 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/context/rendering.md b/context/rendering.md index 8909a6d..54e0188 100644 --- a/context/rendering.md +++ b/context/rendering.md @@ -264,13 +264,23 @@ rubble originally never appeared because additional cultural objects were built DPLStaticChildRenderable/DCSInstanceRenderable at TOP level — those read the bare matrix stack (identity), so the rubble drew at the WORLD ORIGIN. Every cultural video object is now a `RootRenderable` (places from `myEntity->localToWorld`). Screenshot timeline confirms: intact -MECHMOVR truck → ram death → MECHMD wreck debris scattered at the truck's spot. ⚠ STILL OPEN -(#3): (a) the wreck's scorch base quad draws as an opaque BLACK rectangle (MECHMD's own op — -material/ramp resolve); (b) the death Explosion (psfx 1008 ddam5) not visually confirmed; -(c) the authored BurningState fire visual unreconstructed. PICKUP TRAIL for (a): the black -square is MECHMD.bgf patch 0 — a 4-vtx NO-NORMAL quad (VTX_XYZ_UV), material `basev:bvx9_mtl` -(BASEV.BMF; scratchpad/dump_bmf.py truncates before its record) — the no-normal ramp/material -resolve path renders it void-black instead of the authored scorch. For (b): the ram fires the +MECHMOVR truck → ram death → MECHMD wreck debris scattered at the truck's spot. +**(a) SCORCH QUAD FIXED (2026-07-19, pixel-verified, awaiting human confirm):** the scorch base +(MECHMD patch 0, 4-vtx no-normal quad, `basev:bvx9_mtl` → bexp9_tex = BEXP.BSL **RGBA4444 +slice 8**, ramp `cdusty`) drew as a hard dark SQUARE for TWO stacked reasons: (1) the blanket +"truecolor slice (channel ≥ 6) never ramps" gate blocked its cdusty ramp — but only 4 shipped +textures use truecolor slices, and bexp9/bdet9 are GRAYSCALE in RGB (100% / 98.8% r==g==b +texels; only bdam8 is truly coloured) — `bgfload rampableSlice()` now probes the decoded slice +(a truecolor slice ramps iff effectively gray, ≥95%; bdam8's colour stays protected); (2) the +RGBA4444 **authored alpha** (a binary 0/240 splat CUTOUT mask, 78.5% transparent) was never +alpha-tested — new `BgfDrawBatch.texAlpha` (channel ≥ 8) routes through the PUNCH alpha-test +draw states WITHOUT the black-texel keying (which would hole the near-black charred centre). +Side benefit: tree9 (GRASS tree/leaf cards) + bdet9 (trans-rail lattice) get their authored +cutouts too. Post-death shot: irregular char splat, dark brown (71,44,34) lifting to +near-terrain tan (115,100,91 vs terrain 131,119,108), no rectangle; pre-death frames +unregressed (vehicles stay lit/diffuse — the `hasNormals` gate is untouched). [T2] ⚠ STILL OPEN +(#3): (b) the death Explosion (psfx 1008 ddam5) not visually confirmed; +(c) the authored BurningState fire visual unreconstructed. For (b): the ram fires the CRUNCH explosion res 31 (small, human-seen); res 32 is the icon's non-crunch explosion — check which the weapon-kill path should fire + psfx scale vs period footage. LESSON: `[cultvis]` log lines verify the STATE MACHINE, not pixels — only a screen check (BT_SHOT / human) verifies rendering. diff --git a/engine/MUNGA_L4/L4D3D.cpp b/engine/MUNGA_L4/L4D3D.cpp index 60b129f..f1e8107 100644 --- a/engine/MUNGA_L4/L4D3D.cpp +++ b/engine/MUNGA_L4/L4D3D.cpp @@ -437,7 +437,12 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName) // become alpha-0 holes below; DrawMesh alpha-TESTS them in the opaque pass. static int s_punch = -1; if (s_punch < 0) { const char *pv = getenv("BT_PUNCH"); s_punch = (pv == 0 || pv[0] != '0') ? 1 : 0; } - object->mDrawOps[i].punchThrough = (s_punch && data.batches[i].punch); + // PUNCH batches alpha-test load-KEYED holes (black texels). TEXTURE-ALPHA + // batches (RGBA4444 BSL slice, issue #3: the wreck scorch splat / rail + // lattice / tree cards) alpha-test their AUTHORED alpha channel -- same + // draw states, no keying (the near-black splat centre must stay opaque). + object->mDrawOps[i].punchThrough = + (s_punch && data.batches[i].punch) || data.batches[i].texAlpha; object->mDrawOps[i].copRole = data.batches[i].copRole; // punch stencil-cut kit (task #55) object->mDrawOps[i].texture.texture = NULL; object->mDrawOps[i].texture.wrap_u = L4TEXOP::REPEAT; @@ -493,7 +498,10 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName) { // PUNCH keying: black texels of a punch batch become alpha 0 // (holes). Keyed on the ORIGINAL texel, before any ramp. - const bool punchKey = object->mDrawOps[i].punchThrough; + // NOT applied to texture-alpha (RGBA4444) batches -- their + // cutout is the authored alpha channel, and black-keying + // would hole the scorch splat's charred (near-black) centre. + const bool punchKey = (s_punch && data.batches[i].punch); if (useRamp) { // Colorize: for each texel, index the low->high ramp by diff --git a/engine/MUNGA_L4/bgfload.cpp b/engine/MUNGA_L4/bgfload.cpp index 28e6b08..197bc6e 100644 --- a/engine/MUNGA_L4/bgfload.cpp +++ b/engine/MUNGA_L4/bgfload.cpp @@ -1,6 +1,7 @@ // bgfload.cpp - see bgfload.h. Parse logic ported from port/src/bgf.cpp; directory // scanning reimplemented on Win32 (FindFirstFile) so it builds under C++14. #include "bgfload.h" +#include "image.h" // truecolor-slice grayscale probe (rampableSlice, issue #3) #include #include @@ -247,6 +248,40 @@ bool hasNormals(uint16_t id) { return id == TAG_VERTEX_XYZ_N || id == TAG_VERTEX_XYZ_N_UV; } +// TRUECOLOR-slice ramp eligibility (issue #3, the wreck scorch quad). Only 4 +// shipped textures use a truecolor BSL slice (bdam8=RGB444; bdet9/bexp9/tree9= +// RGBA4444). bexp9 (MECHMD's scorch splat, material basev:bvx9_mtl ramp +// 'cdusty') and bdet9 (the trans-rail lattice, ramps 'softer'/'cdusty') are +// GRAYSCALE in their RGB (100% / 98.8% r==g==b texels, corpus-measured) -- they +// author the same luminance->ramp colouring as every mono slice, and the old +// blanket "channel < 6" gate left the scorch a raw dark quad instead of the +// cdusty dark-brown->dusty-tan gradient. bdam8 (the damage sheet) is genuinely +// COLOURED (33% non-gray texels) -- luminance-ramping it would destroy its +// colour, which is what the blanket gate was protecting. Gate on the actual +// slice content: a truecolor slice ramps only when its RGB is effectively +// grayscale. Cached per path#channel (decode runs once per texture). +bool rampableSlice(const std::string& texPath, int channel) { + if (channel < 6 || texPath.empty()) return true; // mono slices always ramp-eligible + static std::map cache; + std::string key = texPath; + key += '#'; + key += (char)('0' + (channel % 10)); + auto it = cache.find(key); + if (it != cache.end()) return it->second; + bool gray = false; + Image img = decodeImage(texPath, channel); + if (img.ok && !img.argb.empty()) { + size_t same = 0; + for (uint32_t p : img.argb) { + const uint8_t r = (uint8_t)(p >> 16), g = (uint8_t)(p >> 8), b = (uint8_t)p; + if (r == g && g == b) ++same; + } + gray = ((float)same / (float)img.argb.size()) >= 0.95f; + } + cache[key] = gray; + return gray; +} + struct Chunk { uint16_t id = 0; const uint8_t* data = nullptr; @@ -689,10 +724,13 @@ struct Builder { // RAMP applies ONLY to no-normal geometry (see hasNormals()): normal-bearing // vehicle/missile batches keep their material diffuse colour + texture (lit path), // so they must NOT be ramped even when the material carries a ramp ref (cdusty). - // TRUECOLOR BSL slices (channel >= 6: RGB444/RGBA4444, e.g. damcolor's bdam8 - // damage sheet) are real colour images -- luminance-ramping them would - // destroy their colour, so they take the plain textured path. - const bool useRamp = currentHasRamp && (!hasNormals(vtag) || currentTSphere) && currentTexChannel < 6; + // TRUECOLOR BSL slices (channel >= 6: RGB444/RGBA4444) ramp only when their RGB + // is effectively GRAYSCALE (rampableSlice): bexp9/bdet9 are authored gray + ramp + // (the wreck scorch splat's cdusty gradient, issue #3); damcolor's bdam8 damage + // sheet is a real colour image -- luminance-ramping it would destroy its colour, + // so it takes the plain textured path. + const bool useRamp = currentHasRamp && (!hasNormals(vtag) || currentTSphere) + && rampableSlice(currentTex, currentTexChannel); const uint32_t base = (uint32_t)px.size(); const uint32_t localCount = (uint32_t)lx.size(); @@ -839,6 +877,10 @@ struct Builder { batch.texScrollU = currentTexScrollU; batch.texScrollV = currentTexScrollV; batch.vertexAlpha = vertexAlpha; + // RGBA4444 slice (channel >= 8): the texture's AUTHORED alpha channel is + // a cutout mask (bexp9 scorch splat / bdet9 rail lattice / tree9 cards) + // -- the renderer alpha-tests it in the opaque pass (see bgfload.h). + batch.texAlpha = (currentTexChannel >= 8) && !currentTex.empty(); batch.hasRamp = useRamp; // tsphere warp flag: warpBands>0 signals L4D3D's ramp bake to CONTRAST- // COMPRESS the bintA cloud toward mid-grey (BT_WARP_CONTRAST) before applying diff --git a/engine/MUNGA_L4/bgfload.h b/engine/MUNGA_L4/bgfload.h index 5c27558..32e2d8e 100644 --- a/engine/MUNGA_L4/bgfload.h +++ b/engine/MUNGA_L4/bgfload.h @@ -44,6 +44,16 @@ struct BgfDrawBatch { // alpha). The verts keep their authored RGBA gradient and the draw routes // to the alpha-blend pass, unlit, colour = texture x vertex gradient. bool vertexAlpha = false; + // TEXTURE ALPHA CUTOUT (issue #3, the wreck scorch splat): an RGBA4444 BSL + // slice (texChannel >= 8) carries an AUTHORED alpha channel -- in the shipped + // content it is a BINARY 0/240 cutout mask (bexp9 = the scorch splat + // silhouette, 78.5% transparent; bdet9 = the trans-rail lattice; tree9 = the + // tree/leaf cards). Drawn opaque, the quad's transparent surround renders as + // a hard-edged rectangle (the "dark square" under the wreck). The renderer + // alpha-TESTS these batches in the opaque pass (same states as PUNCH, but the + // alpha comes from the texture itself -- no black-texel keying, which would + // hole the near-black charred splat centre). + bool texAlpha = false; // RAMP (task #20): colorize the grayscale texture by luminance across // rampLo->rampHi (the authentic IG-board terrain colouring). When set, the // renderer bakes lerp(rampLo, rampHi, texLum) into the texture and draws with From 6f6a39b8c9a46c727018faebbb94c7e49efe6466 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 12:02:15 -0500 Subject: [PATCH 09/17] Issue #3 (b)+(c): weapon damage reaches cultural icons -> full trkdead explosion + authored burn fire (b) The 'tiny explosion' on the ram kill is AUTHORED: crunch res 31 = 'stephit' (one video object, effect 1008 = ddam5 damage smoke) -- a step/ram squash is small by design. The FULL explosion res 32 = 'trkdead' = psfx 15 dtrkboom (fiery omni burst) + psfx 16 dtrkburn (the burning-wreck fire), reached by WEAPON kills -- which the 1995 binary dispatches [T1]: - MechWeapon::SendDamageMessage @004b9728 (part_013.c:6765) gates only "target NOT derived from Mech@0x50bdb4 OR aimed zone set": a non-Mech boresight target takes the zone=-1 damage UNCONDITIONALLY; - Missile contact @004be078 dispatches at the struck solid's OWNER entity with no class test at all. Port wiring: Mech::WorldStructurePick returns the struck solid's owning entity (BoxedSolid::GetOwningSimulation; TERRAIN.cpp:107/246 build every static solid with its Terrain/CulturalIcon/Door entity as owner); the mech4 pick block designates IT instead of the gBTTerrainEntity sentinel (sentinel = fallback); the projectile contact path grew the non-mech damage-zoned else-branch (@004be078 mirror, direct Dispatch). Plain terrain ignores the damage (ENTITY.cpp:885 zone==-1 guard); an icon's handler maps -1 -> 0 and dies. Truck armor is WeaponDamagePoints=1 (TRK.DMG): one laser = one dead truck. (c) The burning fire is dtrkburn.pfx, authored INTO the death package (every icon family carries 1016: trkdead/bigdead/meddead/msldead/twrdead). There is NO looping BurningState fire in the binary: the damage-zone effect watcher ctor @0042a984 has exactly one call site (the Mech ctor, part_012.c:10405), so icon ExplosionTables are inert, and CulturalIcon has no Performance. Documented as authentic -- nothing invented. Verified live (BT_SHOT pixel capture): 26 laser kills, [cult] TakeDamage type=3 -> DYING res=32 -> DPLIndependantEffect 1015+1016 at the icon origin; frames show the orange dtrkboom fireball, dtrkburn flames on the fresh wreck, and the rubble aftermath; ram harness re-run still fires crunch res 31. Diags: BT_FIRE_AT_ICON (designate nearest ahead icon; live-icon census in CULTURAL.cpp), BT_FX_TEST="1015,1016". Co-Authored-By: Claude Fable 5 --- context/combat-damage.md | 19 +++++- context/rendering.md | 36 ++++++++++-- engine/MUNGA/CULTURAL.cpp | 23 +++++++- game/reconstructed/mech.cpp | 17 +++++- game/reconstructed/mech.hpp | 11 +++- game/reconstructed/mech4.cpp | 108 +++++++++++++++++++++++++++++++++-- 6 files changed, 198 insertions(+), 16 deletions(-) diff --git a/context/combat-damage.md b/context/combat-damage.md index 803ba8f..e7d5a2c 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -131,7 +131,24 @@ now: closest MECH (`PickRayHit`, damage zones + lock) → closest STRUCTURE (`Wo occludes a mech BEHIND it) → flat ground (`BTGroundRayHit`) → sky (fire-at-nothing). A structure hit designates `mech+0x388 = gBTTerrainEntity` (the no-damage-zone sentinel) + `0x37c` = the entry point, so the range caret reads the structure distance and NO lock ring draws (mech4.cpp:4529, the -`des != hotTarget` branch), exactly the reported authentic behavior. ⚠ There is NO ray query +`des != hotTarget` branch), exactly the reported authentic behavior. +**⚠ SUPERSEDED IN PART (2026-07-19, issue #3): the struck ENTITY, not the sentinel, is now +designated.** The binary damages world structures/icons under the boresight [T1]: +`MechWeapon::SendDamageMessage` @004b9728 (part_013.c:6765-6794) gates only on "target NOT derived +from Mech (Derivation@0x50bdb4) OR aimed zone != -1" — i.e. a NON-MECH target is sent the zone=−1 +TakeDamageMessage UNCONDITIONALLY — and the Missile contact branch @004be078 dispatches at +whatever entity's solid the round struck (owner via the solid's tag pointer) with NO class test. +The port: `Mech::WorldStructurePick` (mech.cpp) now returns the struck solid's owning entity +(`BoxedSolid::GetOwningSimulation` — every static solid carries its Terrain/CulturalIcon/Door +entity, TERRAIN.cpp:107/246, DOOR.cpp:395), the mech4.cpp pick block designates it (sentinel only +as fallback), and the projectile contact path grew the non-mech damage-zoned else-branch +(@004be078 mirror, direct Dispatch). A plain terrain owner IGNORES the damage +(Entity::TakeDamageMessageHandler returns on zone==−1/no zones, ENTITY.cpp:885); a CulturalIcon's +own handler maps −1→0 and dies → its FULL res-32 `trkdead` explosion (dtrkboom+dtrkburn) instead +of only the ram crunch — see [[rendering]] §Cultural-icon DESTRUCTION. HUD unchanged: the icon is +never `hotTarget`, so no hotbox/lock ring (the `des != hotTarget` terrain branch). Verified live: +26 laser truck/prop kills (`[cult] TakeDamage type=3` → `DYING res=32`), ram crunch (res 31) +unregressed. [T2] ⚠ There is NO ray query against the ground/`containedByNode` `BoundingBoxTree` — `FindBoundingBoxUnder` is DOWNWARD-only (gravity/ground-snap: `*height = FindDistanceBelowBounded`), useless for a horizontal boresight; the static SOLID tree (`BoxedSolidTree::FindBoundingBoxHitBy`) is the only ray-vs-world query. diff --git a/context/rendering.md b/context/rendering.md index 54e0188..7ebd645 100644 --- a/context/rendering.md +++ b/context/rendering.md @@ -278,11 +278,37 @@ draw states WITHOUT the black-texel keying (which would hole the near-black char Side benefit: tree9 (GRASS tree/leaf cards) + bdet9 (trans-rail lattice) get their authored cutouts too. Post-death shot: irregular char splat, dark brown (71,44,34) lifting to near-terrain tan (115,100,91 vs terrain 131,119,108), no rectangle; pre-death frames -unregressed (vehicles stay lit/diffuse — the `hasNormals` gate is untouched). [T2] ⚠ STILL OPEN -(#3): (b) the death Explosion (psfx 1008 ddam5) not visually confirmed; -(c) the authored BurningState fire visual unreconstructed. For (b): the ram fires the -CRUNCH explosion res 31 (small, human-seen); res 32 is the icon's non-crunch explosion — -check which the weapon-kill path should fire + psfx scale vs period footage. LESSON: `[cultvis]` log lines verify +unregressed (vehicles stay lit/diffuse — the `hasNormals` gate is untouched). [T2] +**(b)+(c) CLOSED (2026-07-19, pixel-verified, awaiting human confirm).** The icon death +EXPLOSION packages are authored per KILL TYPE [T1, BTL4.RES byte-dump]: the CRUNCH +res 31 = `stephit` (ONE video object, effect **1008** = ddam5 damage smoke) — the ram/step +squash is SMALL **BY DESIGN**; the full res 32 = `trkdead` = **1015 dtrkboom.pfx** (fiery +omni burst: vel 150, rad 3.5 exp 10, orange 2.0/0.7/0.2→1.0/0.3/0, ~1.5s) + **1016 +dtrkburn.pfx** (the BURNING-WRECK FIRE: 30 rising fire-card particles over a 5s emission, +4±1s each ≈ a ~9s fire at the wreck). The whole icon family shares the pattern: +bigdead=[1017 dbigboom,1016], meddead=[1018,1016], msldead/twrdead=[1017,1016], +smldead=[1020,1022,1021]. **Item (c) verdict: the burning fire is the dtrkburn ONE-SHOT +authored INTO the death package** — there is NO looping BurningState fire: the +damage-zone effect watcher ctor @0042a984 has exactly ONE call site binary-wide (the +Mech ctor, part_012.c:10405), so icon ExplosionTables are inert, and CulturalIcon has no +Performance (AlwaysExecute is replication flush). Documented as authentic. **Item (b): +the res-32 path is reached by WEAPON kills, which the binary DOES dispatch [T1]:** +`MechWeapon::SendDamageMessage` @004b9728 (part_013.c:6765) gates only "target NOT +derived from Mech@0x50bdb4 OR aimed zone set" — a non-Mech target under the boresight is +damaged UNCONDITIONALLY (zone −1; the icon handler maps −1→0) — and the Missile contact +branch @004be078 dispatches a zone=−1 TakeDamageMessage at the struck solid's OWNER with +no class test at all. The port never delivered this because the structure pick designated +the `gBTTerrainEntity` SENTINEL; now `Mech::WorldStructurePick` returns the struck +solid's owning entity (`BoxedSolid::GetOwningSimulation` — every static solid is built +with its Terrain/CulturalIcon/Door entity as owner, TERRAIN.cpp:107/246) and mech4.cpp +designates IT (mech.cpp `WorldStructurePick`, mech4.cpp pick block + projectile contact +else-branch). Truck armor is `WeaponDamagePoints=1` (TRK.DMG) — one laser kills it. +Verified live: 26 laser kills `[cult] TakeDamage type=3` → `DYING … res=32` → +`DPLIndependantEffect 1015+1016` at the icon origin → BT_SHOT frames show the orange +dtrkboom fireball, dtrkburn flames on the fresh wreck, and the rubble aftermath; ram +harness re-run still fires res 31 (crunch unregressed). All 24 psfx slots (incl. 15/16) +load from the BTDPL.INI pages. Diag: `BT_FIRE_AT_ICON` (designate nearest ahead icon), +`BT_FX_TEST="1015,1016"`. LESSON: `[cultvis]` log lines verify the STATE MACHINE, not pixels — only a screen check (BT_SHOT / human) verifies rendering. Diags: `BT_CULT_LOG` (census/damage/death + `[cultvis]` + `[cultobj]` per-object file/type/op flags). MP: replicants transition via `ReadUpdateRecord` → same SetState watcher path (untested diff --git a/engine/MUNGA/CULTURAL.cpp b/engine/MUNGA/CULTURAL.cpp index a2a4ceb..4a53421 100644 --- a/engine/MUNGA/CULTURAL.cpp +++ b/engine/MUNGA/CULTURAL.cpp @@ -258,6 +258,15 @@ CulturalIcon::AttributeIndexSet& CulturalIcon::GetAttributeIndex() // Construction and Destruction // +// +// Live-icon census (issue #3 diag): lets the BT_FIRE_AT_ICON harness designate +// a real map icon deterministically (headless weapon-vs-icon verification). +// Registration bookkeeping only -- no behavior change; entries are removed in +// the dtor (interest streaming can delete icons on zone transitions). +// +Entity *gBTCultIcons[256]; +int gBTCultIconCount = 0; + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // CulturalIcon::CulturalIcon( @@ -266,6 +275,10 @@ CulturalIcon::CulturalIcon( ) : UnscalableTerrain(creation_message, shared_data) { + if (gBTCultIconCount < 256) + { + gBTCultIcons[gBTCultIconCount++] = this; + } Str_Copy(teamName, "Defualt", sizeof(teamName)); // @@ -428,7 +441,15 @@ CulturalIcon* // CulturalIcon::~CulturalIcon() { - + // live-icon census removal (swap-with-last; see the registration above) + for (int ii = 0; ii < gBTCultIconCount; ++ii) + { + if (gBTCultIcons[ii] == this) + { + gBTCultIcons[ii] = gBTCultIcons[--gBTCultIconCount]; + break; + } + } } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 9cf6a76..0722b05 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -563,7 +563,8 @@ Logical // Logical Mech::WorldStructurePick(const Point3D &start, const Vector3D &dir, - Scalar max_range, Point3D *hit_world) + Scalar max_range, Point3D *hit_world, + Entity **hit_owner) { Scalar dlen = (Scalar)Sqrt(dir.x*dir.x + dir.y*dir.y + dir.z*dir.z); if (dlen < 1e-6f) @@ -574,6 +575,20 @@ Logical return False; if (hit_world != 0) ray.FindEnd(hit_world); // clipped length -> entry point + if (hit_owner != 0) + { + // The struck solid's owning ENTITY (issue #3, item b). Every static solid + // carries its owner Simulation in the tag pointer (BoxedSolid ctor; + // TERRAIN.cpp:107/246 pass the Terrain/CulturalIcon `this`, DOOR.cpp:395 + // the Door) and Entity derives from Simulation at offset 0, so this is the + // struck map entity. The binary designates/damages the struck ENTITY: + // the weapon dispatch @004b9728 (part_013.c:6765) gates ONLY "target not + // derived from Mech@0x50bdb4 OR aimed zone set" -- a non-Mech target is + // damaged unconditionally -- and the missile contact @004be078 dispatches + // a zone=-1 TakeDamageMessage at the struck solid's owner with no class + // test at all. + *hit_owner = (Entity *)solid->GetOwningSimulation(); + } return True; } diff --git a/game/reconstructed/mech.hpp b/game/reconstructed/mech.hpp index 7e08268..5923d68 100644 --- a/game/reconstructed/mech.hpp +++ b/game/reconstructed/mech.hpp @@ -699,9 +699,16 @@ public: // tree (garages/walls/props -- the geometry that blocks the walk) along the // boresight and return the entry point of the closest structure hit. The // authentic non-mech world pick (Mover::FindStaticSolidHitBy, the static-world - // half of the engine's FindBoxedSolidHitBy). Defined in mech.cpp. + // half of the engine's FindBoxedSolidHitBy). hit_owner (optional) receives the + // struck solid's OWNING ENTITY (BoxedSolid::GetOwningSimulation -- every static + // solid is built with its Terrain/CulturalIcon/Door entity as owner, + // TERRAIN.cpp:107/246, DOOR.cpp:395): the binary targets/damages the struck + // ENTITY, not a sentinel (weapon dispatch @004b9728 sends to any non-Mech + // target; missile contact @004be078 dispatches at the struck solid's owner). + // Defined in mech.cpp. Logical WorldStructurePick(const Point3D &start, const Vector3D &dir, - Scalar max_range, Point3D *hit_world); + Scalar max_range, Point3D *hit_world, + Entity **hit_owner = 0); // Per-weapon beam render walk (task #33; extracted task #51 so it runs for // EVERY mech -- player, dummy masters, and MP replicants whose emitters diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index f003700..a11e74a 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -1461,6 +1461,32 @@ static void } } } + else if (tgt != 0 && !BTIsRegisteredMech(tgt) && p.damage > 0.0f + && tgt->damageZoneCount > 0) + { + // NON-MECH victim with damage zones (a CulturalIcon truck/prop + // designated by the world pick -- issue #3, item b). The binary + // missile dispatches a zone=-1 TakeDamageMessage DIRECTLY at + // whatever entity's solid the round struck, with NO class test + // (Missile::MoveAndCollide contact branch -> @004be078: msg id + // 100, zone 0xffffffff, invalid-zone flag 1 -> struck->Dispatch). + // The icon's own handler maps -1 -> 0 and runs the death + // transition (-> the FULL 'trkdead' explosion, res 32). + Damage dmg; + dmg.damageType = Damage::ExplosiveDamageType; + dmg.damageAmount = p.damage; + dmg.burstCount = 1; + dmg.impactPoint = p.pos; + Entity::TakeDamageMessage take_damage( + Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), + (p.shooter != 0) ? p.shooter->GetEntityID() : EntityID::Null, + -1 /*unaimed; icon handler maps -1 -> zone 0*/, dmg); + tgt->Dispatch(&take_damage); + if (getenv("BT_PROJ_LOG") || getenv("BT_CULT_LOG")) + DEBUG_STREAM << "[projectile] IMPACT (non-mech entity class=" + << (int)tgt->GetClassID() << ") damage=" << p.damage + << " (@004be078 direct dispatch)\n" << std::flush; + } p.active = 0; } } @@ -4607,10 +4633,11 @@ void Point3D structPoint; float structDist = 1e30f; bool haveStruct = false; + Entity *structOwner = 0; // the struck solid's owning map entity if (gBTTerrainEntity != 0) { Point3D sp; - if (WorldStructurePick(rayStart, rayDir, 1200.0f, &sp)) + if (WorldStructurePick(rayStart, rayDir, 1200.0f, &sp, &structOwner)) { float dx = sp.x-rs[0], dy = sp.y-rs[1], dz = sp.z-rs[2]; structDist = dx*dx + dy*dy + dz*dz; @@ -4623,6 +4650,8 @@ void DEBUG_STREAM << "[wpick] structure hit at (" << sp.x << "," << sp.y << "," << sp.z << ") dist=" << (float)Sqrt(structDist) + << " owner=" << (void *)structOwner + << " ownerClass=" << (structOwner ? (int)structOwner->GetClassID() : -1) << (hotTarget ? " (mech also under boresight)" : "") << "\n" << std::flush; } @@ -4637,11 +4666,21 @@ void } else if (haveStruct) { - // a world structure is the closest hit (garage/wall) -- or it - // occludes a mech behind it. Designate it as the non-mech world - // pick (the geometry rides in the POINT; the sentinel entity has - // no damage-zone table -> no lock ring, HudSim part_013.c:5620). - pickTarget = gBTTerrainEntity; + // a world structure is the closest hit (garage/wall/prop) -- or + // it occludes a mech behind it. Designate the struck solid's + // OWNING ENTITY (issue #3, item b): the binary's target slot + // 0x388 holds the struck entity itself, and its weapon damage + // dispatch @004b9728 (part_013.c:6765-6794) sends a zone=-1 + // TakeDamageMessage to ANY non-Mech target unconditionally -- + // which is what makes CulturalIcons (trucks/props) weapon- + // killable (-> the FULL 'trkdead' explosion, res 32, not just + // the ram 'stephit' crunch). A plain terrain owner ignores it + // (Entity::TakeDamageMessageHandler returns on zone==-1 with no + // zones, ENTITY.cpp:885); an icon's own handler maps -1 -> 0. + // Fall back to the world sentinel only if the solid carried no + // owner. No lock ring either way (hotTarget stays mech-only; + // HudSim part_013.c:5620). + pickTarget = (structOwner != 0) ? structOwner : gBTTerrainEntity; pickPoint = structPoint; ++gAimGround; } @@ -4744,6 +4783,63 @@ void } } + // DETERMINISTIC FIRE-AT-ICON TEST (BT_FIRE_AT_ICON, issue #3 item b + // verify): designate the NEAREST live (DefaultState) CulturalIcon as the + // weapon target every frame, bypassing the flaky headless aim ray, so + // BT_AUTOFIRE exercises the production weapon-vs-icon dispatch + // (SendDamageMessage -> icon TakeDamage -> res-32 'trkdead' explosion). + // Diag only, off by default. + if (getenv("BT_FIRE_AT_ICON")) + { + extern Entity *gBTCultIcons[256]; + extern int gBTCultIconCount; + // Prefer icons AHEAD (toward the BT_GOTO goal when set) so the kill + // happens in the chase camera's field of view -- pure capture-harness + // convenience, the dispatch path is identical either way. + float fwdX = 0.0f, fwdZ = 0.0f; int haveFwd = 0; + { + const char *gv = getenv("BT_GOTO"); + float gx, gz; + if (gv != 0 && sscanf(gv, "%f %f", &gx, &gz) == 2) + { + fwdX = gx - localOrigin.linearPosition.x; + fwdZ = gz - localOrigin.linearPosition.z; + haveFwd = 1; + } + } + Entity *bestIcon = 0; float bestR2 = 1e30f; + for (int ii = 0; ii < gBTCultIconCount; ++ii) + { + CulturalIcon *ic = (CulturalIcon *)gBTCultIcons[ii]; + if (ic == 0 || ic->GetSimulationState() != CulturalIcon::DefaultState) + continue; // already dying/burning + Point3D ip = ic->localOrigin.linearPosition; + float dx = ip.x - localOrigin.linearPosition.x; + float dz = ip.z - localOrigin.linearPosition.z; + if (haveFwd && (dx*fwdX + dz*fwdZ) <= 0.0f) + continue; // behind the walk direction -> off-camera + float r2 = dx*dx + dz*dz; + if (r2 < bestR2) { bestR2 = r2; bestIcon = (Entity *)ic; } + } + if (bestIcon != 0) + { + Point3D aimPt = ((CulturalIcon *)bestIcon)->localOrigin.linearPosition; + aimPt.y += 3.0f; // aim into the hull, not the base + MECH_TARGET_ENTITY(this) = bestIcon; + MECH_TARGET_SUBIDX(this) = -1; + MECH_TARGET_POS(this) = aimPt; + static float s_fai = 0.0f; s_fai += dt; + if (s_fai >= 1.0f) + { + s_fai = 0.0f; + DEBUG_STREAM << "[fireicon] designating icon entity=" + << bestIcon->GetEntityID() << " r=" << (float)Sqrt(bestR2) + << " (" << aimPt.x << "," << aimPt.y << "," << aimPt.z << ")\n" + << std::flush; + } + } + } + // HUD feeds: the range caret + the hotbox (world point + state) + the // recovered-Execute instruments (compass, twist tape, group mask). // The range caret tracks the PICK (authentic: :5639 computes it from From b85afed9257b879a681822c7c5b7398be7e51eb3 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 12:54:39 -0500 Subject: [PATCH 10/17] Torso-elevation aim LIVE: R/F now pitches the view + aim ray (pitch was inert) User + tester reports: pitch does not work. Root cause: the Torso sim integrated the R/F / stick-Y axis into currentElevation (authored limits and rates all correct) but NOTHING consumed it -- eyepointRotation's only writer was the look-state switch, so neither the cockpit view nor the aim ray (camera basis) ever pitched. Shots always flew level; low targets (trucks) were unhittable. Fix (faithful: the pod's stick-Y pitches the EYE; the torso geometry does not tilt): per-frame compose in the HUD tick -- eyepointRotation = EulerAngles(lookPitch + torsoElevation, lookYaw, 0); BTCommitLookState now stores its look component in gBTLookPitch/Yaw; new complete-type bridge BTGetTorsoElevation (torso.cpp, mirrors BTGetTorsoTwist). DPLEyeRenderable consumes it every frame, so the view and the boresight aim pitch together (crosshair stays screen-centred). Sign PIXEL-CALIBRATED via new diag BT_FORCE_ELEV=<-1..1> (pins the axis headless): screenshots confirm axis +1 (key R) = aim UP, -1 (F) = down. X still recenters. Awaiting human verification. Co-Authored-By: Claude Fable 5 --- context/cockpit-view.md | 12 ++++++++++ game/reconstructed/mech4.cpp | 43 ++++++++++++++++++++++++++++++++++++ game/reconstructed/torso.cpp | 12 ++++++++++ 3 files changed, 67 insertions(+) diff --git a/context/cockpit-view.md b/context/cockpit-view.md index 46a79f7..ce84d47 100644 --- a/context/cockpit-view.md +++ b/context/cockpit-view.md @@ -199,3 +199,15 @@ the shell green), `BT_COP_DUMP` (per-batch punch/bbox), `BT_EYE_FWD=` (eye po - Uses: [[bgf-format]] (PUNCH, vertex tags) · [[rendering]] (ramp model, skeleton branch) - Feeds: [[gauges-hud]] (the HUD overlays this view) · [[subsystems]] (gyro, task #56) - Gotchas: [[reconstruction-gotchas]] §14 (LookAt axis guess), §15 (per-patch edge counting) + +## Torso-elevation aim -> EYE PITCH (fixed 2026-07-19) [T2, pixel-calibrated] +User+tester report "pitch does not work": the Torso sim integrated R/F (stick-Y) +into `currentElevation` (authored limits/rates) but NOTHING consumed it — the +view and the aim ray (camera basis, `BTGetAimRay`) never pitched. Fix: the +per-frame HUD tick composes `eyepointRotation = EulerAngles(lookPitch + elev, +lookYaw, 0)` (mech4.cpp; look component stored by `BTCommitLookState` in +`gBTLookPitch/Yaw`; elevation via the new `BTGetTorsoElevation` torso.cpp +bridge). `DPLEyeRenderable` reads it every frame -> the view AND the boresight +aim pitch together (crosshair stays screen-centred, authentic). Sign +pixel-calibrated with `BT_FORCE_ELEV=<-1..1>` screenshots: axis +1 (key R) = +aim UP. The torso GEOMETRY does not tilt — only the eye, like the pod. diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index a11e74a..fd5a770 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -2716,6 +2716,18 @@ void sElev = 0.0f; // X recenters pitch too } gBTElevAxis = sElev; + // DIAG (BT_FORCE_ELEV=<-1..1>): pin the elevation + // axis for headless pitch verification (no focus). + { + static float s_fe = -2.0f; + if (s_fe < -1.5f) + { + const char *fe = getenv("BT_FORCE_ELEV"); + s_fe = fe ? (float)atof(fe) : -3.0f; + } + if (s_fe >= -1.0f) + gBTElevAxis = s_fe; + } if (getenv("BT_INPUT_LOG")) { static float s_eacc = 0.0f; s_eacc += dt; @@ -4933,6 +4945,28 @@ void extern float gBTEyeTwist; gBTEyeTwist = gBTHudTwist; } + + // TORSO-ELEVATION AIM -> EYE PITCH (issue #3 follow-up; user + + // tester report "pitch does not work"): the Torso sim integrated + // R/F into currentElevation (authored limits/rates) but NOTHING + // consumed it -- the view and the aim ray (camera basis) never + // pitched. The pod's stick-Y aim pitches the EYE (the torso + // geometry does not tilt); compose it per frame with the look-state + // angles into eyepointRotation, which DPLEyeRenderable reads every + // frame (VIEW = baseOffset * R). BTCommitLookState stores the + // look component in gBTLookPitch/Yaw (below); the elevation rides + // the pitch term. Pitch sign PIXEL-CALIBRATED (BT_FORCE_ELEV + // screenshots): −elev pitched the view DOWN for a positive axis; + // R (axis +1) must aim UP -> +elev. + { + extern float gBTLookPitch, gBTLookYaw; + extern Scalar BTGetTorsoElevation(Subsystem *); + const float elev = (float)BTGetTorsoElevation(GetTorsoSubsystem()); + eyepointRotation = EulerAngles( + Radian(Radian::Normalize(gBTLookPitch + elev)), + Radian(Radian::Normalize(gBTLookYaw)), + Radian(0.0f)); + } } // task #6 ORDER FIX: the scripted block must run BEFORE the fire-push @@ -6368,6 +6402,13 @@ int BTWeaponMountIsRear(void *ownerMech, int segIndex) return rear; } +// The current look-state eye component (pitch/yaw) -- stored so the per-frame +// eyepoint compose (the HUD tick above) can combine it with the live torso +// elevation. Single-viewpoint state (BTCommitLookState runs for the local +// player's mapper only), same pattern as the other viewpoint globals. +float gBTLookPitch = 0.0f; +float gBTLookYaw = 0.0f; + void BTCommitLookState(void *mech_v, int look_state) { Mech *m = (Mech *)mech_v; @@ -6387,6 +6428,8 @@ void BTCommitLookState(void *mech_v, int look_state) case 4: /*LookDown*/ pitch = m->lookFrontAngle; break; default: break; // LookNone: identity } + gBTLookPitch = (float)pitch; // per-frame compose adds elevation + gBTLookYaw = (float)yaw; m->eyepointRotation = EulerAngles( Radian(Radian::Normalize(pitch)), Radian(Radian::Normalize(yaw)), diff --git a/game/reconstructed/torso.cpp b/game/reconstructed/torso.cpp index b03191a..bd304d3 100644 --- a/game/reconstructed/torso.cpp +++ b/game/reconstructed/torso.cpp @@ -1040,6 +1040,18 @@ Scalar BTGetTorsoTwist(Subsystem *torso) return torso ? ((Torso *)torso)->CurrentTwist() : 0.0f; } +// +// Live torso ELEVATION (pitch aim, rad) -- Torso::currentElevation == the +// binary torso+0x1E4. Same complete-type-TU bridge pattern as +// BTGetTorsoTwist above. Consumed by the per-frame eyepoint compose +// (mech4.cpp): the R/F / stick-Y aim pitches the EYE only -- the torso +// geometry does not tilt, exactly like the pod. +// +Scalar BTGetTorsoElevation(Subsystem *torso) +{ + return torso ? ((Torso *)torso)->CurrentElevation() : 0.0f; +} + // // Task #56 bridge -- ADDRESS of the live torso twist for the gyro's external- // pitch pointer (binary bt_mech stream tail: gyro+0x258 = torso+0x1D8; the gyro From d9ceddb12ab943d26f5de4fba96fe3c6437963a2 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 13:44:49 -0500 Subject: [PATCH 11/17] Aim ray follows the torso elevation (the boresight leveling erased it) Follow-through on the pitch fix: the view pitched but shots still flew level (user report). Cause: the task-#48 AIM BORESIGHT leveling in the eye's aim-camera publish (L4VIDRND) strips ALL pitch from the pick-ray direction -- correct for the TERRAIN body pitch it was built against, but it also erased the pilot's deliberate R/F elevation. Fix: mech4's per-frame eye compose publishes the torso elevation (gBTEyeElev); the publisher re-applies it onto the LEVELED forward (fwd = (cos e * level, sin e)) before building the basis -- terrain pitch stays stripped, the deliberate aim survives into BTGetAimRay. Sign matches the pixel-calibrated view compose (+ = up). Harness evidence: pinned-down elevation visibly pitches the chase eye into the terrain (the ray basis moves); a full headless aimed-kill could not be driven (the autofire gate needs a designated target and the random spawns wedged short of the truck row) -- aim-at-truck kill needs the human pass. Awaiting human verification (issue #8). Co-Authored-By: Claude Fable 5 --- engine/MUNGA_L4/L4VIDRND.cpp | 22 ++++++++++++++++++++-- game/reconstructed/mech4.cpp | 6 ++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/engine/MUNGA_L4/L4VIDRND.cpp b/engine/MUNGA_L4/L4VIDRND.cpp index 84b2661..e3ba10e 100644 --- a/engine/MUNGA_L4/L4VIDRND.cpp +++ b/engine/MUNGA_L4/L4VIDRND.cpp @@ -11,6 +11,11 @@ float gBTEyeSwayX = 0.0f; // lateral weight-shift (the walk "swagger"), same sou // through the jointtorso HingeRenderable in the draw traversal (see the note in // DPLEyeRenderable::Execute). Kept published for diagnostics/correlation. float gBTEyeTwist = 0.0f; +// The player torso's live ELEVATION aim (rad), published by mech4.cpp's HUD +// tick. The aim-boresight leveling below (task #48) strips the TERRAIN body +// pitch from the pick ray -- this is the pilot's DELIBERATE pitch, re-applied +// after the leveling so aimed shots follow the R/F (stick-Y) elevation. +float gBTEyeElev = 0.0f; #pragma hdrstop #include "l4vidrnd.h" @@ -5835,11 +5840,24 @@ void if (D3DXVec3LengthSq(&fwdLevel) > 1e-6f) { D3DXVec3Normalize(&fwdLevel, &fwdLevel); - zax = D3DXVECTOR3(-fwdLevel.x, 0.0f, -fwdLevel.z); // back = -level fwd + // TORSO-ELEVATION AIM (pitch-fix follow-through): the + // leveling above strips the TERRAIN body pitch -- but the + // pilot's DELIBERATE R/F elevation must survive into the + // pick ray, or aimed shots fire level over low targets + // (the "still fires over the truck" report). Re-apply + // the published torso elevation about the level axis: + // fwd = (cos e * level, sin e). +e = aim UP (matches + // the view compose's pixel-calibrated sign). + extern float gBTEyeElev; + const float ce = cosf(gBTEyeElev); + const float se = sinf(gBTEyeElev); + D3DXVECTOR3 fwdAim(fwdLevel.x * ce, se, fwdLevel.z * ce); + D3DXVec3Normalize(&fwdAim, &fwdAim); + zax = D3DXVECTOR3(-fwdAim.x, -fwdAim.y, -fwdAim.z); // back = -aimed fwd D3DXVECTOR3 wup(0.0f, 1.0f, 0.0f); D3DXVec3Cross(&xax, &wup, &zax); D3DXVec3Normalize(&xax, &xax); - D3DXVec3Cross(&yax, &zax, &xax); // == world +Y + D3DXVec3Cross(&yax, &zax, &xax); } else { diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index fd5a770..25f7f59 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -4966,6 +4966,12 @@ void Radian(Radian::Normalize(gBTLookPitch + elev)), Radian(Radian::Normalize(gBTLookYaw)), Radian(0.0f)); + // ...and the AIM RAY: the boresight publisher (L4VIDRND task + // #48) LEVELS the view direction (drops terrain body pitch) -- + // publish the deliberate elevation so it re-applies onto the + // leveled boresight (else shots fire level over low targets). + extern float gBTEyeElev; + gBTEyeElev = elev; } } From 2ae9bd43aeb2d3641cbf956491e0c1f6ef2046d1 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 15:15:44 -0500 Subject: [PATCH 12/17] Coolant Flush end-to-end (Gitea #7): InjectCoolant id-4 handler + drain + FLUSH.PFX cloud + 'H' key The playtest report (sound plays, no gauge drop, no cloud) traced to the Reservoir's InjectCoolant chain being dead in three places: - The handler was never REGISTERED: the binary's Reservoir handler table @0x50e680 has one entry {4, "InjectCoolant", @4aee70}; added Reservoir::GetMessageHandlers + the press/release handler (press starts the flush gated on coolantLevel@0x12C > 0 -- the old body misread +0x12C as currentTemperature; release stops it; novice lockout via FUN_004ac9c8). - Reservoir::InjectCoolant (@4aefa4, 1019 bytes) was an empty stub -- the drain the coolant gauge reads. Reconstructed in full: work list = condenser/weapon/heatable chains (+0x7cc/+0x7bc/+0x7ac, roster-walk emulation with the binary's duplicate-visit weighting; HeatSink-filtered [T2 guarded]) + the linked master sink; per sink with flowScale != 0 move squirtMass x flowScale x dt (clamped to the tank / sink capacity) and credit pendingHeat with the negative carried-heat delta capped at sinkMass x reservoir startingTemperature -- the set%-biased flush of the manual (p24), riding the existing heat model. - Two latent ctor decode bugs surfaced and fixed: the master gate @4af408 (and @4aeb40 HeatWatcher, swept) reads the OWNER MECH's simulationFlags (*(param_2+0x28)), not the resource's subsystemFlags (the misread left the CoolantSimulation Performance unregistered); and the capacity scale FILDs the bank's INTEGER HeatSinkCount ((float10)*(int*)(link+0x1d0)) -- the float reinterpret gave ~1e-44 -> a permanently empty tank. Capacity = 0.05 x heatSinkCount x streamed CoolantCapacity (BLH: 6.0). Visual: the binary's mode-1 coolant-effect renderable (FUN_00456a68, built for classID 0xBC0 on "ReservoirState", part_014.c:5439; tick @part_007.c: 8780) starts psfx 19 = FLUSH.PFX ("Coolant flush", BTDPL.INI) when the state changes to 1 -- BTSpawnFlushCloud (mech4.cpp) spawns it on the same alarm edge as an attached emitter at torso height. Input: new CONTROLS.MAP action "Flush" on 'H' (HELD; press+release both dispatch, the held-button payload). Diags: BT_FLUSH_LOG, BT_FLUSH_TEST. Verified live (FOGDAY): [flush] Reservoir level 6 -> 0.13 -> 0 over ~0.6 s held (= the manual's 3-4 punches to empty), the coolant vertBar source Reservoir/CoolantMass drains, and the bluish condensation cloud rises from the mech (scratchpad/flush_cloud.png vs flush_before.png). Co-Authored-By: Claude Fable 5 --- content/CONTROLS.MAP | 3 +- context/gauges-hud.md | 8 + context/subsystems.md | 31 +++ game/reconstructed/btinput.cpp | 5 +- game/reconstructed/btinput.hpp | 1 + game/reconstructed/heatfamily_reslice.cpp | 274 ++++++++++++++++++---- game/reconstructed/heatfamily_reslice.hpp | 16 +- game/reconstructed/mech4.cpp | 92 ++++++++ 8 files changed, 384 insertions(+), 46 deletions(-) diff --git a/content/CONTROLS.MAP b/content/CONTROLS.MAP index 31c847a..9083631 100644 --- a/content/CONTROLS.MAP +++ b/content/CONTROLS.MAP @@ -31,7 +31,7 @@ # LeftTrigger RightTrigger (as digital, >= 50% pressed) # LeftStickX LeftStickY RightStickX RightStickY # LeftTrigger RightTrigger -# action ViewToggle LookBehind AllStop ModeCycle Valve ConfigHold +# action ViewToggle LookBehind AllStop ModeCycle Valve Flush ConfigHold # Generator1-4 Reconnect # pckey sends an authentic 1995 PC-keyboard hotkey (the in-cockpit # dispatcher): '+'/'-' = target zoom, 'w'..'o' = pilot select, @@ -73,6 +73,7 @@ key D4 button 0x45 # Pinky (4th group) # Systems. key G action ConfigHold # hold + fire keys = regroup the weapon key C action Valve # coolant valve cycle +key H action Flush # coolant flush (HOLD -- dumps reservoir coolant) key M action ModeCycle # Basic -> Standard -> Veteran controls key V action ViewToggle # cockpit <-> chase camera (dev) key B action LookBehind # HOLD = the pod's rear-view button diff --git a/context/gauges-hud.md b/context/gauges-hud.md index eee5d7e..a883052 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -155,6 +155,14 @@ so this is the NOVICE lockout, not a "ROOKIE role" — see [[experience-levels]] `BTPlayerRoleLocksAdvanced` bridge; bring-up seeding = 2 ≈ veteran = UNLOCKED, verified live: press → valveState 1→5 → flow redistribution 1/6 → 5/10). Desktop: 'C' cycles the selected condenser (BT_VALVE_SLOT). +**✅ The COOLANT FLUSH is LIVE (Gitea #7, 2026-07-19) [T2]:** `InjectCoolant` (id 4, the Reservoir +handler table @0x50e680, handler @4aee70) — HOLD flushes reservoir coolant through the loops +(InjectCoolant @4aefa4, set%-biased via each sink's flowScale); the coolant **vertBar C** +(`Reservoir/CoolantMass` = coolantLevel@0x12C, l4gauge.cfg:4526) drops live as the tank drains +(BLH: 6.0 → 0 in ~0.6 s held ≈ the manual's 3-4 punches); the bluish FLUSH.PFX condensation +cloud (psfx 19) spawns on the ReservoirState 0→1 edge. Desktop: **'H' HELD** (action Flush). +Full chain + the two ctor decode corrections it surfaced: [[subsystems]] §coolant FLUSH. +Diags: `BT_FLUSH_LOG`, `BT_FLUSH_TEST=`. The **MessageBoard** feed needs **StatusMessagePool** (a NULL stub) + the per-player status queue. `SeekVoltageGraph`'s 4 Seek* attrs are a cluster-child (not config-called). [T2] diff --git a/context/subsystems.md b/context/subsystems.md index ed0abaa..c5b2e8a 100644 --- a/context/subsystems.md +++ b/context/subsystems.md @@ -89,6 +89,37 @@ its electrical state. The full chain, byte-verified [T1]: - **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 coolant FLUSH (Gitea #7, 2026-07-19) — Reservoir InjectCoolant end-to-end [T2 live-verified] +The manual-p24 coolant button (coolant MFD top-right; punch or HOLD): message id **4 "InjectCoolant"** +on the Reservoir (handler table @**0x50e680**, one entry → @4aee70 — same per-receiver id space as the +Condenser's MoveValve @0x50E52C). Handler @4aee70: novice-lockout gate (FUN_004ac9c8), press +(data≥1) → `reservoirAlarm.SetLevel(1)` **gated on coolantLevel@0x12C > 0** (+300 decimal = 0x12C = +coolantLevel — the old "currentTemperature" reading was a misdecode), release → level 0; ForceUpdate. +While level==1 the CoolantSimulation Performance (@4aef78) runs **InjectCoolant @4aefa4** each frame: +work list = condenser chain @+0x7cc + weapon chain @+0x7bc + heatable chain @+0x7ac + linkedSinks +master (port walks the roster with the same membership tests — chains are SubProxy-dead; condensers/ +weapons/bank get the binary's duplicate-visit weighting; watcher-branch members are filtered to +HeatSink [T2 guarded]); per sink with flowScale@0x15C≠0: move `squirtMass@0x224 × flowScale × dt` +clamped to [-resLevel, 0] (reservoir drains → the coolant vertBar C `Reservoir/CoolantMass` drops), +sink level clamped [0, capacity], and `pendingHeat@0x1C8 += -(sinkE×|Δ|/sinkLvl − resE×|Δ|/resLvl)` +capped at `sinkMass@0x154 × res->startingTemperature@0x13C`, clamped ≤0 (only cools) — the heat +relief rides the existing heat model. **Two ctor corrections surfaced:** (1) the master gate @4af408 +(and @4aeb40 HeatWatcher) reads `*(param_2+0x28)` = the **owner MECH's simulationFlags**, not the +resource's subsystemFlags (the resource read left the Reservoir Performance unregistered → no drain); +(2) `(float10)*(int*)(link+0x1d0)` is a **FILD of the bank's integer HeatSinkCount** — capacity = +0.05 (`_DAT_004af518`) × heatSinkCount × streamed CoolantCapacity (BLH: 0.05×6×20 = 6), not a float +reinterpret (which gave ~1e-44 → empty tank). Visual: the binary's mode-1 coolant-effect renderable +(FUN_00456a68, built for classID 0xBC0 on the "ReservoirState" attr, part_014.c:5439; tick +@part_007.c:8780) spawns **psfx 19 = FLUSH.PFX** ("Coolant flush", BTDPL.INI:1018, bluish smoke) on +the state's 0→1 edge — port: `BTSpawnFlushCloud` (mech4.cpp) on the same alarm edge, attached emitter +at torso height. Audio (CoolantDump layers) rides the ReservoirState watchers. Desktop input: **'H' +HELD = Flush** (CONTROLS.MAP action; press+release dispatch). Diags: `BT_FLUSH_LOG` ([flush-tx] +press/release, [flush] level drain, cloud spawn), `BT_FLUSH_TEST=` scripted ~2 s hold. +Verified live (FOGDAY): level 6→0 over ~0.6 s held (≈3-4 short punches to empty — matches the +manual), bluish cloud screenshot-confirmed (scratchpad/flush_cloud.png). Replicant note: the state +receive @4aeef8 sets the alarm from the state stream — the port's reservoir state replication is not +wired, so remote clouds are deferred (solo/master path complete). + ## 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/game/reconstructed/btinput.cpp b/game/reconstructed/btinput.cpp index 2a0eceb..a2c32e9 100644 --- a/game/reconstructed/btinput.cpp +++ b/game/reconstructed/btinput.cpp @@ -71,6 +71,7 @@ enum BTActionID BTActAllStop, BTActModeCycle, BTActValve, + BTActFlush, // Gitea #7: HELD = coolant flush (InjectCoolant) BTActConfigHold, BTActGenerator1, BTActGenerator2, @@ -192,7 +193,7 @@ static const BTName sActionNames[] = { {"ViewToggle", BTActViewToggle}, {"LookBehind", BTActLookBehind}, {"AllStop", BTActAllStop}, {"ModeCycle", BTActModeCycle}, - {"Valve", BTActValve}, {"ConfigHold", BTActConfigHold}, + {"Valve", BTActValve}, {"Flush", BTActFlush}, {"ConfigHold", BTActConfigHold}, {"Generator1", BTActGenerator1}, {"Generator2", BTActGenerator2}, {"Generator3", BTActGenerator3}, {"Generator4", BTActGenerator4}, {"Reconnect", BTActReconnect}, @@ -263,6 +264,7 @@ static const char *sDefaultProfile = "key D4 button 0x45\n" "key G action ConfigHold\n" "key C action Valve\n" + "key H action Flush\n" "key M action ModeCycle\n" "key V action ViewToggle\n" "key B action LookBehind\n" @@ -962,6 +964,7 @@ void case BTActAllStop: next.allStop = 1; break; case BTActModeCycle: next.modeCycle = 1; break; case BTActValve: next.valve = 1; break; + case BTActFlush: next.flush = 1; break; case BTActConfigHold: next.configHold = 1; break; case BTActGenerator1: next.genSel = 4; break; case BTActGenerator2: next.genSel = 5; break; diff --git a/game/reconstructed/btinput.hpp b/game/reconstructed/btinput.hpp index 9698e6c..031ce12 100644 --- a/game/reconstructed/btinput.hpp +++ b/game/reconstructed/btinput.hpp @@ -52,6 +52,7 @@ struct BTInputState int allStop; int modeCycle; int valve; + int flush; // Gitea #7: coolant flush HELD (InjectCoolant) int configHold; int genSel; // 0 = none, 4..7 = Generator A..D, 8 = reconnect }; diff --git a/game/reconstructed/heatfamily_reslice.cpp b/game/reconstructed/heatfamily_reslice.cpp index 0722b72..ea2f78e 100644 --- a/game/reconstructed/heatfamily_reslice.cpp +++ b/game/reconstructed/heatfamily_reslice.cpp @@ -293,8 +293,12 @@ HeatWatcher::HeatWatcher( watchedSubsystem = subsystem_resource->watchedSubsystem; // this[0x4a] = res +0xE4 heatAlarm.Initialize(3); // FUN_0041b9ec(this+0x4b, 3) - if ((subsystem_resource->subsystemFlags & SegmentCopyMask) == 0 // (flags & 0xC) == 0 - && (subsystem_resource->subsystemFlags & MasterHeatSinkFlag) != 0) // flags & 0x100 + // (Gitea #7 sweep) the binary @4aeb40 tests *(param_2+0x28) = the OWNER + // MECH's simulationFlags (the same misread fixed in the Reservoir ctor; + // the watcher happened to work because its resource flags mirror the + // master bits on the player mech -- read the authentic source anyway). + if ((owner->simulationFlags & SegmentCopyMask) == 0 // (flags & 0xC) == 0 + && (owner->simulationFlags & MasterHeatSinkFlag) != 0) // flags & 0x100 { SetPerformance(&HeatWatcher::WatchSimulation); // this[7..9] = PTR 0050e634 -> @4aeac4 } @@ -559,17 +563,28 @@ Reservoir::Reservoir( coolantFlowScale = 0.0f; // this[0x57] @0x15C (== "word57") reservoirAlarm.SetLevel(0); // FUN_0041bbd8(this+0x74, 0) -- inject flag = 0 (was redundant injectActive=0) - if ((subsystem_resource->subsystemFlags & SegmentCopyMask) == 0 - && (subsystem_resource->subsystemFlags & MasterHeatSinkFlag) != 0) + // Gitea #7 gate correction: the binary @4af408 tests *(param_2+0x28) -- + // param_2 is the OWNER MECH (the factory passes the mech as arg 2), so + // this is owner->simulationFlags, NOT the resource's subsystemFlags (the + // old resource read was false on the player mech -> no CoolantSimulation + // Performance -> the flush never drained). Same gate the AggregateHeatSink + // ctor (@4ae8d0, below) already reads correctly. [T1] + if ((owner->simulationFlags & SegmentCopyMask) == 0 + && (owner->simulationFlags & MasterHeatSinkFlag) != 0) { SetPerformance(&Reservoir::CoolantSimulation); // this[7..9] = PTR 0050e6b4 -> @4aef78 HeatSink *link = (HeatSink *)linkedSinks.Resolve(); // FUN_00417ab4(this+0x59) link->Attach(this); // (**(link+0x1d8+4))(link+0x1d8, this) - // The binary reads *(float*)(link+0x1d0) -- the master heat sink's first OWN - // field (HeatSink now ends exactly at 0x1d0, so this is the master subclass's - // own scale word). field_1d0 was deleted from HeatSink; mirror the raw byte - // read (the master's dynamic subclass isn't known statically here). - Scalar masterScale = *(Scalar *)((char *)link + 0x1d0); + // Gitea #7 decode correction: the binary loads `*(int *)(link+0x1d0)` + // and FILDs it -- `(float10)*(int *)(iVar1 + 0x1d0)` @4af408 -- i.e. + // the master bank's INTEGER HeatSinkCount (AggregateHeatSink @0x1D0), + // not a float scale word. CoolantCapacity = 0.05 x heatSinkCount x + // streamed capacity (MadCat: 0.05 x 14 x 3800 = 2660). The old raw + // FLOAT reinterpret read the int bits as ~1e-44 -> capacity ~0 (latent + // while the master-gate bug kept this branch dead). The master's + // dynamic subclass isn't known statically here, so the read stays a + // guarded raw offset -- but of the documented int slot. + Scalar masterScale = (Scalar)*(int *)((char *)link + 0x1d0); // FILD heatSinkCount thermalCapacity = CoolantCapacityScale * masterScale * thermalCapacity; // scale by master (coolantCapacity reuses thermalCapacity) coolantLevel = thermalCapacity; } @@ -587,30 +602,80 @@ Logical Reservoir::TestInstance() const // @4af564 -> IsDerivedFrom 0x50e650 Logical Reservoir::TestClass(Mech &) { return True; } // -// @4aee70 -- "InjectCoolant" message / per-state update. When the watched -// segment has positive count and the reservoir is active above 0 temperature, -// raise the inject alarm and zero the inject accumulator; otherwise clear it. +// @4aee70 -- the "InjectCoolant" (COOLANT FLUSH) message handler, registered +// under id 4 in the Reservoir handler table @0x50e680 (one entry, name string +// @0x50eba5 "InjectCoolant") [T1]. Gitea #7. // -Logical - Reservoir::HandleMessage(int message) // FUN_004aee70 +// Decode (task-#7-flush correction of the old best-effort body): the binary +// bVar1 = FUN_004ac9c8(this); // the novice lockout gate (the +// // same gate MoveValve uses; reads +// // owning BTPlayer @mech+0x190, +// // +0x274 experience level == 0) +// if (!bVar1) { +// if (*(int*)(msg+0xc) < 1) // RELEASE -> stop the flush +// SetLevel(alarm@0x1d0, 0); +// else { // PRESS (or hold) +// if (level@0x1e4 != 1 && _DAT_004aeedc(=0.0f) < *(float*)(this+0x12C)) +// SetLevel(alarm@0x1d0, 1); // ... gate is +0x12C = COOLANT- +// *(this+0x228) = 0; // LEVEL (the old recon misread it +// } // as currentTemperature@0x114: +// *(ushort*)(this+0x18) |= 1; // can't flush an EMPTY tank) +// } +// While the alarm is 1 the CoolantSimulation Performance runs InjectCoolant +// every frame (the drain the coolant gauge reads); the audio watcher bound to +// ReservoirState fires the CoolantDump layers on the 0/1 transitions, and the +// view side spawns the condensation cloud (flush.pfx, psfx 19 -- BTDPL.INI +// "Coolant flush") on the 0->1 edge, matching the binary's mode-1 +// BTTracerEffectRenderable (built for classID 0xBC0 on the "ReservoirState" +// attr, part_014.c:5439; its tick @part_007.c:8780 starts the pfx exactly +// when the watched state CHANGES to 1). +// +void + Reservoir::InjectCoolantMessageHandler(ReceiverDataMessageOf *message) // FUN_004aee70 { - if (!HeatableSubsystem::HandleMessage(message)) // FUN_004ac9c8 + extern int BTPlayerRoleLocksAdvanced(void *owner_mech); // btplayer.cpp (FUN_004ac9c8) + if (BTPlayerRoleLocksAdvanced(owner)) + return; + if (message->dataContents < 1) // *(int*)(msg+0xc) < 1 = release { - if (message /*->count*/ < 1) - { - reservoirAlarm.SetLevel(0); // this+0x1d0 - } - else - { - if (reservoirAlarm.GetLevel() != 1 && currentTemperature > 0.0f) // injectActive == alarm level @0x1e4 - { - reservoirAlarm.SetLevel(1); - } - injectAccumulator = 0; // this+0x228 - } - ForceUpdate(); // this+0x18 + reservoirAlarm.SetLevel(0); // this+0x1d0 -- flush OFF } - return True; + else + { + if (reservoirAlarm.GetLevel() != 1 // injectActive == alarm level @0x1e4 + && coolantLevel > 0.0f) // _DAT_004aeedc = 0.0f; this+0x12C + { + reservoirAlarm.SetLevel(1); // flush ON + // The binary's flush-cloud renderable spawns on this 0->1 edge + // (see the header comment); the port's effect renderables are + // consolidated in the psfx layer, so the spawn hook rides the + // same transition here. + extern void BTSpawnFlushCloud(void *owner_mech); // mech4.cpp (psfx 19) + BTSpawnFlushCloud(owner); + } + injectAccumulator = 0; // this+0x228 + } + ForceUpdate(); // this+0x18 |= 1 +} + +// +// Gitea #7 -- the Reservoir handler registration (table @0x50e680: exactly +// one entry {4, "InjectCoolant", @4aee70}). Chained onto the engine Receiver +// root set exactly like the Condenser's (task #13); function-local statics +// per the static-init-order rule (reconstruction-gotchas #9). +// +Receiver::MessageHandlerSet& + Reservoir::GetMessageHandlers() +{ + static const Receiver::HandlerEntry entries[]= + { + MESSAGE_ENTRY(Reservoir, InjectCoolant) // id 4 @4aee70 + }; + static Receiver::MessageHandlerSet messageHandlers( + ELEMENTS(entries), entries, + Receiver::GetMessageHandlers() + ); + return messageHandlers; } // @@ -623,6 +688,22 @@ void if (reservoirAlarm.GetLevel() == 1) // injectActive == alarm level @0x1e4 { injectAccumulator += time_slice; // this+0x228 + + // DIAG (BT_FLUSH_LOG, Gitea #7): the reservoir drain the coolant + // gauge reads (vertBar C binds Reservoir/CoolantMass = coolantLevel). + if (getenv("BT_FLUSH_LOG")) + { + static Scalar s_flAcc = 1.0e9f; // log the first active frame too + s_flAcc += time_slice; + if (s_flAcc >= 0.5f) + { + s_flAcc = 0.0f; + DEBUG_STREAM << "[flush] " << GetName() + << " level=" << coolantLevel << "/" << thermalCapacity + << " squirtMass=" << coolantSquirtMass + << " held=" << injectAccumulator << "s\n" << std::flush; + } + } InjectCoolant(time_slice); // FUN_004aefa4 } } @@ -648,28 +729,135 @@ Scalar } // -// @4aefa4 (1019 bytes) -- distribute the reservoir charge across the linked -// heat-sink network. Iterates three engine collections off the owner -// (+0x7ac/+0x7bc/+0x7cc) plus the local sink, builds a work list, and for each -// member moves a clamped, time-scaled "squirt" of coolant -- bounded by the -// per-sink remaining capacity and by the reservoir's own coolantLevel -- while -// tracking the per-sink coolant-flow delta (+0x1c8). Bails out as soon as the -// reservoir's currentTemperature drops to ~0 (|temp| <= 1e-4). +// @4aefa4 (1019 bytes) -- the COOLANT FLUSH proper (Gitea #7; was a stub that +// did NOTHING -- the reported "gauge doesn't show a drop"). Distribute the +// reservoir charge across the mech's heat-sink network: build a work list +// from the owner's three capability chains -- condensers @+0x7cc (GUID +// 0x50e4fc), weapons @+0x7bc (0x511830 = MechWeapon), heatables @+0x7ac +// (0x51155c) -- plus the linked master sink (linkedSinks.Resolve @+0x164); +// then, for each member with a nonzero coolant-flow share (+0x15C -- the +// valve set% bias, manual p24 "with a bias based on your set% levels"), move +// a squirt of coolantSquirtMass x flowScale x dt coolant out of the +// reservoir into the sink (clamped to what the reservoir still holds, and +// the sink to its capacity), and credit the sink's pendingHeat (+0x1C8) with +// the NEGATIVE heat delta carried by the fresh coolant -- capped at +// sink->thermalMass x reservoir->startingTemperature (+0x154 x +0x13C, the +// coolant enters at the reservoir's base temperature) and clamped to only +// ever COOL. Bails out as soon as the reservoir runs dry +// (|coolantLevel@0x12C| <= 1e-4 -- the old stub's "currentTemperature" +// reading of +0x12C was a misdecode; +300 decimal == 0x12C == coolantLevel). +// Constants: _DAT_004af3a0 = 1e-4f, _DAT_004af3a4 = _DAT_004af3a8 = 0.0f +// (byte-verified @4af3a0). // -// (Uses the engine collection-iterator helpers @4af9cf..@4afb96 as locals -- -// see notes at the foot of this file. Body reproduced structurally; the -// per-sink arithmetic mirrors HeatSink::ComputeHeatFlow.) +// PORT NOTE (chain emulation, same pattern as BTRecomputeCondenserValves): +// the factory's capability-chain fill loops ride the SubProxy stub in the +// port (loops 2/4 add nothing), so the work list is rebuilt here by walking +// the populated roster with the SAME membership tests, in the binary's +// order: condensers, weapons, HeatSink-derived heatables, the linked master +// sink. Condensers and weapons therefore appear TWICE (they are heatable- +// derived too), the bank twice (heatable + linked sink) -- exactly the +// binary's duplicate-visit weighting. ONE guarded deviation [T2]: the +// binary's heatable chain also contains the WATCHER branch (HeatWatcher +// chains the HeatableSubsystem derivation), whose bytes at +0x15C/+0x12C are +// interior alarm state in OUR layout (writing them = the databinding trap); +// real coolant carriers are all HeatSink-derived, so the walk filters to +// HeatSink -- a watcher's incidental +0x15C in the 1995 layout is alarm +// interior there too, so the faithful economics are identical. // void Reservoir::InjectCoolant(Scalar time_slice) // FUN_004aefa4 { - if (fabsf(currentTemperature) <= 1.0e-4f) // _DAT_004af3a0 + if (fabsf(coolantLevel) <= 1.0e-4f) // _DAT_004af3a0; +0x12C (tank empty) { return; } - // ... iterate owner collections (+0x7ac/+0x7bc/+0x7cc) and self, - // squirt clamped coolant to each linked sink, update +0x1c8 ... - // (full per-sink math recovered in heatfamily_gap.c @4aefa4) + + extern int BTWeaponIsRearFiring(::Subsystem *sub); // mechweap.cpp (-1 = not a weapon) + + enum { kMaxWork = 96 }; // 20-subsystem roster x duplicate passes + HeatSink *work[kMaxWork]; + int workCount = 0; + Derivation &condenserClass = *Condenser::GetClassDerivations(); // 0x50e4fc + Derivation &heatSinkClass = *HeatSink::GetClassDerivations(); + Entity *own = (Entity *)owner; // roster access (Entity-complete TU) + int count = own->GetSubsystemCount(); + + for (int i = 2; i < count && workCount < kMaxWork; ++i) // chain @+0x7cc + { + ::Subsystem *s = own->GetSubsystem(i); + if (s != 0 && s->IsDerivedFrom(condenserClass)) + work[workCount++] = (HeatSink *)s; + } + for (int i = 2; i < count && workCount < kMaxWork; ++i) // chain @+0x7bc + { + ::Subsystem *s = own->GetSubsystem(i); + if (s != 0 && BTWeaponIsRearFiring(s) >= 0) // MechWeapon-derived + work[workCount++] = (HeatSink *)s; // (weapons are HeatSinks) + } + for (int i = 2; i < count && workCount < kMaxWork; ++i) // chain @+0x7ac + { + ::Subsystem *s = own->GetSubsystem(i); + if (s != 0 && s->IsDerivedFrom(heatSinkClass)) // see PORT NOTE + work[workCount++] = (HeatSink *)s; + } + { + HeatSink *link = (HeatSink *)linkedSinks.Resolve(); // FUN_00417ab4(this+0x164) + if (link != 0 && workCount < kMaxWork) + work[workCount++] = link; + } + + for (int w = 0; w < workCount; ++w) + { + if (fabsf(coolantLevel) <= 1.0e-4f) // ran dry mid-pass -> stop + { + return; + } + HeatSink *sink = work[w]; + if (sink->coolantFlowScale == 0.0f) // +0x15C == _DAT_004af3a4 + { + continue; + } + + // squirt = -(squirtMass x flowScale x dt), clamped to [-coolantLevel, 0] + Scalar move = -coolantSquirtMass // this+0x224 + * sink->coolantFlowScale // sink+0x15C + * time_slice; + Scalar lo = -coolantLevel; // local_14 + if (move < lo) move = lo; // can't move more than the tank holds + if (move > 0.0f) move = 0.0f; // squirts only ever LEAVE the tank + + // the heat delta riding the moved mass (computed BEFORE the level + // updates, as in the binary): + Scalar den = (fabsf(sink->coolantLevel) > 1.0e-4f) + ? sink->coolantLevel // sink+0x12C + : sink->thermalCapacity; // sink+0x128 (empty-sink fallback) + Scalar fracSink = fabsf(move) / den; // local_20 + Scalar fracRes = fabsf(move) / coolantLevel; // local_1c + Scalar heatDelta = + sink->heatEnergy * fracSink // sink+0x158 + - heatEnergy * fracRes; // this+0x158 + + coolantLevel += move; // the reservoir DRAINS (move <= 0) + sink->coolantLevel -= move; // the sink gains + if (sink->coolantLevel >= 0.0f) // clamp to [0, capacity] + { + if (sink->coolantLevel > sink->thermalCapacity) + sink->coolantLevel = sink->thermalCapacity; + } + else + { + sink->coolantLevel = 0.0f; // _DAT_004af3a8 + } + + Scalar cap = sink->thermalMass // sink+0x154 + * startingTemperature; // this+0x13C (reservoir base temp) + if (heatDelta > cap) + heatDelta = cap; + Scalar chill = -heatDelta; + if (chill > 0.0f) // _DAT_004af3a4 < local_2c -> clamp + chill = 0.0f; // the flush only ever COOLS + sink->pendingHeat += chill; // sink+0x1C8 + } } // diff --git a/game/reconstructed/heatfamily_reslice.hpp b/game/reconstructed/heatfamily_reslice.hpp index af79695..dbda458 100644 --- a/game/reconstructed/heatfamily_reslice.hpp +++ b/game/reconstructed/heatfamily_reslice.hpp @@ -222,6 +222,15 @@ { public: static Derivation *GetClassDerivations(); // name "Reservoir" + // Gitea #7: the Reservoir handler table @0x50e680 -- exactly ONE entry, + // {4, "InjectCoolant", @4aee70} (PE bytes: 04000000 a5eb5000 70ee4a00; + // the name string @0x50eba5 = "InjectCoolant"). Same per-receiver id + // space as the Condenser's: id 4 to a condenser = MoveValve, to the + // reservoir = InjectCoolant (the COOLANT FLUSH -- manual p24: "punch + // (or hold down) the coolant button to flush fresh coolant through + // your 'Mech's loops"). Function-local statics per the static-init- + // order rule. [T1] + static Receiver::MessageHandlerSet& GetMessageHandlers(); static SharedData DefaultData; // Attribute Support -- audio binds an AudioStateWatcher to ReservoirState; @@ -267,7 +276,12 @@ // @4af3b0 -- slot-14 DrawCoolant override: hand out up to coolantLevel. Scalar DrawCoolant(Scalar requested); - Logical HandleMessage(int message); // @4aee70 ("InjectCoolant" msg) + // @4aee70 -- the "InjectCoolant" (COOLANT FLUSH) message handler, id 4 + // (table @0x50e680 [T1]). Press (data >= 1) starts the flush while + // coolantLevel > 0; release (data < 1) stops it. + enum { InjectCoolantMessageID = 4 }; + void InjectCoolantMessageHandler( + ReceiverDataMessageOf *message); void PrintState(); // @4aeef8 (ReservoirState) void GetState(/*...*/); // @4aef40 (slot 7 state query) diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 25f7f59..3b5eea1 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -661,6 +661,7 @@ static int gBTConfigKey = 0; // task #6: HOLD 'G' = the weapon-con static int gBTGenSelKey = 0; // task #12: F5..F8 = SelectGeneratorA..D, F9 = mode toggle // (0 idle; else the message id 4..8) static int gBTValveKey = 0; // task #13: 'C' = MoveValve on the selected condenser +static int gBTFlushKey = 0; // Gitea #7: 'H' HELD = InjectCoolant (the coolant flush) // Damage: each shot dispatches a REAL Entity::TakeDamageMessage to the target. Now // that the damage zones are constructed (mech.cpp Pass-3 zone build), the engine base @@ -899,6 +900,40 @@ void << " at(" << mx << "," << my << "," << mz << ")\n" << std::flush; } +//########################################################################### +// COOLANT-FLUSH CONDENSATION CLOUD (Gitea #7) -- the flush visual. +// +// BTDPL.INI psfx 19 = FLUSH.PFX, commented "Coolant flush" (both day and +// night tables): a bluish (0.7/0.9/1.6 tint) firesmoke burst, ~3.5 s window. +// The binary drives it with the mode-1 "coolant/reservoir" effect renderable +// (FUN_00456a68, alloc 0x138), built for every classID-0xBC0 subsystem on +// its "ReservoirState" attribute (BTL4VID.CPP build loop, part_014.c:5439; +// missing attr = Verify "Reservoir did not have a ReservoirState...") and +// DCS-parented to the mech; its per-frame tick (part_007.c:8780) starts the +// effect exactly when the watched state CHANGES to 1. Manual p24: "other +// pilots will see steam rise from your 'Mech on the battlefield". +// +// The port consolidates the BT effect renderables in the psfx layer, so this +// helper is called on the same 0->1 alarm edge (Reservoir:: +// InjectCoolantMessageHandler) and rides the mech via the attached-emitter +// follower (seg -1 = the torso-height entity fallback in +// BTResolveSegmentWorld -- the cloud rises FROM the mech and trails it). +//########################################################################### +void + BTSpawnFlushCloud(void *ownerMech) +{ + if (ownerMech == 0) + return; + float pos[3], rows[9]; + if (!BTResolveSegmentWorld(ownerMech, -1, pos, rows)) + return; + extern void BTStartPfxAttached(int, void *, int, float, float, float, const float *); + BTStartPfxAttached(19, ownerMech, -1, pos[0], pos[1], pos[2], rows); + if (getenv("BT_FLUSH_LOG")) + DEBUG_STREAM << "[flush] condensation cloud (psfx 19 FLUSH.PFX) at(" + << pos[0] << "," << pos[1] << "," << pos[2] << ")\n" << std::flush; +} + //########################################################################### // PER-ROUND DETONATION (the binary's Missile::MoveAndCollide @004bef78: every // round spawns ITS OWN ExplosionModelFile at its impact point, resource @@ -2621,6 +2656,10 @@ void gBTGenSelKey = gBTInput.genSel; // task #13: the Valve action cycles the coolant valve. gBTValveKey = gBTInput.valve; + // Gitea #7: the Flush action HELD = the coolant MFD's + // top-right button (manual p24) -- InjectCoolant while + // down, stop on release. + gBTFlushKey = gBTInput.flush; // TORSO CONTROLS (2026-07-13): 'M' cycles the control mode // (Basic -> Standard -> Veteran -- the pod console button, // CycleControlModeMessageHandler); Q/E deflect the torso @@ -5110,6 +5149,59 @@ void gBTValveKey = (s_vtFrame >= 600 && s_vtFrame < 610) ? 1 : 0; } + // Gitea #7 scripted verify (BT_FLUSH_TEST=): hold the coolant + // flush for ~60 sim frames starting at the given frame -- the + // reservoir drains ([flush] log), the coolant vertBar drops, and the + // FLUSH.PFX condensation cloud spawns on the 0->1 edge. + if ((Entity *)this == application->GetViewpointEntity() + && getenv("BT_FLUSH_TEST")) + { + static int s_ftFrame = 0; + ++s_ftFrame; + int t0 = atoi(getenv("BT_FLUSH_TEST")); + if (t0 < 1) t0 = 600; + gBTFlushKey = (s_ftFrame >= t0 && s_ftFrame < t0 + 60) ? 1 : 0; + } + + // Gitea #7: the COOLANT FLUSH button (the coolant MFD's top-right + // button, manual p24 -- "punch (or hold down) the coolant button to + // flush fresh coolant through your 'Mech's loops"). Press AND + // release edges both dispatch: InjectCoolant (id 4, the Reservoir + // table @0x50e680) starts the flush on data>=1 and stops it on + // data<1 -- the same held-button payload the pod's streamed MFD + // EventMapping delivers. + if ((Entity *)this == application->GetViewpointEntity()) + { + static int s_prevFlush = 0; + if (gBTFlushKey != s_prevFlush) + { + int pressed = (gBTFlushKey != 0); + s_prevFlush = gBTFlushKey; + Subsystem *reservoir = 0; + for (int s = 1; s < GetSubsystemCount(); ++s) + { + Subsystem *sub = GetSubsystem(s); + if (sub != 0 && (int)sub->GetClassID() == 0xBC0) // Reservoir + { + reservoir = sub; + break; + } + } + if (reservoir != 0) + { + if (getenv("BT_FLUSH_LOG")) + DEBUG_STREAM << "[flush-tx] InjectCoolant " + << (pressed ? "PRESS" : "RELEASE") << " -> " + << reservoir->GetName() << std::endl; + ReceiverDataMessageOf msg( + 4 /*Reservoir::InjectCoolantMessageID*/, + sizeof(ReceiverDataMessageOf), + (ControlsButton)(pressed ? 1 : 0)); + reservoir->Dispatch(&msg); + } + } + } + // task #13 dev harness: the COOLANT VALVE lever. 'C' edge -> dispatch // MoveValve (id 4, the Condenser table @0x50E52C) to the selected // condenser -- the same press payload the pod's engineering-screen aux From 1d6339b226c9ce9f03d782092f9db0fc598f1a55 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 16:28:38 -0500 Subject: [PATCH 13/17] Gitea #6: secondary MFD Damage/Critical/Heat cycling -- reconstruct the NotifyOfDisplayModeChange override (vtbl+0x4C @4d1ae4) + wire desktop 'N' The secondary screen's schematic selector was mislabeled: @004d1ae4 (the bits-18..20 ModeSecondary* mask swap) was reconstructed as a non-virtual "SetControlMode" that nothing called, so the desktop stayed pinned on the Damage view. The binary's L4 vtable @0051e440 pins the truth: +0x48 = @004d1acc <- CycleControlModeMessageHandler (FUN_004afbe0): forwards to the base RET no-op @004b048c. A BAS/MID/ADV control-mode change never touches the secondary view (empirically confirmed: BT_MODECYCLE_TEST cycles the CONTROL MODE lamp, mask bits 18-20 unchanged, schematic stays ARMOR). +0x4C = @004d1ae4 <- CycleDisplayModeMessageHandler (FUN_004afcac): THE Damage/Critical/Heat selector, indexed by displayMode (table @0051dbe4 = ModeSecondaryDamage/Critical/Heat). Authentic pod inputs (streamed type-6 .CTL EventMappings, dumped via the new BT_CTRLMAP_LOG EVENT records): secondary-panel button 0x15 -> msg 0x15 CycleDisplayMode (manual p13, the "'Mech status Info center" bottom left of the secondary screen), button 0x18 -> msg 0x14 CycleControlMode (manual p6, top right), 0x10/0x11 -> ZoomIn/Out. The DOS keyboard fallbacks (Keypress 0x13d/0x13e = extended F3/F4) are dead under the WinTesla VK map, hence the desktop pin. Port wiring (the M/ModeCycle pattern): key N / pad RightThumb -> action DisplayCycle -> gBTDisplayCycle -> CycleDisplayModeNow() -- the same body the pod console button message drives. Both .MAP profiles + the compiled-in default updated. Verified live (docked gauges + BT_SHOT, BT_VIEWCYCLE_TEST): the sec panel cycles ARMOR DAMAGE silhouette -> CRITICAL DAMAGE subsystem list -> HEAT DAMAGE colored list, mask 0x450421 -> 0x490421 -> 0x510421; M control-mode cycling un-regressed (BAS/MID/ADV lamp cycles, view pinned). Diags: BT_MODE_LOG, BT_VIEWCYCLE_TEST=, BT_MODECYCLE_TEST=, BT_CTRLMAP_LOG now dumps EVENT records. KB: gauges-hud secondary-view section rewritten, CLASSMAP +0x48/+0x4C slots, decomp-reference env gates, GAUGE_COMPOSITE phase-4 entry resolved. Co-Authored-By: Claude Fable 5 --- content/CONTROLS.MAP | 6 ++- content/CONTROLS_NUMPAD.MAP | 2 + context/decomp-reference.md | 3 ++ context/gauges-hud.md | 35 +++++++++++++----- docs/GAUGE_COMPOSITE.md | 5 ++- engine/MUNGA_L4/L4CTRL.cpp | 21 +++++++++++ game/reconstructed/CLASSMAP.md | 7 +++- game/reconstructed/btinput.cpp | 5 +++ game/reconstructed/btinput.hpp | 1 + game/reconstructed/btl4mppr.cpp | 65 ++++++++++++++++++++++++++------- game/reconstructed/btl4mppr.hpp | 26 ++++++++++--- game/reconstructed/mech4.cpp | 42 +++++++++++++++++++++ game/reconstructed/mechmppr.cpp | 43 ++++++++++++++++++---- game/reconstructed/mechmppr.hpp | 7 ++++ 14 files changed, 229 insertions(+), 39 deletions(-) diff --git a/content/CONTROLS.MAP b/content/CONTROLS.MAP index 9083631..46f46d9 100644 --- a/content/CONTROLS.MAP +++ b/content/CONTROLS.MAP @@ -31,8 +31,8 @@ # LeftTrigger RightTrigger (as digital, >= 50% pressed) # LeftStickX LeftStickY RightStickX RightStickY # LeftTrigger RightTrigger -# action ViewToggle LookBehind AllStop ModeCycle Valve Flush ConfigHold -# Generator1-4 Reconnect +# action ViewToggle LookBehind AllStop ModeCycle DisplayCycle Valve Flush +# ConfigHold Generator1-4 Reconnect # pckey sends an authentic 1995 PC-keyboard hotkey (the in-cockpit # dispatcher): '+'/'-' = target zoom, 'w'..'o' = pilot select, # 'a','b','c','d','f','g','s','v','x','z' = display presets, ... @@ -75,6 +75,7 @@ key G action ConfigHold # hold + fire keys = regroup the weapon key C action Valve # coolant valve cycle key H action Flush # coolant flush (HOLD -- dumps reservoir coolant) key M action ModeCycle # Basic -> Standard -> Veteran controls +key N action DisplayCycle # secondary schematic: Damage -> Critical -> Heat key V action ViewToggle # cockpit <-> chase camera (dev) key B action LookBehind # HOLD = the pod's rear-view button key F5 action Generator1 @@ -102,4 +103,5 @@ pad B action AllStop pad Y action ViewToggle pad X action LookBehind pad Start action ModeCycle +pad RightThumb action DisplayCycle pad Back action Valve diff --git a/content/CONTROLS_NUMPAD.MAP b/content/CONTROLS_NUMPAD.MAP index 852336a..f8eb82c 100644 --- a/content/CONTROLS_NUMPAD.MAP +++ b/content/CONTROLS_NUMPAD.MAP @@ -44,6 +44,7 @@ key Right button 0x43 # Hat Right # ---- Systems ------------------------------------------------------------ key G action ConfigHold key Enter action ModeCycle +key N action DisplayCycle # secondary schematic: Damage -> Critical -> Heat key V action ViewToggle key B action LookBehind key F5 action Generator1 @@ -99,4 +100,5 @@ pad B action AllStop pad Y action ViewToggle pad X action LookBehind pad Start action ModeCycle +pad RightThumb action DisplayCycle pad Back action Valve diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 3da8294..c606347 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -283,6 +283,9 @@ default-ON (`'0'` disables). | `BT_SELF=<[pilots] tag>` | claim a specific roster seat in relay mode; absent/`auto` = the relay assigns one (SEAT_REQUEST) | | `BT_RELAY_TCP_ONLY=1` | disable the relay UDP channel (all unreliable traffic rides the TCP relay conn) | | `BT_INPUT_LOG` | binding-engine trace: CONTROLS.MAP load, button/keypad emissions, pad connect, torso twist/elev axis ([[pod-hardware]] §Desktop input remap) | +| `BT_MODE_LOG` | `[mode] display notify` — the ModeManager mask after each secondary-view swap (btl4mppr.cpp NotifyOfDisplayModeChange, Gitea #6) | +| `BT_VIEWCYCLE_TEST=` / `BT_MODECYCLE_TEST=` | scripted verify (mech4.cpp): pulse a secondary-schematic cycle / a control-mode cycle at the frame and every 300 after — pixel-verifiable with `BT_DEV_GAUGES_DOCK`+`BT_SHOT` | +| `BT_CTRLMAP_LOG` | streamed `.CTL` record audit (L4CTRL.cpp CreateStreamedMappings): Direct records with the resolved member offset + EVENT records (button→subsystem message; found the pod's secondary-panel Cycle buttons 0x15/0x18) | | `BT_CAM_LOG` | camera-seat/broadcast trace: streamed camera network count, director pick + Players-group census, ship follow state, PlayerLink dispatch/receive, ranking-window render ([[multiplayer]] camera seat) | | `BT_SPEC_LOG` | manual spec-audit dump at subsystem ctor: torso speed/limits, heat-sink count, reservoir capacity, per-subsystem coolant loop ([[pod-hardware]] §Spec cross-check) | diff --git a/context/gauges-hud.md b/context/gauges-hud.md index a883052..07a414e 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -8,7 +8,7 @@ key_terms: [gauge, methodDescription, attribute-pointer, GaugeRenderer, MFD, PCC open_questions: - "MessageBoard LIVE 2026-07-12 (kill ticker); remaining: only strip 0 (kill) is produced -- survey the other btsmsgs.pcx strips for authentic producers" - "HUD binary attr table @005110c0 offsets conflict with the port HUD layout on 3 slots (0x1D8/0x1EC/0x1F8) -- re-dump before publishing" - - "Secondary-view cycling (Damage/Critical/Heat) unreachable from the desktop keyboard: the mapper's 0x13d/0x13e cases are dead DOS F3/F4 codes (VK_F3/F4 = 0x72/0x73 collide with 'r'/'s') -- wire a free key to CycleControlMode" + - "Secondary-view cycling RESOLVED 2026-07-19 (Gitea #6): the selector is the DISPLAY mode (CycleDisplayMode -> vtbl+0x4C override @4d1ae4), NOT CycleControlMode; desktop 'N' / pad RightThumb wired; pixel-verified dama->crit->heat" - "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)" --- @@ -70,7 +70,7 @@ over one shared `SVGA16` pixelBuffer; `SVGA16::DrawDevSurface`). On the POD they intact). The `overlay` port (SectorDisplay lives there) shares the `sec` physical surface via a different bit-plane (0x00C0). [T2] -## The secondary screen's THREE views (Damage / Critical / Heat) — mode-gated [T0/T1] +## The secondary screen's THREE views (Damage / Critical / Heat) — mode-gated [T0/T1/T2] The `sec` port stacks three mode-gated mech-schematic layers at offset (50,0) over the always-on radar/heading/speed/messageBoard (`Secondary1`): **Damage** (`ModeSecondaryDamage`, `dama.pcc` + `colorMapArmor`/`colorMapperMultiArmor` — 4 silhouettes front/left/right/back, @@ -80,13 +80,30 @@ pixel-plane ids 60-63, per-DAMAGE-ZONE `dz_*` tint through the adpal→adpal2 ra NonMapping=0x10000, Intercom=0x20000, **SecondaryDamage=0x40000, SecondaryCritical=0x80000, SecondaryHeat=0x100000** (bits 18-20). `ModeInitial` includes **SecondaryDamage** → the ARMOR view is the default-on layer (our port creates `BTL4ModeManager(ModeInitial)`, btl4app.cpp:303). -`L4MechControlsMapper::SetControlMode` @004d1ae4 is the SELECTOR — its mask table -{0x40000,0x80000,0x100000} clears bits 18-20 and sets one — despite the "control mode" name it -switches the secondary VIEW. ⚠ Desktop gap: the Keypress cases `0x13d`/`0x13e` -(CycleControlMode/CycleDisplayMode) are the DOS Tesla extended F3/F4 codes and never fire under -the WinTesla VK map (VK_F3=0x72 collides with 'r', VK_F4=0x73 with 's' — the same collision -class as the documented 'p'/VK_F1 drop), so the desktop is PINNED on the Damage view. The -schematic shows the pilot's OWN mech only — there is no target-damage readout in the cockpit. +The schematic shows the pilot's OWN mech only — there is no target-damage readout in the cockpit. + +**The selector is the DISPLAY mode, not the control mode (Gitea #6, RESOLVED 2026-07-19) [T1→T2].** +The L4 vtable @0051e440 pins the slots: **+0x4C = @004d1ae4** — dispatched by +`CycleDisplayModeMessageHandler` (FUN_004afcac) with the new `displayMode` (0/1/2) — is the +`NotifyOfDisplayModeChange` override that clears bits 18-20 and sets the mask from the table +@0051dbe4 {0x40000,0x80000,0x100000}. (The old "SetControlMode @004d1ae4 switches the secondary +VIEW" claim was the mislabel that kept the port's copy a never-called non-virtual.) **+0x48 = +@004d1acc** — dispatched by `CycleControlModeMessageHandler` (FUN_004afbe0) — just forwards to +the base RET no-op @004b048c: a BAS/MID/ADV control-mode change NEVER touches the secondary +view (empirically confirmed: M cycles the CONTROL MODE gauge lamp, mask bits 18-20 unchanged, +schematic stays on ARMOR DAMAGE). **Authentic pod inputs** (streamed type-6 `.CTL` +EventMappings, dumped live via `BT_CTRLMAP_LOG`): secondary-panel button **0x15 → msg 0x15 +CycleDisplayMode** (the manual-p13 "'Mech status Info center", bottom left of the secondary +screen: Armor/Critical/Heat Damage Schematic cycle), button **0x18 → msg 0x14 CycleControlMode** +(the manual-p6 mode button, top right), buttons 0x10/0x11 → ZoomIn/ZoomOut 0x12/0x13 (the map +zoom ± pair). The DOS keyboard fallbacks (Keypress `0x13d`/`0x13e` = extended F3/F4) never fire +under the WinTesla VK map (VK_F3=0x72 collides with 'r', VK_F4=0x73 with 's' — the 'p'/VK_F1 +collision class), so the desktop was PINNED on Damage. **Port wiring (mirrors the M/ModeCycle +pattern): 'N' / pad RightThumb → action DisplayCycle → gBTDisplayCycle → `CycleDisplayModeNow()`** +(mechmppr.cpp; the same body the pod button message drives). Pixel-verified live (docked gauges + +BT_SHOT): ARMOR DAMAGE silhouette → CRITICAL DAMAGE subsystem list → HEAT DAMAGE colored list, +mask 0x450421→0x490421→0x510421. Diags: `BT_MODE_LOG`, `BT_VIEWCYCLE_TEST=`, +`BT_MODECYCLE_TEST=`. ## pilotList (Comm KILLS/DEATHS) row semantics + the −1 [T1/T2] One ROW PER PILOT in the mission (2-player MP = 2 rows — not duplicate displays). KILLS = diff --git a/docs/GAUGE_COMPOSITE.md b/docs/GAUGE_COMPOSITE.md index c88504c..2520674 100644 --- a/docs/GAUGE_COMPOSITE.md +++ b/docs/GAUGE_COMPOSITE.md @@ -627,7 +627,10 @@ Byte-exact re-base of the PoweredSubsystem:HeatSink LEAF (Sensor/Emitter/Myomers (delete the sensor.cpp:287-288 guard — ✅ LANDED: the guard is deleted and the leaf is byte-exact, sensor.cpp:407-413) + the raw-offset cluster reads (partially locked since — MechWeapon byte-exact); radar pip/name symbology + staticEntities (task #17, under reconstruction 2026-07-12); -CycleDisplayMode→ModeManager mask (machinery reconstructed, gauge-layer toggle unverified [T3]); +CycleDisplayMode→ModeManager mask (✅ RESOLVED 2026-07-19, Gitea #6 [T2]: @4d1ae4 relabeled the +NotifyOfDisplayModeChange override (vtbl+0x4C) and made virtual; desktop 'N'/pad RightThumb → +CycleDisplayModeNow; secondary schematic swap dama→crit→heat pixel-verified via +BT_DEV_GAUGES_DOCK+BT_SHOT; pod buttons = streamed EventMappings 0x15→msg 0x15 / 0x18→msg 0x14); numericSpeed units (open). ### Authentically-static (do NOT "fix") diff --git a/engine/MUNGA_L4/L4CTRL.cpp b/engine/MUNGA_L4/L4CTRL.cpp index eb5334a..f0df062 100644 --- a/engine/MUNGA_L4/L4CTRL.cpp +++ b/engine/MUNGA_L4/L4CTRL.cpp @@ -2286,6 +2286,27 @@ void Receiver::MessageID msg_id = mapping->messageID; + // + // BT_CTRLMAP_LOG=1: dump the streamed EVENT records too (button -> + // subsystem message). Gitea #6 needed this to identify which pod + // console buttons carried the mapper's CycleControlMode (0x14) / + // CycleDisplayMode (0x15) messages. + // + { + static const int s_ctrlmapLog = + (getenv("BT_CTRLMAP_LOG") != 0 && *getenv("BT_CTRLMAP_LOG") != '0'); + if (s_ctrlmapLog) + { + DEBUG_STREAM << "[ctrlmap] rec " << i + << " subsys " << mapping->subsystemID + << " group " << (int)mapping->controlsGroup + << " elem 0x" << std::hex << element + << " mask 0x" << (unsigned)mode_mask + << " EVENT msg 0x" << (unsigned)msg_id + << std::dec << "\n" << std::flush; + } + } + switch (mapping->controlsGroup) { case ScalarGroup: diff --git a/game/reconstructed/CLASSMAP.md b/game/reconstructed/CLASSMAP.md index 595d59e..6f1033b 100644 --- a/game/reconstructed/CLASSMAP.md +++ b/game/reconstructed/CLASSMAP.md @@ -147,8 +147,11 @@ Subsystem (MUNGA base — have source: RP/MUNGA/SUBSYSTM.HPP) │ throttleForward@0x1ac/throttleReverse@0x1b0/leftPedal@0x1b4/rightPedal@0x1b8 (cleared by derived ctors). │ InterpretControls@4d196c (throttle detent snap 1.0±0.05, BuildPilotArray, review-mode bit 0x200000, │ target-range ramp range=250*2^exp, exp clamp[0,5]; then chains MechControlsMapper::InterpretControls @4afd10). -│ ZoomTargetRangeIn@4d1b64 / ZoomTargetRangeOut@4d1b9c (±1.0). NotifyOfDisplayModeChange-override@4d1ae4 (vtbl+0x4C; ex-"SetControlMode", (mode bits18-20, -│ table @0051dbe4={0x40000,0x80000,0x100000}). SetPresetMode@4d1b24 (group*5+item -> {clear,set} mask table @0051dbf0[15]). +│ ZoomTargetRangeIn@4d1b64 / ZoomTargetRangeOut@4d1b9c (±1.0). NotifyOfControlModeChange-override@4d1acc (vtbl+0x48; forwards to base no-op @4b048c — +│ control mode never touches the secondary view). NotifyOfDisplayModeChange-override@4d1ae4 (vtbl+0x4C; ex-"SetControlMode"; Gitea #6 reconstructed +│ 2026-07-19 = THE secondary Damage/Critical/Heat selector, mode bits18-20, table @0051dbe4={0x40000,0x80000,0x100000}; pod input = streamed +│ EventMapping secondary-panel button 0x15 -> msg 0x15 CycleDisplayMode; button 0x18 -> msg 0x14 CycleControlMode). +│ SetPresetMode@4d1b24 (group*5+item -> {clear,set} mask table @0051dbf0[15]). │ EnterConfiguration@4d1840 (+0x38) / ExitConfiguration@4d18dc (+0x3C) (task #6 relabel (buttons 0x40..0x47, hat-skip 0x41->0x45, │ mode 0x8000=ConfigReady). AddOrErase evt/dir RET no-ops @4d195c/@4d1964 (+0x40/+0x44; TM can't regroup). KeypressMessageHandler@4d1bf0 │ (shared dispatcher: zoom/preset/pilot-cycle/look keys + fake-button dev keys; default -> @4d141c). diff --git a/game/reconstructed/btinput.cpp b/game/reconstructed/btinput.cpp index a2c32e9..cf3b3ae 100644 --- a/game/reconstructed/btinput.cpp +++ b/game/reconstructed/btinput.cpp @@ -70,6 +70,7 @@ enum BTActionID BTActLookBehind, BTActAllStop, BTActModeCycle, + BTActDisplayCycle, // Gitea #6: secondary schematic cycle (Damage/Critical/Heat) BTActValve, BTActFlush, // Gitea #7: HELD = coolant flush (InjectCoolant) BTActConfigHold, @@ -193,6 +194,7 @@ static const BTName sActionNames[] = { {"ViewToggle", BTActViewToggle}, {"LookBehind", BTActLookBehind}, {"AllStop", BTActAllStop}, {"ModeCycle", BTActModeCycle}, + {"DisplayCycle", BTActDisplayCycle}, {"Valve", BTActValve}, {"Flush", BTActFlush}, {"ConfigHold", BTActConfigHold}, {"Generator1", BTActGenerator1}, {"Generator2", BTActGenerator2}, {"Generator3", BTActGenerator3}, {"Generator4", BTActGenerator4}, @@ -266,6 +268,7 @@ static const char *sDefaultProfile = "key C action Valve\n" "key H action Flush\n" "key M action ModeCycle\n" + "key N action DisplayCycle\n" "key V action ViewToggle\n" "key B action LookBehind\n" "key F5 action Generator1\n" @@ -289,6 +292,7 @@ static const char *sDefaultProfile = "pad Y action ViewToggle\n" "pad X action LookBehind\n" "pad Start action ModeCycle\n" + "pad RightThumb action DisplayCycle\n" "pad Back action Valve\n"; //============================================================================= @@ -963,6 +967,7 @@ void case BTActLookBehind: next.lookBehind = 1; break; case BTActAllStop: next.allStop = 1; break; case BTActModeCycle: next.modeCycle = 1; break; + case BTActDisplayCycle: next.displayCycle = 1; break; case BTActValve: next.valve = 1; break; case BTActFlush: next.flush = 1; break; case BTActConfigHold: next.configHold = 1; break; diff --git a/game/reconstructed/btinput.hpp b/game/reconstructed/btinput.hpp index 031ce12..d22517d 100644 --- a/game/reconstructed/btinput.hpp +++ b/game/reconstructed/btinput.hpp @@ -51,6 +51,7 @@ struct BTInputState int lookBehind; int allStop; int modeCycle; + int displayCycle; // Gitea #6: secondary schematic cycle (Damage/Critical/Heat) int valve; int flush; // Gitea #7: coolant flush HELD (InjectCoolant) int configHold; diff --git a/game/reconstructed/btl4mppr.cpp b/game/reconstructed/btl4mppr.cpp index 75c4baf..61d37ab 100644 --- a/game/reconstructed/btl4mppr.cpp +++ b/game/reconstructed/btl4mppr.cpp @@ -30,10 +30,12 @@ // @004d17ac ctor @004d1814 dtor // @004d196c InterpretControls (target-range ramp + review-mode watch) // @004d1b64 ZoomTargetRangeIn @004d1b9c ZoomTargetRangeOut -// @004d1ae4 SetControlMode (mode-mask switch; calls base NotifyOfDisplayMode) +// @004d1acc NotifyOfControlModeChange (+0x48; forwards to base no-op @004b048c) +// @004d1ae4 NotifyOfDisplayModeChange (+0x4C; the secondary-view mask swap +// -- Gitea #6 relabel; ex-"SetControlMode") // @004d1b24 SetPresetMode (group/item -> preset mode-mask) -// @004d1840 CreateTemporaryEventMappings @004d18dc RemoveTemporaryEventMappings -// @004d195c NotifyOfControlModeChange (no-op) @004d1964 NotifyOfConfigurationModeChange (no-op) +// @004d1840 EnterConfiguration @004d18dc ExitConfiguration +// @004d195c/@004d1964 AddOrErase evt/dir (+0x40/+0x44, RET no-ops) // @004d1bf0 KeypressMessageHandler (the shared L4 keyboard dispatcher) // MechThrustmasterMapper --------------------------- vtable @0051e3f0 // @004d21d0 ctor @004d22b4 dtor @004d22e0 TestInstance @@ -81,7 +83,8 @@ // _DAT_004d1ac8 = 0x437a0000 = 250.0f (target-range scale: range = 250*2^x) // _DAT_004d1b94 = 1.0f zoom-in step _DAT_004d1b98 = 0.0f min exponent // _DAT_004d1bcc = 1.0f zoom-out step _DAT_004d1bd0 = 5.0f max exponent -// DAT_0051dbe4[3] = {0x40000,0x80000,0x100000} control-mode mode-masks +// DAT_0051dbe4[3] = {0x40000,0x80000,0x100000} secondary-VIEW mode-masks +// (ModeSecondaryDamage/Critical/Heat, indexed by displayMode) // DAT_0051dbf0[15] = {clear,set} mode-mask pairs (group*5+item), preset select // DAT_0051dcd0[8] = {0x37,0x36,0x35,0x34,0x33,0x32,0x31,0x30} hotbox buttons // @@ -482,25 +485,61 @@ L4MechControlsMapper::MessageHandlerSet& // //############################################################################# -// SetControlMode @004d1ae4 +// NotifyOfControlModeChange @004d1acc (vtbl+0x48) +// NotifyOfDisplayModeChange @004d1ae4 (vtbl+0x4C) //############################################################################# // -// Swap the application mode-manager's control-mode bits (18..20) for the bit -// selected by new_mode, then notify the base of the implied display change. +// Gitea #6 RELABEL (2026-07-19). The old reconstruction carried @004d1ae4 as +// a non-virtual "SetControlMode" that nothing ever called, so the desktop's +// secondary MFD stayed pinned on the Damage view. The vtable @0051e440 +// (section_dump.txt:73033) pins the truth: +// +// +0x48 = @004d1acc -- dispatched by CycleControlModeMessageHandler +// (FUN_004afbe0) with the new controlMode. Capstone disasm: pushes +// its two args and forwards to the BASE no-op @004b048c. A +// control-mode (BAS/MID/ADV) change never touches the secondary +// view -- in the shipped binary OR here. +// +0x4C = @004d1ae4 -- dispatched by CycleDisplayModeMessageHandler +// (FUN_004afcac) with the new displayMode. THE secondary-view +// selector: clears mode bits 18..20 (& 0xffe3ffff = +// ~ModeSecondaryAll), sets the mask picked from the table @0051dbe4 +// = {ModeSecondaryDamage 0x40000, ModeSecondaryCritical 0x80000, +// ModeSecondaryHeat 0x100000}, then chains the base no-op +// @004b0494. displayMode 0/1/2 = Damage/Critical/Heat -- the +// manual-p13 "'Mech status Info center" schematic cycle (bottom +// left of the secondary screen). // void - L4MechControlsMapper::SetControlMode(int new_mode) + L4MechControlsMapper::NotifyOfControlModeChange(int new_mode) { - static const ModeMask control_mode_mask[] = // @0051dbe4 - { 0x40000, 0x80000, 0x100000 }; + MechControlsMapper::NotifyOfControlModeChange(new_mode); // @004b048c (no-op) + } + + void + L4MechControlsMapper::NotifyOfDisplayModeChange(int new_mode) + { + static const ModeMask display_mode_mask[] = // @0051dbe4 + { + BTL4ModeManager::ModeSecondaryDamage, // 0 -> 0x040000 + BTL4ModeManager::ModeSecondaryCritical, // 1 -> 0x080000 + BTL4ModeManager::ModeSecondaryHeat // 2 -> 0x100000 + }; BTL4ModeManager *mode_manager = (BTL4ModeManager*)application->GetModeManager(); // DAT_004efc94+0x50 - mode_manager->RemoveModeMask(0x001c0000); // clear bits 18..20 (~0xffe3ffff) - mode_manager->AddModeMask(control_mode_mask[new_mode]); + mode_manager->RemoveModeMask( // clear bits 18..20 + BTL4ModeManager::ModeSecondaryAll); // (& 0xffe3ffff) + mode_manager->AddModeMask(display_mode_mask[new_mode]); - NotifyOfDisplayModeChange(displayMode); // FUN_004b0494 + // DIAG (BT_MODE_LOG): the resulting manager mask -- proves the + // secondary-view bit actually flipped for the gauge layer gating. + if (getenv("BT_MODE_LOG")) + DEBUG_STREAM << "[mode] display notify " << new_mode + << " -> manager mask 0x" << std::hex + << mode_manager->GetModeMask() << std::dec << "\n" << std::flush; + + MechControlsMapper::NotifyOfDisplayModeChange(new_mode); // @004b0494 (no-op) } // diff --git a/game/reconstructed/btl4mppr.hpp b/game/reconstructed/btl4mppr.hpp index e6a15cf..e4aea7b 100644 --- a/game/reconstructed/btl4mppr.hpp +++ b/game/reconstructed/btl4mppr.hpp @@ -104,11 +104,27 @@ ZoomTargetRangeOut(); // @004d1b9c //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // Control / display modes + // Control / display modes (Gitea #6 relabel -- the vtable pins the slots). + // + // The binary's L4 vtable @0051e440 (section_dump): +0x48 = @004d1acc, + // +0x4C = @004d1ae4. CycleControlModeMessageHandler (FUN_004afbe0) + // dispatches vtbl+0x48 with the new controlMode; CycleDisplayModeMessage- + // Handler (FUN_004afcac) dispatches vtbl+0x4C with the new displayMode. + // So @004d1ae4 -- the {0x40000,0x80000,0x100000} secondary-view mask swap + // the old reconstruction called "SetControlMode" (never called by anyone) + // -- is the NotifyOfDisplayModeChange OVERRIDE: the secondary screen's + // Damage / Critical / Heat schematic is selected by the DISPLAY mode + // (manual p13: the "'Mech status Info center", bottom left of the + // secondary screen), NOT by the BAS/MID/ADV control mode (manual p6: top + // right of the secondary screen). @004d1acc (NotifyOfControlModeChange) + // just forwards to the base no-op @004b048c -- a control-mode change + // never touches the secondary view. // public: - void - SetControlMode(int new_mode); // @004d1ae4 (mode-mask switch) + virtual void + NotifyOfControlModeChange(int new_mode); // @004d1acc (+0x48, chains base no-op) + virtual void + NotifyOfDisplayModeChange(int new_mode); // @004d1ae4 (+0x4C, secondary-view mask swap) void SetPresetMode(int group, int item); // @004d1b24 (preset mode-mask) @@ -119,8 +135,8 @@ // old reconstruction mislabeled "NotifyOfControlModeChange / // NotifyOfConfigurationModeChange". Only MechRIOMapper overrides the // AddOrErase pair with real bodies -- a Thrustmaster cockpit cannot - // regroup. (The REAL NotifyOf* L4 overrides are @004d1acc/@004d1ae4, - // vtable +0x48/+0x4C -- out of scope here.) + // regroup. (The REAL NotifyOf* L4 overrides @004d1acc/@004d1ae4, + // vtable +0x48/+0x4C, are declared in the mode section above.) // public: virtual void diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 3b5eea1..68adbb2 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -652,6 +652,7 @@ 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) +int gBTDisplayCycle = 0; // 'N' edge: cycle the secondary schematic (Gitea #6, mapper consumes) int gBTLookBehind = 0; // 'V' held: the pod's rear-view button (task #68) float gBTTwistAxis = 0.0f; // Q/E torso-twist deflection (assisted-mode stick X) float gBTElevAxis = 0.0f; // R/F torso-elevation (pitch aim, stick Y) @@ -2670,6 +2671,15 @@ void const int mNow = gBTInput.modeCycle; if (mNow && !sPrevM) gBTModeCycle = 1; // edge -> one cycle sPrevM = mNow; + // Gitea #6: 'N' cycles the secondary screen's schematic + // (Damage -> Critical -> Heat) -- the pod's status-info- + // center button (manual p13), CycleDisplayModeNow. + { + static int sPrevN = 0; + const int nNow = gBTInput.displayCycle; + if (nNow && !sPrevN) gBTDisplayCycle = 1; + sPrevN = nNow; + } // (task #68) LookBehind action HELD = the pod's rear-view // button (releases back to the forward view). Rear-mounted // weapons (blackhawk/owens back racks) fire only in it. @@ -5149,6 +5159,38 @@ void gBTValveKey = (s_vtFrame >= 600 && s_vtFrame < 610) ? 1 : 0; } + // Gitea #6 scripted verify (BT_VIEWCYCLE_TEST=): pulse one + // secondary-schematic cycle (Damage -> Critical -> Heat -> Damage) at + // the given frame and every 300 frames after -- with + // BT_DEV_GAUGES_DOCK + BT_SHOT the docked sec panel's schematic layer + // swap is pixel-verifiable. BT_MODECYCLE_TEST= does the same + // for the M-key CONTROL-mode cycle (the empirical proof that a + // control-mode change does NOT touch the secondary view). + if ((Entity *)this == application->GetViewpointEntity() + && getenv("BT_VIEWCYCLE_TEST")) + { + static int s_vcFrame = 0; + ++s_vcFrame; + int t0 = atoi(getenv("BT_VIEWCYCLE_TEST")); + if (t0 < 1) t0 = 600; + if (s_vcFrame >= t0 && (s_vcFrame - t0) % 300 == 0) + { + gBTDisplayCycle = 1; + } + } + if ((Entity *)this == application->GetViewpointEntity() + && getenv("BT_MODECYCLE_TEST")) + { + static int s_mcFrame = 0; + ++s_mcFrame; + int t0 = atoi(getenv("BT_MODECYCLE_TEST")); + if (t0 < 1) t0 = 600; + if (s_mcFrame >= t0 && (s_mcFrame - t0) % 300 == 0) + { + gBTModeCycle = 1; + } + } + // Gitea #7 scripted verify (BT_FLUSH_TEST=): hold the coolant // flush for ~60 sim frames starting at the given frame -- the // reservoir drains ([flush] log), the coolant vertBar drops, and the diff --git a/game/reconstructed/mechmppr.cpp b/game/reconstructed/mechmppr.cpp index 1668d53..715e182 100644 --- a/game/reconstructed/mechmppr.cpp +++ b/game/reconstructed/mechmppr.cpp @@ -521,7 +521,13 @@ void //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // -// @004afcac -- cycle the HUD display mode (0 -> 1 -> 2 -> 0). +// @004afcac -- cycle the DISPLAY mode (0 -> 1 -> 2 -> 0). Gitea #6: this is +// the secondary screen's Damage/Critical/Heat schematic cycle -- the L4 +// NotifyOfDisplayModeChange override (vtbl+0x4C, @004d1ae4) swaps the +// ModeSecondary* mask bits 18..20. The pod input was the "'Mech status Info +// center" button (manual p13, bottom left of the secondary screen); the DOS +// keyboard fallback was extended-F4 (Keypress 0x13e), dead under the WinTesla +// VK map. // void MechControlsMapper::CycleDisplayModeMessageHandler( @@ -533,16 +539,30 @@ void if (message->dataContents > 0) { - displayMode = displayMode + 1; - if (displayMode > 2) - { - displayMode = 0; - } - NotifyOfDisplayModeChange(displayMode); // vtable+0x4c + CycleDisplayModeNow(); } Check_Fpu(); } +// +// The display-cycle body, shared by the console-button message handler above +// and the desktop 'N' key (mech4 key poll -> gBTDisplayCycle) -- the same +// split as CycleControlModeNow. +// +void + MechControlsMapper::CycleDisplayModeNow() +{ + displayMode = displayMode + 1; + if (displayMode > 2) + { + displayMode = 0; + } + NotifyOfDisplayModeChange(displayMode); // vtable+0x4c + DEBUG_STREAM << "[mode] display mode -> " << displayMode + << " (0=Damage 1=Critical 2=Heat)" << std::endl; + Check_Fpu(); +} + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // @004afce8 -- toggle voice assist on the Mech's pilot subsystem. @@ -759,6 +779,7 @@ void // CONTROL MODE gauge tracks). { extern int gBTModeCycle; + extern int gBTDisplayCycle; extern float gBTTwistAxis; extern int gBTTorsoRecenter; if (gBTModeCycle) @@ -766,6 +787,14 @@ void gBTModeCycle = 0; CycleControlModeNow(); } + // Gitea #6: 'N' cycles the secondary screen's schematic + // (Damage -> Critical -> Heat) -- the same body the pod's + // status-info-center button message drives. + if (gBTDisplayCycle) + { + gBTDisplayCycle = 0; + CycleDisplayModeNow(); + } // TORSO ELEVATION (pitch): the pod stick's Y axis -- every // control mode routes stickPosition.y into // Torso::SetAnalogElevationAxis below, so the bridge feeds diff --git a/game/reconstructed/mechmppr.hpp b/game/reconstructed/mechmppr.hpp index f0bb261..d3f05f8 100644 --- a/game/reconstructed/mechmppr.hpp +++ b/game/reconstructed/mechmppr.hpp @@ -113,6 +113,13 @@ class Pilot; CycleDisplayModeMessageHandler( // @004afcac ReceiverDataMessageOf *message ); + + // The shared display-cycle body (Gitea #6: the pod console button + // message above + the desktop 'N' key both drive it) -- cycles the + // secondary screen's Damage -> Critical -> Heat schematic via the + // NotifyOfDisplayModeChange override (vtbl+0x4C, @004d1ae4). + void + CycleDisplayModeNow(); void ToggleVoiceAssistMessageHandler( // @004afce8 ReceiverDataMessageOf *message From 678361906986f857e98bbbe824c7eede671a9d4d Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 17:30:35 -0500 Subject: [PATCH 14/17] Gitea #9: the upper-MFD PRESET pages (3 MFDs x 5) live -- SetPresetMode table re-decoded to the ModeMFD bits + desktop J/K/L page cycle The preset system was unwired by ONE defect in the message layer: the SetPresetMode @004d1b24 table @0051dbf0 had been transcribed from the section dump as BIG-endian dwords ({0x1e,0x01000000} instead of {0x1e,0x01}), so a preset press set a garbage high bit -- and for group 1 items 3-4 / group 2 items 0-2 stomped the LIVE NonMapping / Intercom / ModeSecondary* bits -- while the real page bits never moved. Ground truth (section_dump.txt:72901-72908, little-endian + BTL4MODE.HPP [T0]): set = ModeMFD{1,2,3}{Quad,Eng1-4} = 1<<(group*5+item), bits 0-14, fully disjoint from the #6 secondary trio (bits 18-20); group 2 is MFD3, NOT a duplicate of the secondary views. What the 15 presets show (l4gauge.cfg): group = the MFD (Mfd1 lower left / Mfd2 upper center / Mfd3 lower right), item 0 = the btquad.pcx Quad overview (up to 4 vehicleSubSystems cluster panels), items 1-4 = the full-screen engineering-detail pages (bteng.pcx + prepEngr screens group*4+1..4 + the cluster eng children: GENERATOR SELECT A-D, POWER graph, COOLING loop, DAMAGE, ammo). Empty screens are authored per mech (Blackhawk: 3/11/12 empty). Authentic dispatch (streamed "L4" .CTL EventMappings, BT_CTRLMAP_LOG dump): each MFD owns the 8-button RIO bank around it, MODE-MASK-gated -- Mfd1 = 0x08-0x0F, Mfd2 = 0x20-0x27, Mfd3 = 0x00-0x07. Quad page -> direct-select buttons for the POPULATED eng pages (mapper msgs Aux1Eng1-4 0x4-0x7 / Aux2* 0x9-0xC / Aux3* 0xE-0x11); eng page -> one back-to-Quad button (0x3/0x8/0xD) + per-subsystem controls. These records already install and fire on desktop (btinput passes the live manager mask), so the NUMPAD profile's 0x20-0x27 keys page MFD2 authentically. Port wiring (the #6 pattern): keys J/K/L -> actions Mfd1/2/3Cycle -> gBTPresetCycle -> L4MechControlsMapper::CyclePresetModeNow(group) -- a documented desktop shim (24 mode-dependent pod buttons don't fit a keyboard) that cycles Quad -> populated Eng pages -> Quad, visiting exactly the pod-reachable set; the body is the authentic SetPresetMode. Dev-composite: BTDrawGaugeSurfaces now draws the Eng1-3 planes at their sibling cells and skips any mono plane whose channel is BlankColor, honoring the mode-driven reconfigure (RemapGraphicsPort) -- each dev cell shows the ACTIVE page like the pod monitor. This supersedes and removes the 2026-07-12 GAUGREND "frozen-dial" scaffold (forced all 15 page bits active under BT_DEV_GAUGES; it pinned the shared Eng plane on the highest screen and ate the page flips). Pixel-verified (BT_PRESET_TEST + BT_DEV_GAUGES_DOCK + BT_SHOT, Blackhawk): all three MFDs page Quad -> SYSTEM NN eng details -> back to Quad in lockstep with the [mode] preset mask log; group 0 skips the authored-empty screen 3, group 2 skips 11/12. Un-regressed: N display cycle (0x450421->0x490421->0x510421, page bits intact), M control mode, CONTROLS.MAP 52 bindings parse clean. Diags: BT_MODE_LOG ([mode] preset), BT_PRESET_TEST=. Co-Authored-By: Claude Fable 5 --- content/CONTROLS.MAP | 5 +- content/CONTROLS_NUMPAD.MAP | 3 + context/decomp-reference.md | 3 +- context/gauges-hud.md | 53 ++++++++++ context/pod-hardware.md | 14 ++- engine/MUNGA/GAUGREND.cpp | 26 ++--- engine/MUNGA_L4/L4VB16.cpp | 23 ++++- engine/MUNGA_L4/L4VB16.h | 14 ++- game/reconstructed/CLASSMAP.md | 4 +- game/reconstructed/btinput.cpp | 11 ++ game/reconstructed/btinput.hpp | 3 + game/reconstructed/btl4mppr.cpp | 173 ++++++++++++++++++++++++++++++-- game/reconstructed/btl4mppr.hpp | 6 ++ game/reconstructed/mech4.cpp | 34 +++++++ 14 files changed, 336 insertions(+), 36 deletions(-) diff --git a/content/CONTROLS.MAP b/content/CONTROLS.MAP index 46f46d9..3dfcd88 100644 --- a/content/CONTROLS.MAP +++ b/content/CONTROLS.MAP @@ -32,7 +32,7 @@ # LeftStickX LeftStickY RightStickX RightStickY # LeftTrigger RightTrigger # action ViewToggle LookBehind AllStop ModeCycle DisplayCycle Valve Flush -# ConfigHold Generator1-4 Reconnect +# ConfigHold Generator1-4 Reconnect Mfd1Cycle Mfd2Cycle Mfd3Cycle # pckey sends an authentic 1995 PC-keyboard hotkey (the in-cockpit # dispatcher): '+'/'-' = target zoom, 'w'..'o' = pilot select, # 'a','b','c','d','f','g','s','v','x','z' = display presets, ... @@ -76,6 +76,9 @@ key C action Valve # coolant valve cycle key H action Flush # coolant flush (HOLD -- dumps reservoir coolant) key M action ModeCycle # Basic -> Standard -> Veteran controls key N action DisplayCycle # secondary schematic: Damage -> Critical -> Heat +key J action Mfd1Cycle # lower-left MFD page: Quad -> Eng detail pages +key K action Mfd2Cycle # upper-center MFD page (engineering) +key L action Mfd3Cycle # lower-right MFD page key V action ViewToggle # cockpit <-> chase camera (dev) key B action LookBehind # HOLD = the pod's rear-view button key F5 action Generator1 diff --git a/content/CONTROLS_NUMPAD.MAP b/content/CONTROLS_NUMPAD.MAP index f8eb82c..15d1482 100644 --- a/content/CONTROLS_NUMPAD.MAP +++ b/content/CONTROLS_NUMPAD.MAP @@ -53,6 +53,9 @@ key F7 action Generator3 key F8 action Generator4 key F9 action Reconnect key F10 action Valve +key J action Mfd1Cycle # lower-left MFD page: Quad -> Eng detail pages +key K action Mfd2Cycle # upper-center MFD page (engineering) +key L action Mfd3Cycle # lower-right MFD page # ---- Upper MFD banks: number row = the top aux row, QWERTY row = the row # ---- under it, left to right across the Left / Center / Right panels. diff --git a/context/decomp-reference.md b/context/decomp-reference.md index c606347..3e0252f 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -283,8 +283,9 @@ default-ON (`'0'` disables). | `BT_SELF=<[pilots] tag>` | claim a specific roster seat in relay mode; absent/`auto` = the relay assigns one (SEAT_REQUEST) | | `BT_RELAY_TCP_ONLY=1` | disable the relay UDP channel (all unreliable traffic rides the TCP relay conn) | | `BT_INPUT_LOG` | binding-engine trace: CONTROLS.MAP load, button/keypad emissions, pad connect, torso twist/elev axis ([[pod-hardware]] §Desktop input remap) | -| `BT_MODE_LOG` | `[mode] display notify` — the ModeManager mask after each secondary-view swap (btl4mppr.cpp NotifyOfDisplayModeChange, Gitea #6) | +| `BT_MODE_LOG` | `[mode] display notify` / `[mode] preset (g,i)` — the ModeManager mask after each secondary-view swap (Gitea #6) or upper-MFD preset-page swap (SetPresetMode, Gitea #9) (btl4mppr.cpp) | | `BT_VIEWCYCLE_TEST=` / `BT_MODECYCLE_TEST=` | scripted verify (mech4.cpp): pulse a secondary-schematic cycle / a control-mode cycle at the frame and every 300 after — pixel-verifiable with `BT_DEV_GAUGES_DOCK`+`BT_SHOT` | +| `BT_PRESET_TEST=` | scripted verify (mech4.cpp, Gitea #9): from the frame, pulse an upper-MFD preset-page cycle on all three MFDs every 120 frames (Quad → populated Eng pages → Quad) — pixel-verifiable with `BT_DEV_GAUGES_DOCK`+`BT_SHOT` | | `BT_CTRLMAP_LOG` | streamed `.CTL` record audit (L4CTRL.cpp CreateStreamedMappings): Direct records with the resolved member offset + EVENT records (button→subsystem message; found the pod's secondary-panel Cycle buttons 0x15/0x18) | | `BT_CAM_LOG` | camera-seat/broadcast trace: streamed camera network count, director pick + Players-group census, ship follow state, PlayerLink dispatch/receive, ranking-window render ([[multiplayer]] camera seat) | | `BT_SPEC_LOG` | manual spec-audit dump at subsystem ctor: torso speed/limits, heat-sink count, reservoir capacity, per-subsystem coolant loop ([[pod-hardware]] §Spec cross-check) | diff --git a/context/gauges-hud.md b/context/gauges-hud.md index 07a414e..8845215 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -9,6 +9,7 @@ open_questions: - "MessageBoard LIVE 2026-07-12 (kill ticker); remaining: only strip 0 (kill) is produced -- survey the other btsmsgs.pcx strips for authentic producers" - "HUD binary attr table @005110c0 offsets conflict with the port HUD layout on 3 slots (0x1D8/0x1EC/0x1F8) -- re-dump before publishing" - "Secondary-view cycling RESOLVED 2026-07-19 (Gitea #6): the selector is the DISPLAY mode (CycleDisplayMode -> vtbl+0x4C override @4d1ae4), NOT CycleControlMode; desktop 'N' / pad RightThumb wired; pixel-verified dama->crit->heat" + - "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired; remaining: the always-active 0x28-0x2F / 0x1A-0x1D per-subsystem msg-4 records (subs 3-13 -- likely the condenser-valve/coolant buttons) unidentified" - "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)" --- @@ -105,6 +106,58 @@ BT_SHOT): ARMOR DAMAGE silhouette → CRITICAL DAMAGE subsystem list → HEAT DA mask 0x450421→0x490421→0x510421. Diags: `BT_MODE_LOG`, `BT_VIEWCYCLE_TEST=`, `BT_MODECYCLE_TEST=`. +## The upper-MFD PRESET pages — 3 MFDs × 5 pages (Gitea #9, RESOLVED 2026-07-19) [T0/T1/T2] +The three preset-able MFDs are **Mfd1 (lower left) / Mfd2 (upper center) / Mfd3 (lower right)**, +each a PAIR of bit-planes on one physical monitor: the base **Quad** plane (`Mfd1/2/3`, masks +0x0100/0x0400/0x1000, `btquad.pcx`) and the **engineering-page** plane (`Eng1/2/3`, masks +0x0200/0x0800/0x2000, `bteng.pcx`). Mode bits (BTL4MODE.HPP [T0], bits 0-14): +`ModeMFD{1,2,3}{Quad,Eng1-4}` = `1<<(group*5+item)` — **fully disjoint** from Mapping/NonMapping +(15/16), Intercom (17) and the #6 Secondary* trio (18-20); `ModeInitial` puts all three MFDs on +Quad. **What the pages show:** Quad = up to four `vehicleSubSystems` cluster mini-panels (the +quadrants, geometry table @0x51bf34); Eng item i = the FULL-SCREEN engineering detail of the +subsystem streamed onto aux screen `group*4+i` (`sub+0x1dc`; `prepEngr` screens 1-12: "SYSTEM NN" ++ per-class label cells + the cluster's eng child — GENERATOR SELECT A-D, POWER graph, COOLING +loop, DAMAGE (MJ), ammo count …). Unpopulated screens are authored-empty per mech (Blackhawk: +9 of 12 — scr 1/2/4=PPC/Streak6/ERMed, 5-8=Sensors/Myomers/ERMed/ERMed, 9/10=Streak6/PPC; +screens 3, 11, 12 empty). + +**`SetPresetMode(group,item)` @004d1b24** swaps the page: table @0051dbf0 = 15 `{clear,set}` +pairs — set = the page's ModeMFD bit, clear = the group's other four (item 0) or all five +(items 1-4). ⚠ The old reconstruction had transcribed the little-endian set column as +BIG-endian dwords (0x01→0x01000000 …), so a preset press set a garbage high bit — and for +group 1 items 3-4 / group 2 items 0-2 STOMPED the live NonMapping/Intercom/Secondary* bits — +while the page bits never moved (the "presets unwired" defect). Fixed against +section_dump.txt:72901-72908; the "does group 2 duplicate #6's secondary views?" concern is +resolved: **no** — group 2 = MFD3, bits 10-14. + +**Authentic dispatch (streamed type-19 "L4" .CTL, 121 records dumped via `BT_CTRLMAP_LOG`):** +every MFD has its own 8-button RIO bank whose meanings are MODE-MASK-gated — **Mfd1 = +buttons 0x08-0x0F (AuxLowerLeft), Mfd2 = 0x20-0x27 (AuxUpperCenter), Mfd3 = 0x00-0x07 +(AuxLowerRight)**. On a Quad page the bank's buttons DIRECT-SELECT the populated Eng pages +(mapper EventMappings → `MechRIOMapper` messages `Aux1Eng1-4`=0x4-0x7, `Aux2Eng1-4`=0x9-0xC, +`Aux3Eng1-4`=0xE-0x11 → SetPresetMode; no button is streamed for an empty screen); on an Eng +page one button returns to Quad (`Aux1/2/3Quad` = 0x3/0x8/0xD) and the rest remap to the SHOWN +subsystem (per-subsystem msgs: 0x4-0x7 = SelectGeneratorA-D, 0x8 = ToggleGeneratorMode, 0x9 = +ConfigureMappables, 0x3/0xb = unjam/eject-class functions). The `MechRIOMapper` keyboard cases +in @004d1bf0 mirror it as three key rows: `1-4`=MFD1 Eng1-4/`5`=Quad, `a s d f`/`g`, +`z x c v`/`b` — mostly claimed by the port's WASD bindings, hence dead on desktop. +**Port wiring:** the streamed records install and fire on desktop (btinput passes the LIVE +manager mask on button press, so the NUMPAD profile's 0x20-0x27 keys page MFD2 authentically); +the default WASD profile adds **J/K/L → actions Mfd1/2/3Cycle → gBTPresetCycle → +`CyclePresetModeNow(group)`** (btl4mppr.cpp; a port cycle-key shim — 24 mode-dependent pod +buttons don't fit a keyboard — that visits exactly the pod-reachable set: Quad + populated Eng +pages; the body is the authentic SetPresetMode). + +**Dev-composite change:** `BTDrawGaugeSurfaces` (L4VB16.cpp) now draws the Eng1-3 planes into +their sibling's cell and SKIPS any mono plane whose port channel is currently `BlankColor` — +honoring the mode-driven `reconfigure` (RemapGraphicsPort) so each dev cell shows the ACTIVE +page, like the pod monitor. This SUPERSEDED the 2026-07-12 "frozen-dial" scaffold +(GAUGREND.cpp force-activated all 15 page bits under BT_DEV_GAUGES — removed; it made every eng +screen paint over the shared Eng plane, pinning it on the highest screen). Pixel-verified live +(BT_PRESET_TEST): all three MFDs page Quad → eng details → back to Quad in lockstep with the +[mode] mask log; N/M un-regressed. Diags: `BT_MODE_LOG` ([mode] preset lines), +`BT_PRESET_TEST=`, `BT_CTRLMAP_LOG`. + ## pilotList (Comm KILLS/DEATHS) row semantics + the −1 [T1/T2] One ROW PER PILOT in the mission (2-player MP = 2 rows — not duplicate displays). KILLS = `killCount` (the victim's ScoreMessageHandler credits the shooter cross-player, works for both diff --git a/context/pod-hardware.md b/context/pod-hardware.md index 98117d6..f417963 100644 --- a/context/pod-hardware.md +++ b/context/pod-hardware.md @@ -27,8 +27,8 @@ the `MechControlsMapper` interprets them ([[locomotion]]). [T2] ### The button space + lamps [T0, L4CTRL.h enum] buttonGroup addresses 0x00-0x47: `AuxLowerRight/Left 1-8` (0x00-0x0F), `Secondary1-12` (0x10-0x1B), -`AuxUpperCenter/Left/Right 1-8` (0x20-0x37 — the preset/eng "display eng data" banks; **0x30-0x37 = -the target HOTBOX**, pilot select), icom/door 0x39-0x3C, `Panic` 0x3D (the config-mode lamp), +`AuxUpperCenter/Left/Right 1-8` (0x20-0x37; **0x30-0x37 = the target HOTBOX**, pilot select), +icom/door 0x39-0x3C, `Panic` 0x3D (the config-mode lamp), `Throttle1` 0x3F (throttle-head = REVERSE THRUST), joystick cluster 0x40-0x47 (Main trigger 0x40, hat 0x41-0x44, Pinky/ThumbLow/ThumbHigh 0x45-0x47 — the four MAPPABLE fire buttons). "Lamp buttons" are literal: physical illuminated pushbuttons in panels around the screens; the RIO `LampRequest` @@ -37,6 +37,16 @@ per-lamp names). A lit button = active. Keypads are NOT buttons: `keyboardGroup[ KeyboardExternal]` carry key VALUES ('0'-'9','A'-'F', L4CTRL.cpp:2526). RIO event convention: press = `buttonGroup[a].Update(a+1, modeMask)` (mask saved), release = `Update(-a-1, savedMask)` (L4CTRL.cpp:2470-2520). +**Bank→MFD assignment (Gitea #9, from the streamed "L4" .CTL EventMapping dump [T1]):** each +preset-able MFD owns the 8-button bank AROUND it, mode-mask-gated per page — **Mfd1 (lower +left) = 0x08-0x0F (AuxLowerLeft), Mfd2 (upper center) = 0x20-0x27 (AuxUpperCenter), Mfd3 +(lower right) = 0x00-0x07 (AuxLowerRight)**: Quad page → the outer buttons direct-select the +populated Eng pages; Eng page → one button back to Quad, the rest drive the shown subsystem +(generator select A-D, gen mode, weapon config/unjam). Always-active: **0x28-0x2F +(AuxUpperLeft) → per-subsystem msg 0x4 to roster subs 3-9** and secondary-panel 0x1A-0x1D → +subs 10-13 msg 0x4 (identities unverified — likely the condenser-valve / coolant buttons +[T4]); 0x10/0x11 = map ZoomIn/Out, 0x15 = CycleDisplayMode, 0x18 = CycleControlMode (#6). +Full decode: [[gauges-hud]] §preset pages. ### Desktop input remap — CONTROLS.MAP + XInput (2026-07-18) [T2 live] `game/reconstructed/btinput.cpp` + `content/CONTROLS.MAP` (WASD-classic default, compiled-in twin; diff --git a/engine/MUNGA/GAUGREND.cpp b/engine/MUNGA/GAUGREND.cpp index 61978fe..1c2f6af 100644 --- a/engine/MUNGA/GAUGREND.cpp +++ b/engine/MUNGA/GAUGREND.cpp @@ -3677,22 +3677,16 @@ void Check(application->GetModeManager()); ModeMask current_mode_mask = application->GetModeManager()->GetModeMask(); - // DEV-COMPOSITE (2026-07-12, the frozen-dial fix): page-gated gauges (mode - // = their MFD page bit) freeze when their page isn't mode-active -- on the - // pod you FLIP pages, but the dev window shows every page surface at once, - // so six of seven weapon dials sat frozen on screen (user-hit; the arcs - // executed exactly 8 times at startup then never again). Under - // BT_DEV_GAUGES force the 15 MFD page-plane bits (BTL4 mode bits 0..14 = - // MFD1/2/3 Quad+Eng1-4) active so every visible page runs. The secondary - // trio (bits 18-20) stays authentic -- those views SHARE pixels and are - // exclusive by design. - { - static int s_devAllPages = -1; - if (s_devAllPages < 0) - s_devAllPages = (getenv("BT_DEV_GAUGES") != NULL) ? 1 : 0; - if (s_devAllPages) - current_mode_mask |= (ModeMask)0x7FFF; - } + // DEV-COMPOSITE history: a 2026-07-12 scaffold ("the frozen-dial fix") + // forced the 15 MFD page-plane bits (BTL4 mode bits 0..14 = MFD1/2/3 + // Quad+Eng1-4) active under BT_DEV_GAUGES, because the dev panel then drew + // every page plane at once and page-gated dials froze. REMOVED for Gitea + // #9 (2026-07-19): the dev composite now honors the mode-driven + // `reconfigure` (a BlankColor plane is skipped -- L4VB16.cpp + // BTDrawGaugeSurfaces), so exactly the ACTIVE page of each MFD is shown, + // like the pod -- and the working SetPresetMode page switching NEEDS the + // authentic gating (the force made every eng screen paint over the shared + // Eng plane, pinning it on the highest screen and ignoring page flips). ModeMask previous_mode_mask = application->GetModeManager()->GetPreviousModeMask(); ModeMask diff --git a/engine/MUNGA_L4/L4VB16.cpp b/engine/MUNGA_L4/L4VB16.cpp index ecec317..aebb191 100644 --- a/engine/MUNGA_L4/L4VB16.cpp +++ b/engine/MUNGA_L4/L4VB16.cpp @@ -167,7 +167,7 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa { int w = pixelBuffer.Data.Size.x; // 640 int h = pixelBuffer.Data.Size.y; // 480 - if (device == NULL || slot < 0 || slot >= 8 || w <= 0 || h <= 0) return; + if (device == NULL || slot < 0 || slot >= 10 || w <= 0 || h <= 0) return; // Lazily create this surface's texture on the MAIN device (MANAGED -> lockable + // survives device reset). @@ -289,7 +289,13 @@ struct BTGaugeSurfaceDesc // buffer, so past ~1.5x you magnify, not sharpen. static const float kBTPanelW = 1320.0f; static const float kBTPanelH = 480.0f; -static const BTGaugeSurfaceDesc kBTGaugeSurfaces[6] = +// Gitea #9: each preset-able MFD is TWO bit-planes sharing one physical +// monitor -- the base Quad plane (Mfd1/2/3) and the engineering-page plane +// (Eng1/2/3). The mode-driven `reconfigure` swaps which of the pair is +// channel-enabled and which is BlankColor; the draw loop below skips the +// blanked plane, so the dev cell shows exactly what the pod monitor shows. +// The Eng entries share their sibling's cell rect and FOLLOW it in the list. +static const BTGaugeSurfaceDesc kBTGaugeSurfaces[9] = { { "Heat", 0xFFFF, 0.0f / kBTPanelW, 0.0f, 320.0f / kBTPanelW, 0.5f, 0 }, { "Mfd2", 0xFFFF, 320.0f / kBTPanelW, 0.0f, 320.0f / kBTPanelW, 0.5f, 0 }, @@ -297,6 +303,9 @@ static const BTGaugeSurfaceDesc kBTGaugeSurfaces[6] = { "Mfd3", 0xFFFF, 320.0f / kBTPanelW, 0.5f, 320.0f / kBTPanelW, 0.5f, 0 }, { "sec", -1, 640.0f / kBTPanelW, 0.0f, 360.0f / kBTPanelW, 1.0f, 1 }, { "Comm", 0xFFFF, 1000.0f / kBTPanelW, 0.0f, 320.0f / kBTPanelW, 0.5f, 0 }, + { "Eng2", 0xFFFF, 320.0f / kBTPanelW, 0.0f, 320.0f / kBTPanelW, 0.5f, 0 }, + { "Eng1", 0xFFFF, 0.0f / kBTPanelW, 0.5f, 320.0f / kBTPanelW, 0.5f, 0 }, + { "Eng3", 0xFFFF, 320.0f / kBTPanelW, 0.5f, 320.0f / kBTPanelW, 0.5f, 0 }, }; // @@ -345,11 +354,17 @@ void BTDrawGaugeSurfaces(LPDIRECT3DDEVICE9 device, float px, float py, float pw, } if (svga == NULL) return; - for (int i = 0; i < 6; i++) + for (int i = 0; i < 9; i++) { const BTGaugeSurfaceDesc &d = kBTGaugeSurfaces[i]; L4GraphicsPort *port = static_cast(gr->GetGraphicsPort(d.portName)); if (port == NULL) continue; // this surface's port isn't configured -> skip + // Gitea #9: honor the live mode-driven `reconfigure` -- a mono plane + // whose channel is currently BlankColor contributes nothing on the pod + // monitor (its palette bits translate to black), so skip it; its + // non-blank sibling (Mfd or Eng, same cell rect) draws instead. + if (d.monoTint >= 0 && port->GetEnableID() == L4GraphicsPort::BlankColor) + continue; // Secondary-CRT unrotation direction is env-flippable (BT_GAUGE_SEC_ROT=3 // for the other way) in case the assumed pod mounting is mirrored. int rot = d.rotateCCW; @@ -4309,7 +4324,7 @@ SVGA16::SVGA16( ):Video16BitBuffered(init_width, init_height) { BuildWindows(init_width,init_height,windowed, secondaryIndex, aux1Index, aux2Index); - for (int _i = 0; _i < 8; _i++) // DEV-COMPOSITE: lazily created on first surface draw + for (int _i = 0; _i < 10; _i++) // DEV-COMPOSITE: lazily created on first surface draw mDevSurfaceTex[_i] = NULL; //STUBBED: VIDEO RB 1/15/07 # if defined(DEBUG) diff --git a/engine/MUNGA_L4/L4VB16.h b/engine/MUNGA_L4/L4VB16.h index 140c7e6..55f56c2 100644 --- a/engine/MUNGA_L4/L4VB16.h +++ b/engine/MUNGA_L4/L4VB16.h @@ -292,8 +292,9 @@ private: int mDisplayToUpdate; - LPDIRECT3DTEXTURE9 mDevSurfaceTex[8]; // DEV-COMPOSITE: one texture per gauge surface - // (sec/radar + the 5 MFD bit-planes), on the MAIN device. + LPDIRECT3DTEXTURE9 mDevSurfaceTex[10]; // DEV-COMPOSITE: one texture per gauge surface + // (sec/radar + the 5 MFD bit-planes + the Eng1-3 + // preset-page planes, Gitea #9), on the MAIN device. public: // DEV-COMPOSITE: extract ONE gauge surface from the shared pixelBuffer into // mDevSurfaceTex[slot] on the given (main) device + draw it as a quad at (dstX,dstY, @@ -462,6 +463,15 @@ public: Check(this); return bitMask; } + // DEV-COMPOSITE (Gitea #9): the dock honors the live reconfigure state -- + // a plane whose channel is currently BlankColor contributes nothing on the + // pod's shared-word palette LUT, so the dev panel skips drawing it. + L4GraphicsPort::ChannelEnableID + GetEnableID() + { + Check(this); + return channelEnable; + } SVGA16::PaletteID paletteID; diff --git a/game/reconstructed/CLASSMAP.md b/game/reconstructed/CLASSMAP.md index 6f1033b..a589996 100644 --- a/game/reconstructed/CLASSMAP.md +++ b/game/reconstructed/CLASSMAP.md @@ -151,7 +151,9 @@ Subsystem (MUNGA base — have source: RP/MUNGA/SUBSYSTM.HPP) │ control mode never touches the secondary view). NotifyOfDisplayModeChange-override@4d1ae4 (vtbl+0x4C; ex-"SetControlMode"; Gitea #6 reconstructed │ 2026-07-19 = THE secondary Damage/Critical/Heat selector, mode bits18-20, table @0051dbe4={0x40000,0x80000,0x100000}; pod input = streamed │ EventMapping secondary-panel button 0x15 -> msg 0x15 CycleDisplayMode; button 0x18 -> msg 0x14 CycleControlMode). -│ SetPresetMode@4d1b24 (group*5+item -> {clear,set} mask table @0051dbf0[15]). +│ SetPresetMode@4d1b24 (group*5+item -> {clear,set} mask table @0051dbf0[15]; Gitea #9 decode: set = ModeMFD{1,2,3}{Quad,Eng1-4} +│ = 1<<(group*5+item), bits 0-14 [section_dump:72901-72908 little-endian]; clear = the group's other/all page bits. +│ Group = the MFD (0/1/2 = lower-left/upper-center/lower-right), item 0 = Quad overview, 1-4 = eng-detail pages). │ EnterConfiguration@4d1840 (+0x38) / ExitConfiguration@4d18dc (+0x3C) (task #6 relabel (buttons 0x40..0x47, hat-skip 0x41->0x45, │ mode 0x8000=ConfigReady). AddOrErase evt/dir RET no-ops @4d195c/@4d1964 (+0x40/+0x44; TM can't regroup). KeypressMessageHandler@4d1bf0 │ (shared dispatcher: zoom/preset/pilot-cycle/look keys + fake-button dev keys; default -> @4d141c). diff --git a/game/reconstructed/btinput.cpp b/game/reconstructed/btinput.cpp index cf3b3ae..f2af152 100644 --- a/game/reconstructed/btinput.cpp +++ b/game/reconstructed/btinput.cpp @@ -79,6 +79,9 @@ enum BTActionID BTActGenerator3, BTActGenerator4, BTActReconnect, + BTActMfd1Cycle, // Gitea #9: cycle the lower-left MFD preset page + BTActMfd2Cycle, // Gitea #9: cycle the upper-center MFD preset page + BTActMfd3Cycle, // Gitea #9: cycle the lower-right MFD preset page BTActCount }; @@ -199,6 +202,8 @@ static const BTName sActionNames[] = {"Generator1", BTActGenerator1}, {"Generator2", BTActGenerator2}, {"Generator3", BTActGenerator3}, {"Generator4", BTActGenerator4}, {"Reconnect", BTActReconnect}, + {"Mfd1Cycle", BTActMfd1Cycle}, {"Mfd2Cycle", BTActMfd2Cycle}, + {"Mfd3Cycle", BTActMfd3Cycle}, {0, 0} }; @@ -269,6 +274,9 @@ static const char *sDefaultProfile = "key H action Flush\n" "key M action ModeCycle\n" "key N action DisplayCycle\n" + "key J action Mfd1Cycle\n" + "key K action Mfd2Cycle\n" + "key L action Mfd3Cycle\n" "key V action ViewToggle\n" "key B action LookBehind\n" "key F5 action Generator1\n" @@ -971,6 +979,9 @@ void case BTActValve: next.valve = 1; break; case BTActFlush: next.flush = 1; break; case BTActConfigHold: next.configHold = 1; break; + case BTActMfd1Cycle: next.mfdCycle[0] = 1; break; + case BTActMfd2Cycle: next.mfdCycle[1] = 1; break; + case BTActMfd3Cycle: next.mfdCycle[2] = 1; break; case BTActGenerator1: next.genSel = 4; break; case BTActGenerator2: next.genSel = 5; break; case BTActGenerator3: next.genSel = 6; break; diff --git a/game/reconstructed/btinput.hpp b/game/reconstructed/btinput.hpp index d22517d..11be1a6 100644 --- a/game/reconstructed/btinput.hpp +++ b/game/reconstructed/btinput.hpp @@ -56,6 +56,9 @@ struct BTInputState int flush; // Gitea #7: coolant flush HELD (InjectCoolant) int configHold; int genSel; // 0 = none, 4..7 = Generator A..D, 8 = reconnect + // Gitea #9: per-MFD preset-page cycle (desktop senders for SetPresetMode; + // index 0/1/2 = Mfd1 lower-left / Mfd2 upper-center / Mfd3 lower-right) + int mfdCycle[3]; }; extern BTInputState gBTInput; diff --git a/game/reconstructed/btl4mppr.cpp b/game/reconstructed/btl4mppr.cpp index 61d37ab..bec20b0 100644 --- a/game/reconstructed/btl4mppr.cpp +++ b/game/reconstructed/btl4mppr.cpp @@ -86,6 +86,8 @@ // DAT_0051dbe4[3] = {0x40000,0x80000,0x100000} secondary-VIEW mode-masks // (ModeSecondaryDamage/Critical/Heat, indexed by displayMode) // DAT_0051dbf0[15] = {clear,set} mode-mask pairs (group*5+item), preset select +// (little-endian: set = ModeMFD{1,2,3}{Quad,Eng1-4} bits 0-14 +// -- Gitea #9; see SetPresetMode below) // DAT_0051dcd0[8] = {0x37,0x36,0x35,0x34,0x33,0x32,0x31,0x30} hotbox buttons // // ModeManager (app+0x50): +0x4 currentMode mask, +0x8 savedMode mask. @@ -425,6 +427,23 @@ L4MechControlsMapper::MessageHandlerSet& } } + // + // (3b) PORT (Gitea #9): the desktop J/K/L per-MFD preset-page cycle + // edges (mech4 poll) -- the sender for the pod's per-MFD page-button + // banks; the body is the authentic SetPresetMode. + // + { + extern int gBTPresetCycle[3]; + for (int mfd_group = 0; mfd_group < 3; ++mfd_group) + { + if (gBTPresetCycle[mfd_group]) + { + gBTPresetCycle[mfd_group] = 0; + CyclePresetModeNow(mfd_group); + } + } + } + // // (4) Target-range zoom: slew current exponent toward demand, then set // the Mech's target range = 250 * 2^exponent. @@ -547,9 +566,24 @@ L4MechControlsMapper::MessageHandlerSet& // SetPresetMode @004d1b24 //############################################################################# // -// Each (group,item) selects a preset; the table @0051dbf0 holds {clear,set} -// mode-mask pairs that remove the previous preset of that group and enable the -// chosen one. +// Each (group,item) selects an upper-MFD PRESET page; the table @0051dbf0 +// holds {clear,set} mode-mask pairs that remove the previous preset of that +// group and enable the chosen one. Group = the MFD (0/1/2 = Mfd1 lower-left / +// Mfd2 upper-center / Mfd3 lower-right); item 0 = the Quad overview page +// (four subsystem cluster panels), items 1-4 = the four full-screen +// engineering-detail pages (aux screens group*4+1..group*4+4, prepEngr). +// +// Gitea #9 TABLE FIX (2026-07-19): the set column had been transcribed from +// the section dump as BIG-endian dwords ({0x1e,0x01000000} instead of +// {0x1e,0x01}, etc.) -- so a preset press set a garbage high bit (and, for +// group 1 items 3-4 / group 2 items 0-2, stomped the LIVE ModeNonMapping / +// ModeIntercom / ModeSecondary* bits) while the ModeMFD* page bits never +// moved: the panels stayed pinned on their Quad pages. Ground truth +// (section_dump.txt:72901-72908, little-endian) decodes the table to exactly +// the BTL4MODE.HPP [T0] ModeMFD{1,2,3}{Quad,Eng1-4} allocation, bits 0-14: +// set = 1 << (group*5 + item); clear = the group's other four page bits +// (item 0) or all five (items 1-4). The presets are fully disjoint from the +// Mapping/NonMapping (15/16), Intercom (17) and Secondary* (18-20) bits. // void L4MechControlsMapper::SetPresetMode(int group, int item) @@ -557,14 +591,31 @@ L4MechControlsMapper::MessageHandlerSet& struct PresetMaskPair { ModeMask clear, set; }; static const PresetMaskPair preset_mask[15] = // @0051dbf0 (group*5+item) { - {0x1e, 0x01000000}, {0x1f, 0x02000000}, {0x1f, 0x04000000}, - {0x1f, 0x08000000}, {0x1f, 0x10000000}, - {0x3c0, 0x20000000}, {0x3e0, 0x40000000}, {0x3e0, 0x80000000}, - {0x3e0, 0x00010000}, {0x3e0, 0x00020000}, - {0x7800,0x00040000}, {0x7c00,0x00080000}, {0x7c00,0x00100000}, - {0x7c00,0x00200000}, {0x7c00,0x00400000} + // group 0 = Mfd1 (lower left) + {0x001e, BTL4ModeManager::ModeMFD1Quad}, // 0x0001 + {0x001f, BTL4ModeManager::ModeMFD1Eng1}, // 0x0002 + {0x001f, BTL4ModeManager::ModeMFD1Eng2}, // 0x0004 + {0x001f, BTL4ModeManager::ModeMFD1Eng3}, // 0x0008 + {0x001f, BTL4ModeManager::ModeMFD1Eng4}, // 0x0010 + // group 1 = Mfd2 (upper center) + {0x03c0, BTL4ModeManager::ModeMFD2Quad}, // 0x0020 + {0x03e0, BTL4ModeManager::ModeMFD2Eng1}, // 0x0040 + {0x03e0, BTL4ModeManager::ModeMFD2Eng2}, // 0x0080 + {0x03e0, BTL4ModeManager::ModeMFD2Eng3}, // 0x0100 + {0x03e0, BTL4ModeManager::ModeMFD2Eng4}, // 0x0200 + // group 2 = Mfd3 (lower right) + {0x7800, BTL4ModeManager::ModeMFD3Quad}, // 0x0400 + {0x7c00, BTL4ModeManager::ModeMFD3Eng1}, // 0x0800 + {0x7c00, BTL4ModeManager::ModeMFD3Eng2}, // 0x1000 + {0x7c00, BTL4ModeManager::ModeMFD3Eng3}, // 0x2000 + {0x7c00, BTL4ModeManager::ModeMFD3Eng4} // 0x4000 }; + if (group < 0 || group > 2 || item < 0 || item > 4) + { + return; + } + int index = group * 5 + item; BTL4ModeManager *mode_manager = @@ -572,6 +623,110 @@ L4MechControlsMapper::MessageHandlerSet& mode_manager->RemoveModeMask(preset_mask[index].clear); mode_manager->AddModeMask(preset_mask[index].set); + + // DIAG (BT_MODE_LOG): prove the ModeMFD* page bit actually flipped for + // the gauge reconfigure/prepEngr layer gating. + if (getenv("BT_MODE_LOG")) + DEBUG_STREAM << "[mode] preset (" << group << "," << item + << ") -> manager mask 0x" << std::hex + << mode_manager->GetModeMask() << std::dec << "\n" << std::flush; + } + +// +//############################################################################# +// CyclePresetModeNow (PORT desktop sender -- Gitea #9, no binary analog) +//############################################################################# +// +// On the pod every MFD has its own 8-button bank (Mfd1 lower-left = RIO +// buttons 0x08-0x0F, Mfd2 upper-center = 0x20-0x27, Mfd3 lower-right = +// 0x00-0x07) whose meanings are MODE-MASK-gated by the streamed "L4" .CTL +// EventMappings: on a Quad page the bank's outer buttons DIRECT-SELECT the +// populated Eng pages (mapper messages Aux1Eng1..Aux3Eng4 = 0x4-0x7 / 0x9-0xC +// / 0xE-0x11 -> SetPresetMode), on an Eng page one button returns to Quad +// (Aux1Quad/Aux2Quad/Aux3Quad = 0x3/0x8/0xD) and the rest drive the shown +// subsystem. A desktop keyboard cannot host 24 mode-dependent buttons, so +// the port cycles each MFD with one key (J/K/L -> mech4 edge poll -> +// gBTPresetCycle -> here); the message BODY is the authentic SetPresetMode. +// The cycle visits exactly the pages the pod's streamed buttons could reach: +// Quad always, an Eng page only if a roster subsystem is assigned to that +// aux screen (screens group*4+1 .. group*4+4; the shipped .CTL carries NO +// select button for an unpopulated screen -- e.g. the Blackhawk's screens +// 3, 11 and 12 are authored empty). +// + // The vehicleSubSystems/prepEngr aux-screen bridge (powersub.cpp). + extern bool BTGetSubsystemAuxScreen( + Subsystem *sub, int *screen, int *placement, char *label64); + + void + L4MechControlsMapper::CyclePresetModeNow(int group) + { + if (group < 0 || group > 2) + { + return; + } + + BTL4ModeManager *mode_manager = + (BTL4ModeManager*)application->GetModeManager(); + + // + // Current page = the set bit among the group's five ModeMFD* bits. + // + ModeMask mask = mode_manager->GetModeMask(); + int current = 0; // default: Quad + for (int i = 0; i < 5; ++i) + { + if (mask & (1 << (group * 5 + i))) + { + current = i; + break; + } + } + + // + // Which aux screens are populated on this mech (streamed sub+0x1dc)? + // + Mech *mech = GetMech(); + bool populated[5] = { true, false, false, false, false }; // Quad always + if (mech != NULL) + { + int count = mech->GetSubsystemCount(); + for (int i = 1; i < count; ++i) + { + Subsystem *sub = mech->GetSubsystem(i); + if (sub == NULL) + { + continue; + } + int screen = 0, placement = -1; + if (!BTGetSubsystemAuxScreen(sub, &screen, &placement, NULL)) + { + continue; + } + int item = screen - group * 4; // Eng item 1..4 of THIS group? + if (item >= 1 && item <= 4) + { + populated[item] = true; + } + } + } + + // + // Advance to the next reachable page (wraps through Quad). + // + int next = current; + for (int step = 1; step <= 5; ++step) + { + int candidate = (current + step) % 5; + if (populated[candidate]) + { + next = candidate; + break; + } + } + if (next != current) + { + SetPresetMode(group, next); + } } // diff --git a/game/reconstructed/btl4mppr.hpp b/game/reconstructed/btl4mppr.hpp index e4aea7b..5a12fef 100644 --- a/game/reconstructed/btl4mppr.hpp +++ b/game/reconstructed/btl4mppr.hpp @@ -127,6 +127,12 @@ NotifyOfDisplayModeChange(int new_mode); // @004d1ae4 (+0x4C, secondary-view mask swap) void SetPresetMode(int group, int item); // @004d1b24 (preset mode-mask) + void + CyclePresetModeNow(int group); // PORT (Gitea #9): desktop J/K/L + // per-MFD page cycle; visits Quad + + // the POPULATED Eng pages only (the + // reachable set of the pod's streamed + // page buttons); body = SetPresetMode //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Configuration support (overrides the MechControlsMapper traps; task #6 diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 68adbb2..2c19f29 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -653,6 +653,7 @@ 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) int gBTDisplayCycle = 0; // 'N' edge: cycle the secondary schematic (Gitea #6, mapper consumes) +int gBTPresetCycle[3] = {0,0,0}; // J/K/L edges: cycle an upper-MFD preset page (Gitea #9, L4 mapper consumes) int gBTLookBehind = 0; // 'V' held: the pod's rear-view button (task #68) float gBTTwistAxis = 0.0f; // Q/E torso-twist deflection (assisted-mode stick X) float gBTElevAxis = 0.0f; // R/F torso-elevation (pitch aim, stick Y) @@ -2680,6 +2681,19 @@ void if (nNow && !sPrevN) gBTDisplayCycle = 1; sPrevN = nNow; } + // Gitea #9: J/K/L cycle an upper-MFD preset page + // (Quad -> populated Eng pages -> Quad) -- the desktop + // sender for the pod's per-MFD page-button banks + // (SetPresetMode, consumed by the L4 mapper). + { + static int sPrevMfd[3] = {0,0,0}; + for (int mg = 0; mg < 3; ++mg) + { + const int gNow = gBTInput.mfdCycle[mg]; + if (gNow && !sPrevMfd[mg]) gBTPresetCycle[mg] = 1; + sPrevMfd[mg] = gNow; + } + } // (task #68) LookBehind action HELD = the pod's rear-view // button (releases back to the forward view). Rear-mounted // weapons (blackhawk/owens back racks) fire only in it. @@ -5191,6 +5205,26 @@ void } } + // Gitea #9 scripted verify (BT_PRESET_TEST=): from the given + // frame, pulse a preset-page cycle on ALL THREE upper MFDs every 120 + // frames (Quad -> populated Eng pages -> Quad, per MFD) -- with + // BT_DEV_GAUGES_DOCK + BT_SHOT the Mfd1/Mfd2/Mfd3 page swaps are + // pixel-verifiable ([mode] preset log lines under BT_MODE_LOG). + if ((Entity *)this == application->GetViewpointEntity() + && getenv("BT_PRESET_TEST")) + { + static int s_ptFrame = 0; + ++s_ptFrame; + int t0 = atoi(getenv("BT_PRESET_TEST")); + if (t0 < 1) t0 = 600; + if (s_ptFrame >= t0 && (s_ptFrame - t0) % 120 == 0) + { + gBTPresetCycle[0] = 1; + gBTPresetCycle[1] = 1; + gBTPresetCycle[2] = 1; + } + } + // Gitea #7 scripted verify (BT_FLUSH_TEST=): hold the coolant // flush for ~60 sim frames starting at the given frame -- the // reservoir drains ([flush] log), the coolant vertBar drops, and the From 904c75aff9cfc9c7373aad631d6bf6e61dd8b98c Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 18:33:44 -0500 Subject: [PATCH 15/17] Gitea #10 audit fixes: HUD attr table re-based (@5110b8, one-record shift) + BT_SHOT dock capture - hud.hpp/hud.cpp/CLASSMAP.md: the binary HUD AttributePointers table starts at @5110b8 with a FULL id-3 FlickerRate record (the old transcription read it as a label and shifted every offset one slot). Re-based member names/offsets: rotationOfTorsoHorizontal@0x1DC (<- Torso+0x1D8 twist), limits@0x1E0/0x1E4, speed@0x1E8, rangeToTarget@0x1EC, @0x1F0 unbound, Visible@0x1F4 (init 1), Lock@0x1F8 (init 0), HotBoxVector P3D@0x1FC, ThreatVector P3D@0x208, CompassHeading Scalar@0x214 (= yaw euler[0] + twist, HudSimulation :5676). Cross-checked vs ctor @004b7f94 + HudSimulation @004b7830 (Lock rule writes @0x1F8 :5622/:5633; range default 1200 + 500 m/s slide @0x1EC). SetCompassHeading renamed SetThreatVector (@004b7810 writes @0x208 = the threat vector; no port caller). No behavioral change: the reticle feeds via port globals and the CFG never binds HUD attrs. Resolves the gauges-hud open question (Gitea #10 entry b). - L4VIDEO.cpp: BT_SHOT capture moved AFTER BTDrawGaugeInset -- the 2026-07-18 reorder had left it before the dock blit, so BT_DEV_GAUGES_DOCK screenshots silently omitted the gauge panel. Co-Authored-By: Claude Fable 5 --- engine/MUNGA_L4/L4VIDEO.cpp | 16 +++---- game/reconstructed/CLASSMAP.md | 15 ++++--- game/reconstructed/hud.cpp | 54 ++++++++++++++---------- game/reconstructed/hud.hpp | 77 +++++++++++++++++++++------------- 4 files changed, 100 insertions(+), 62 deletions(-) diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index 6bf777d..d2c6328 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -8585,12 +8585,19 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte if (mCamShipHUD) mCamShipHUD->Render(0, &viewTransform); + // DEV-COMPOSITE: in DOCKED mode (BT_DEV_GAUGES_DOCK) blit the 6-surface gauge panel + // into this window as the LAST draw before EndScene (no-op otherwise / off pod). + extern void BTDrawGaugeInset(LPDIRECT3DDEVICE9 device); + BTDrawGaugeInset(mDevice); + // DIAG (off by default): BT_SHOT= dumps this instance's backbuffer to a // PNG every 90 frames -- non-disruptive per-instance frame capture without // foregrounding the window. Built for the -net render glitch (task #53); // moved AFTER the 2D pass (2026-07-18, camera-seat bring-up) so captures - // include the HUD overlays (reticle, camera-ship ranking window) the screen - // actually shows. + // include the HUD overlays (reticle, camera-ship ranking window), and AFTER + // the docked gauge inset (2026-07-19, Gitea #10 audit -- the 07-18 reorder + // had left the capture BEFORE BTDrawGaugeInset, so BT_DEV_GAUGES_DOCK shots + // were missing the gauge panel the screen actually shows). { const char *shotPath = getenv("BT_SHOT"); if (shotPath) @@ -8613,11 +8620,6 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte } } - // DEV-COMPOSITE: in DOCKED mode (BT_DEV_GAUGES_DOCK) blit the 6-surface gauge panel - // into this window as the LAST draw before EndScene (no-op otherwise / off pod). - extern void BTDrawGaugeInset(LPDIRECT3DDEVICE9 device); - BTDrawGaugeInset(mDevice); - hr = mDevice->EndScene(); // DEV-COMPOSITE: default mode -- render the 6 cockpit surfaces into a SEPARATE window diff --git a/game/reconstructed/CLASSMAP.md b/game/reconstructed/CLASSMAP.md index a589996..995cce5 100644 --- a/game/reconstructed/CLASSMAP.md +++ b/game/reconstructed/CLASSMAP.md @@ -31,13 +31,18 @@ Subsystem (MUNGA base — have source: RP/MUNGA/SUBSYSTM.HPP) │ │ │ cockpit heads-up display (torso-horizon gimbal + reticle/threat/status read-outs). Confirms the │ │ │ "Resolved conflict" note: @4b7f94 is the HUD, mislabeled "MechTech" by mech.cpp's 0xBD6 factory case. │ │ │ DefaultData 00511078; resource +0xF4 FlickerRate, +0xF8 SegmentPageName(idx), +0xFC HorizontalMovementPerSecond, +0x100 HorizontalLimit. -│ │ │ members: flickerRate@0x1D8, torso/target read-outs@0x1DC-0x1EC, lockFlag@0x1F4, visible@0x1F8, -│ │ │ threatVector(Vec3)@0x1FC, compassHeading(Vec3)@0x208 (setter @4b7810), blink/slew accums@0x218-0x230, +│ │ │ members (re-based 2026-07-19, Gitea #10, per the re-dumped attr table @5110b8): flickerRate@0x1D8, +│ │ │ rotationOfTorsoHorizontal@0x1DC(<-Torso+0x1D8 twist), torsoLimits@0x1E0/0x1E4, torsoSpeed@0x1E8, +│ │ │ rangeToTarget@0x1EC, @0x1F0 unbound, visible@0x1F4(init 1), lockFlag@0x1F8(init 0), +│ │ │ hotBoxVector(Vec3)@0x1FC, threatVector(Vec3)@0x208 (setter @4b7810 = SetThreatVector, ex-"SetCompassHeading" mislabel), +│ │ │ compassHeading(Scalar)@0x214(=yaw+twist), blink/slew accums@0x218-0x230, │ │ │ linkedEntity@0x234(=Mech+0x438), statusAlarm(2)@0x238, horizontalTorsoOffset@0x294, HMPS@0x298, HorizontalLimit@0x29C, flickerActive@0x2A0. │ │ │ overrides: HandleMessage@4b7780(s9, body not captured), ResetToInitialState@4b77bc(s10). HudSimulation@4b7830 (Performance, PTR@511170). -│ │ │ attr table @5110c0 (ids 4-0xd: RotationOfTorsoHorizontal/HorizontalTorsoLimit{Right,Left}/SpeedOfTorsoHorizontal/ -│ │ │ RangeToTarget/Visible/Lock/HotBoxVector/ThreatVector/CompassHeading) — NB attr name<->offset bindings only -│ │ │ partially agree with ctor/sim usage (UNCERTAIN). TestInstance@4b81b8 (deriv @511088 "HUD"). UpdateFlicker@4b7ed4. +│ │ │ attr table @5110b8 (16-byte records; ids 3-0xd: FlickerRate@0x1D8 + RotationOfTorsoHorizontal@0x1DC/ +│ │ │ HorizontalTorsoLimit{Right@0x1E0,Left@0x1E4}/SpeedOfTorsoHorizontal@0x1E8/RangeToTarget@0x1EC/Visible@0x1F4/ +│ │ │ Lock@0x1F8/HotBoxVector@0x1FC/ThreatVector@0x208/CompassHeading@0x214) — RESOLVED 2026-07-19: the old +│ │ │ "@5110c0, ids 4-0xd" transcription had dropped the leading id-3 FlickerRate record and shifted every offset +│ │ │ one slot; ctor+HudSimulation usage now agrees exactly. TestInstance@4b81b8 (deriv @511088 "HUD"). UpdateFlicker@4b7ed4. │ │ ├─ Searchlight vtable 005114d4 classID 0xBD8 ctor @4b84dc dtor @4b8568 CSS @4b85a8 [searchlight.cpp DONE] model 0xF4 │ │ │ illumination subsystem (corroborated by btl4vid MakeMechRenderables "0xBD8 SearchLight"). DefaultData 00511198. │ │ │ empty resource (CSS only stamps). members: lightState@0x1D8 ("LightState" attr id5), diff --git a/game/reconstructed/hud.cpp b/game/reconstructed/hud.cpp index 5ab57bb..d078894 100644 --- a/game/reconstructed/hud.cpp +++ b/game/reconstructed/hud.cpp @@ -126,17 +126,21 @@ HUD::HUD( Check(owner); Check_Pointer(subsystem_resource); - flickerRate = 0.0f; // @0x1D8 (overwritten below from resource) - torsoLimitRight = 0.0f; // @0x1DC - torsoLimitLeft = 0.0f; // @0x1E0 - torsoSpeed = 0.0f; // @0x1E4 - rangeToTarget = 0.0f; // @0x1E8 - targetRange = 0.0f; // @0x1EC - lockFlag = True; // @0x1F4 = 1 - visible = False; // @0x1F8 - threatVector = HudZeroVector; // @0x1FC - compassHeading = HudZeroVector; // @0x208 - hotBoxScalar = 0.0f; // @0x214 + // (member names re-based 2026-07-19 per the re-dumped attr table @005110b8; + // the ctor @004b7f94 zeroes [0x76..0x7b] then sets [0x7d]=1 / [0x7e]=0 -- + // note [0x7c]=@0x1F0 is left uninitialized in the binary) + flickerRate = 0.0f; // @0x1D8 (overwritten below from resource) + rotationOfTorsoHorizontal = 0.0f; // @0x1DC + torsoLimitRight = 0.0f; // @0x1E0 + torsoLimitLeft = 0.0f; // @0x1E4 + torsoSpeed = 0.0f; // @0x1E8 + rangeToTarget = 0.0f; // @0x1EC + scratch1F0 = 0; // @0x1F0 (binary leaves it uninitialized; zeroed here) + visible = True; // @0x1F4 = 1 ("Visible" starts shown) + lockFlag = False; // @0x1F8 = 0 ("Lock" starts un-locked) + hotBoxVector = HudZeroVector; // @0x1FC + threatVector = HudZeroVector; // @0x208 + compassHeading = 0.0f; // @0x214 blinkTimer = 0.0f; // @0x218 flickerTimer = 0.0f; // @0x21C blinkState = True; // @0x224 = 1 @@ -162,12 +166,14 @@ HUD::HUD( // owner+0x438 is the torso-orientation source -- a Mech RAW OFFSET that does not // hold a valid pointer in the reconstructed Mech layout (and isn't linked yet at // subsystem-construction time), so dereferencing it crashes. Default the - // torso/range readouts to 0; HudSimulation refreshes them per-frame once the link + // torso/twist readouts to 0; HudSimulation refreshes them per-frame once the link // is live. TODO: wire a real Mech torso-orientation-source accessor. + // (binary snapshot @004b7f94: [0x77]@0x1DC <- linked+0x1D8 twist, [0x78]@0x1E0 + // <- +0x1DC limitR, [0x79]@0x1E4 <- +0x1E0 limitL, [0x7a]@0x1E8 <- +0x1E8 speed) linkedEntity = 0; // @0x234 + rotationOfTorsoHorizontal = 0.0f; torsoLimitRight = 0.0f; torsoLimitLeft = 0.0f; - rangeToTarget = 0.0f; torsoSpeed = 0.0f; Check_Fpu(); @@ -203,14 +209,17 @@ Logical } //########################################################################### -// SetCompassHeading -- @004b7810 +// SetThreatVector -- @004b7810 // -// Copies a Point3D into compassHeading (@0x208). +// Copies a Point3D into threatVector (@0x208). Renamed 2026-07-19 (Gitea +// #10): the re-dumped attr table @005110b8 binds "ThreatVector" (attr 12) to +// @0x208 -- the old SetCompassHeading label was the one-slot transcription +// shift (the compass is the Scalar @0x214). No port caller. // void - HUD::SetCompassHeading(const Point3D &heading) + HUD::SetThreatVector(const Point3D &threat) { - compassHeading = heading; // FUN_00408440(this+0x208, &heading) + threatVector = threat; // FUN_00408440(this+0x208, &threat) } //########################################################################### @@ -299,11 +308,12 @@ Logical // 0 -> set HUD flag bit 0x1 on the graphic, clear bit 0x2 // 3 -> set bit 0x2, clear bit 0x1 (and gate on mech +0x410) // writes the resulting "active" flag to *(mech +0x374). -// 5. Target line-of-sight: if a target entity (mech +0x388) exists and is -// not over the heat limits, computes targetRange (@0x1EC) as the distance -// between the HUD anchor (mech +0x37C) and the target (+0x100); sets -// visible (@0x1F8); otherwise blanks. A default range 1200.0 is used -// when there is no target. +// 5. Target lock + range: if a target entity (mech +0x388) exists and the +// own/target zone-damage limits pass (:5619-5634, the Lock rule), sets +// lockFlag (@0x1F8, "Lock"); rangeToTarget (@0x1EC, "RangeToTarget") is +// the distance between the HUD anchor (mech +0x37C) and the target +// (+0x100), sliding at 500 m/s (:5652-5670), default 1200.0 with no +// target; the compass Scalar @0x214 = yaw euler[0] + torso twist (:5676). // 6. Torso-horizon slew: horizontalTorsoOffset (@0x294) is moved toward the // commanded torso heading at up to MaxTorsoSlew (500/sec), clamped to // +/- horizontalLimit (@0x29C), then written to the graphic at diff --git a/game/reconstructed/hud.hpp b/game/reconstructed/hud.hpp index f586390..20d9ec7 100644 --- a/game/reconstructed/hud.hpp +++ b/game/reconstructed/hud.hpp @@ -96,18 +96,30 @@ // // AttributePointers IndexEntry table @005110c0 (each record binds a name // to an attribute id and an object offset, the offset encoded as - // byte_offset|1 -- the same scheme used by sensor.hpp). Recovered: - // id 4 "RotationOfTorsoHorizontal" -> @0x1D8 - // id 5 "HorizontalTorsoLimitRight" -> @0x1DC - // id 6 "HorizontalTorsoLimitLeft" -> @0x1E0 - // id 7 "SpeedOfTorsoHorizontal" -> @0x1E4 - // id 8 "RangeToTarget" -> @0x1E8 - // id 9 "Visible" -> @0x1EC - // id 10 "Lock" -> @0x1F4 - // id 11 "HotBoxVector" -> @0x1F8 - // id 12 "ThreatVector" -> @0x1FC (Vec3) - // id 13 "CompassHeading" -> @0x208 (Vec3) - // (FlickerRate name ptr @005110bc heads the table as the category label.) + // byte_offset|1 -- the same scheme used by sensor.hpp). + // + // RE-DUMPED 2026-07-19 (Gitea #10 audit; section_dump.txt @5110b8, 16-byte + // records {id, namePtr, offset|1, 0}, terminator @511160). The table + // actually STARTS at @005110b8 with a FULL id-3 FlickerRate record (the old + // transcription had misread it as a "category label" and shifted every + // subsequent offset down one slot): + // id 3 "FlickerRate" -> @0x1D8 + // id 4 "RotationOfTorsoHorizontal" -> @0x1DC + // id 5 "HorizontalTorsoLimitRight" -> @0x1E0 + // id 6 "HorizontalTorsoLimitLeft" -> @0x1E4 + // id 7 "SpeedOfTorsoHorizontal" -> @0x1E8 + // id 8 "RangeToTarget" -> @0x1EC + // id 9 "Visible" -> @0x1F4 (init 1; @0x1F0 is unbound) + // id 10 "Lock" -> @0x1F8 (init 0) + // id 11 "HotBoxVector" -> @0x1FC (Point3D) + // id 12 "ThreatVector" -> @0x208 (Point3D) + // id 13 "CompassHeading" -> @0x214 (Scalar: yaw + torso twist) + // CROSS-CHECKED against the ctor @004b7f94 (inits @0x1F4=1 Visible-on / + // @0x1F8=0 Lock-off; @0x1F0 uninitialized) and HudSimulation @004b7830 + // (per-frame @0x1DC <- linkedTorso+0x1D8 = Torso::currentTwist; @0x1EC + // range default 1200.0 + the 500 m/s display slide; the Lock rule writes + // @0x1F8 at :5622/:5633; @0x214 = EulerAngles(mech)[0] + @0x1DC at :5676; + // the hotbox Point3D copy @0x1FC at :5680). All consistent. // // CONFIRMED (task #37): the recovered reticle Execute @004cdcf0 binds and // USES ids 4/5/6/8/0xA/0xB/0xC/0xD exactly per these names -- @@ -115,8 +127,7 @@ // HorizontalTorsoLimit full-scales, RangeToTarget the range caret, Lock // the spinning lock ring, HotBoxVector the target box (eye-space vec3), // ThreatVector the compass threat trail, CompassHeading the rose - // rotation. (The earlier note about @0x1D8/@0x1EC/@0x1F8 usage remains - // for the ctor-side seeds only.) + // rotation. // public: enum { @@ -142,20 +153,25 @@ // Local data. Offsets are byte offsets into the shipped object; names are // best-effort from the attribute pool + ctor/HudSimulation usage. // + // Member names re-based 2026-07-19 (Gitea #10) against the RE-DUMPED attr + // table @005110b8 -- the old names were shifted one slot (see the table + // comment above). protected: - Scalar flickerRate; // @0x1D8 resource +0xF4 (flicker / blink period threshold) - // Torso / target read-outs sampled each construction from the linked - // targeting entity (owner Mech +0x438 -> this->linkedEntity @0x234): - Scalar torsoLimitRight; // @0x1DC <- linkedEntity +0x1D8 - Scalar torsoLimitLeft; // @0x1E0 <- linkedEntity +0x1DC - Scalar torsoSpeed; // @0x1E4 <- linkedEntity +0x1E0 - Scalar rangeToTarget; // @0x1E8 <- linkedEntity +0x1E8 (and recomputed in sim @0x1EC) - Scalar targetRange; // @0x1EC computed line-of-sight distance to the target - int lockFlag; // @0x1F4 init 1 - int visible; // @0x1F8 set 0/1 from heat + line-of-sight each frame - Point3D threatVector; // @0x1FC init (0,0,0) ("ThreatVector") - Point3D compassHeading; // @0x208 init (0,0,0) ("CompassHeading"); see SetCompassHeading - Scalar hotBoxScalar; // @0x214 init 0 + Scalar flickerRate; // @0x1D8 resource +0xF4 ("FlickerRate", attr id 3) + // Torso / target read-outs sampled at construction AND per-frame in + // HudSimulation from the linked Torso subsystem (owner Mech +0x438 -> + // this->linkedEntity @0x234): + Scalar rotationOfTorsoHorizontal; // @0x1DC <- linkedEntity +0x1D8 (Torso::currentTwist; attr 4) + Scalar torsoLimitRight; // @0x1E0 <- linkedEntity +0x1DC (attr 5) + Scalar torsoLimitLeft; // @0x1E4 <- linkedEntity +0x1E0 (attr 6) + Scalar torsoSpeed; // @0x1E8 <- linkedEntity +0x1E8 (attr 7 "SpeedOfTorsoHorizontal") + Scalar rangeToTarget; // @0x1EC computed target distance; 1200.0 default; 500 m/s display slide (attr 8) + int scratch1F0; // @0x1F0 unbound / not ctor-initialized in the binary + int visible; // @0x1F4 init 1 ("Visible", attr 9; heat/page gating) + int lockFlag; // @0x1F8 init 0 ("Lock", attr 10; the :5619-5634 lock rule) + Point3D hotBoxVector; // @0x1FC init (0,0,0) ("HotBoxVector", attr 11) + Point3D threatVector; // @0x208 init (0,0,0) ("ThreatVector", attr 12); see SetThreatVector + Scalar compassHeading; // @0x214 init 0 ("CompassHeading", attr 13; yaw euler[0] + torso twist @:5676) Scalar blinkTimer; // @0x218 accumulates time, reset against flickerRate Scalar flickerTimer; // @0x21C int blinkState; // @0x224 init 1 (visible phase of the blink) @@ -193,8 +209,13 @@ void HudSimulation(Scalar time_slice); // @004b7830 (Performance, PTR @00511170) + // @004b7810 writes the Point3D @0x208 -- which the RE-DUMPED attr table + // (2026-07-19) proves is the THREAT vector, not the compass (the compass + // is the Scalar @0x214). Renamed from the old mislabel SetCompassHeading; + // no port caller exists (the port threat-trail feed goes through the + // reticle globals, btl4vid.cpp). void - SetCompassHeading(const Point3D &heading); // @004b7810 (writes compassHeading @0x208) + SetThreatVector(const Point3D &threat); // @004b7810 (writes threatVector @0x208) //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Subsystem virtual overrides (vtable @00511510) From f1a6ccbaaf348980e78e672d3cd730fc9e954d61 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 18:33:59 -0500 Subject: [PATCH 16/17] Gitea #10: systematic gauge-data audit -- 45-row verdict table + findings docs/GAUGE_COMPOSITE.md gains the AUDIT 2026-07-19 section: every widget on the 6 pod MFD surfaces + the secondary screen + the HUD reticle audited for binary-correct data (static pass vs decomp + two instrumented live sessions). Verdicts: 34 CORRECT / 1 WRONG (filed) / 6 AUTH-STATIC / 6 DEFERRED-FEED. Findings: A. The 'SYSTEM 10 PPC ammo readout' (entry a) root-caused: header/label/screen mapping are CORRECT ([vss] dump == authored sub+0x1dc); the ammo box is a GHOST -- stale pixels from the sibling ballistic/sensor page on the shared Eng plane, persisting because the port SeekVoltageGraph::Execute is a bring-up no-op. In the binary the graph IS the top-box eraser (view = (0x97,0x80)-(0x17d,0x13b) @004c6798; clear @004c6be4 on the @004c6920 activation sentinel) -- ghost-free on the pod. Full reconstruction FILED (4 Seek* attrs + vtbl+0x3C sampler + x87 recovery); no erase-only stand-in. B. HUD attr table @5110c0 conflict (entry b) RESOLVED -- see commit 904c75a. C. Entry c: no [T3] tags remain in gauges-hud.md; the single [T4] (reticlePosition writer) stays flagged. D. BT_SHOT dock-capture regression found + fixed (commit 904c75a). Live re-verifications this audit: heat digits climb (515->680 under autofire), reservoir flush 6->0 (~1s held) + PFX cloud, ammo counter moves (24->19 after missile autofire), heading 089->120 + speed 0->182 under BT_GOTO, secondary dama->crit->heat cycle (masks 0x450421/0x490421/0x510421), preset cycles visit exactly the populated page set (MFD1:1,2,4 MFD2:1-4 MFD3:1,2), mission clock, Comm 0/0, pips/compass/tape/caret render. Audit performed by the agent; findings await human review, fixes await human verification. Co-Authored-By: Claude Fable 5 --- context/gauges-hud.md | 12 +++- docs/GAUGE_COMPOSITE.md | 130 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/context/gauges-hud.md b/context/gauges-hud.md index 8845215..5603024 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -7,7 +7,8 @@ related_topics: [reconstruction-gotchas, decomp-reference, subsystems, pod-hardw key_terms: [gauge, methodDescription, attribute-pointer, GaugeRenderer, MFD, PCC] open_questions: - "MessageBoard LIVE 2026-07-12 (kill ticker); remaining: only strip 0 (kill) is produced -- survey the other btsmsgs.pcx strips for authentic producers" - - "HUD binary attr table @005110c0 offsets conflict with the port HUD layout on 3 slots (0x1D8/0x1EC/0x1F8) -- re-dump before publishing" + - "HUD attr table RESOLVED 2026-07-19 (Gitea #10 audit): re-dumped @5110b8 -- the table starts one record earlier (id 3 FlickerRate@0x1D8), every hud.hpp offset was shifted one slot; hud.hpp/CLASSMAP corrected, no behavioral impact (the reticle feeds via port globals). See GAUGE_COMPOSITE.md AUDIT finding B" + - "SeekVoltageGraph reconstruction FILED 2026-07-19 (Gitea #10 finding A): the port Execute is a bring-up no-op, so the emitter/myomer eng pages never erase their top data box (the binary graph IS the box eraser: view=(0x97,0x80)-(0x17d,0x13b), clear @004c6be4 on the @004c6920 activation sentinel) -> stale ammo/sensor ghosts from sibling pages (the 'SYSTEM 10 PPC ammo readout'). Needs: 4 Seek* attrs on Emitter+Myomers, the subsystem vtbl+0x3C sampler, x87 recovery of @004c6934/@004c6c6c/@004c6c30" - "Secondary-view cycling RESOLVED 2026-07-19 (Gitea #6): the selector is the DISPLAY mode (CycleDisplayMode -> vtbl+0x4C override @4d1ae4), NOT CycleControlMode; desktop 'N' / pad RightThumb wired; pixel-verified dama->crit->heat" - "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired; remaining: the always-active 0x28-0x2F / 0x1A-0x1D per-subsystem msg-4 records (subs 3-13 -- likely the condenser-valve/coolant buttons) unidentified" - "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)" @@ -19,6 +20,11 @@ The pod's cockpit instruments: the secondary MFD (radar/heat/comm), the 5 mono M engineering screens. Full map + per-widget history in **`docs/GAUGE_COMPOSITE.md`**. **The gauge system is COMPLETE** at the registration+binding level: all 50 config attribute bindings resolve (0 NULL) and every config gauge primitive is registered + built (0 parse-skips). [T2] +**AUDIT 2026-07-19 (Gitea #10):** the full per-widget verdict table (45 rows: 34 CORRECT / 1 +WRONG-filed / 6 AUTH-STATIC / 6 DEFERRED-FEED) is in `docs/GAUGE_COMPOSITE.md` §"AUDIT +2026-07-19" — the one WRONG is the missing SeekVoltageGraph reconstruction (the emitter/myomer +eng pages' POWER curve + the authentic top-box eraser; causes the stale-ammo "SYSTEM 10 PPC" +ghost). [T2] ## Architecture (three layers) 1. **The config** `content/GAUGE/l4gauge.cfg` — a text file the engine `GaugeInterpreter` parses @@ -310,7 +316,9 @@ and every instrument is now live [T2]:** [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 +(hud.hpp had flagged them uncertain; the OFFSETS were re-based 2026-07-19 — the table @5110b8 +starts with id 3 FlickerRate@0x1D8, so Rotation=0x1DC…CompassHeading=0x214 Scalar; hud.hpp/ +CLASSMAP corrected, names/ids unchanged). 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.) Player CALLSIGN labels (kill/damage feed, radar/target tags, score display) are 1bpp name diff --git a/docs/GAUGE_COMPOSITE.md b/docs/GAUGE_COMPOSITE.md index 2520674..838a23d 100644 --- a/docs/GAUGE_COMPOSITE.md +++ b/docs/GAUGE_COMPOSITE.md @@ -638,3 +638,133 @@ numericSpeed units (open). Degradation/Failure temps (fixed threshold markers); AmbientTemperature 300; MaxRunSpeed (per-mech constant); cmArmor/cmCrit all-green (undamaged solo player — BT is PvP-only); CoolantMassLeakRate 0 on a pristine mech (damage-gated); Player RANK 1 in solo. + +--- + +## AUDIT 2026-07-19 (Gitea #10) — systematic per-widget gauge-data audit + +Every widget on the cockpit displays audited for BINARY-CORRECT data: static pass (CFG configure +blocks + `BTL4MethodDescription[]` + widget sources vs the decomp) + live pass (two instrumented +sessions, BLH/ARENA1, docked composite + BT_SHOT series; drivers: BT_PRESET_TEST, BT_VIEWCYCLE_TEST, +BT_FLUSH_TEST, BT_AUTOFIRE(+AF_MISSILE), BT_GOTO movement; logs audit2/3/4.log). Verdicts: +**CORRECT** (binary-correct data, evidence cited) / **WRONG** (defect; fixed or filed) / +**AUTH-STATIC** (authentically static — do not fix) / **DEFERRED-FEED** (widget correct, data +source a documented deferred system). + +### Coverage inventory (static pass) +All 26 distinct CFG primitives resolve: 17 BT-specific registrations (btl4grnd.cpp:135-152) + +engine base (numeric/numericSpeed/digitalClock/rankAndScore/bgPixelMap/bgBitMap/configure/ +reconfigure/externalConfigure); parse-skip list EMPTY ([gskip]=0), all 50 attr bindings resolve +(0 NULL) — the gauge-complete wave claims re-verified against btl4grnd.cpp + the CFG keyword census. + +### Verdict table + +| # | Surface / widget | Binding → producer | Evidence | Tier | Verdict | +|---|---|---|---|---|---| +| 1 | Heat: condenser temp two-part bars ×6 (TMP) | HeatSink/CurrentTemperature + Degradation/Failure endpoints → heat.cpp table | attr wave incr.1 + tasks #9/#10 heat economy; LIVE: bank S 515→680 climbing under autofire (audit4) | T2 | CORRECT | +| 2 | Heat: SETS valve sliders ×6 (vertNormalSlider) | Condenser/ValveSetting → coolantFlowScale (RecomputeCondenserValves FUN_0049f788, 1/N) | task #13 live valveState 1→5 → flow 1/6→5/10 | T2 | CORRECT | +| 3 | Heat: LEK LeakGauge column | CoolantMassLeakRate → coolantDraw (damage-gated) | writer census; pristine=0 | T2 | AUTH-STATIC (pristine) | +| 4 | Heat: COOLANT A numeric | HeatSink/AmbientTemperature → AggregateHeatSink 300 (ctor @4ae8d0) | LIVE: reads 300 (audit3/4 shots) | T2 | AUTH-STATIC (hardcoded 300) | +| 5 | Heat: COOLANT S numeric | HeatSink/CurrentTemperature (bank) | LIVE: 515→653→680 climbing | T2 | CORRECT | +| 6 | Heat: RES coolant vertBar C | Reservoir/CoolantMass → coolantLevel@0x12C (CFG:4526) | Gitea #7; re-verified LIVE this audit: [flush] 6→0 in ~1.0s held, PFX cloud | T2 | CORRECT | +| 7 | GeneratorCluster A-D panels | Generator/OutputVoltage → outputVoltage@0x1DC + state lamps | gauge wave (@004c72ac); task #12 GenSelect live | T2 | CORRECT | +| 8 | Comm: pilotList KILLS | victim-posted ScoreMessage → killCount (cross-player credit) | commit 34aaa7d; solo 0 observed (authentic) | T2 | CORRECT | +| 9 | Comm: pilotList DEATHS | observed-death tally + −2/−1 display clamp (BTPilotDeaths) | 2026-07-12 resolution; solo 0 observed | T2 | CORRECT | +| 10 | Comm: SELECT-TARGET highlight | BTPilotIsSelected accessors (ex raw-offset AV) | commit 34aaa7d root-cause note | T2 | CORRECT | +| 11 | Plasma: rankAndScore | engine primitive → external serial annunciator | not a D3D surface (L4PLASMA=com2) | T0 | CORRECT (n/a on dev composite) | +| 12 | Quad mini-panels: temp bars / cooling-loop / power-bus lamps / evolt bar / cmode lamp | SubsystemCluster children (@004c8140 map) reading InputVoltage/HeatSink/temps | VEHICLE_SUBSYSTEMS.md phase 2; LIVE: panels render per-subsystem, [vss] roster matches authored aux screens 1-10 | T2 | CORRECT | +| 13 | Quad: recharge dial (SegmentArc270) | PercentDone → rechargeLevel@0x320 | writer census: Emitter::ComputeOutputVoltage @004ba738 only — projectile dial full-once | T1/T2 | CORRECT (emitters) + AUTH-STATIC (projectile) | +| 14 | Quad: ammo count (ammoCountA) | AmmoBin::ammoCount via BTAmmoBinCountPtr bridge | AFC100 fix 2026-07-12; LIVE: 24 fresh (audit4) vs 19 after missile autofire (audit3) — counter moves | T2 | CORRECT | +| 15 | Quad: ConfigMap regroup lamps | LBE4ControlsManager buttonGroup map state | task #6: NO SetColor caller in the binary → color=0, Execute early-outs | T1 | AUTH-STATIC (dormant; BT_CONFIGMAP dev enable) | +| 16 | Eng pages: prepEngr SYSTEM NN + subsystem label + class cells | roster walk, sub+0x1dc auxScreen == screen (BTGetSubsystemAuxScreen) | FUN_004c7e48 matched line-by-line; [vss] dump: scr1/2/4=PPC/Strk6/ERMed, 5-8=Sens/Myo/ERMed/ERMed, 9/10=Strk6/PPC — page headers+labels CORRECT | T1/T2 | CORRECT | +| 17 | Eng ballistic page: ammoCountB / jam / fire / reload numeric / destroyed lamp | bin count bridge + weaponAlarm@0x364 (MechWeapon layout static_assert-locked) | ctor @004c9558 coords matched; LIVE renders | T2 | CORRECT | +| 18 | Eng ballistic page: eject wipe (BitMapInverseWipeScalar @004c61c8) | subsys+0x3f8 eject timer | port tracked NULL (class not reconstructed) | T3 | DEFERRED-FEED | +| 19 | Eng emitter/myomer pages: SeekVoltageGraph (POWER curve) | 4 Seek* attrs + subsystem vtbl+0x3C sampler | WRONG — port Execute is a bring-up no-op (btl4gau2.cpp:331); see finding A below | T1 (binary) | WRONG (filed) | +| 20 | Eng emitter page: seek-step lamp (bteseek) | Emitter::seekVoltageIndex@0x3F0 (static_assert-locked) | @004c93b0 :2146 | T2 | CORRECT | +| 21 | Eng myomer page: seek-step lamp | Myomers@0x320 INFERRED (ctor @004c8df4 not in the decomp export) | marked in code | T3 | CORRECT (best-effort; flagged) | +| 22 | Eng page: linked heat-sink number (hs+0x1d4 guarded raw) | PrepEngr numeric #2 | marked BEST-EFFORT (guarded) | T3 | CORRECT (guarded; flagged) | +| 23 | sec: radar map (wedge/blips/scale) | Mech Radar* attrs; wedge ← Torso::currentTwist@0x1D8 (Gitea #1) | #1 [T2]; LIVE: blips + wedge render, zoom via SetTargetRange | T2 | CORRECT | +| 24 | sec: radar pip/name symbology | GetVideoObject/LookUpPip/GetNameID | task #17 under reconstruction — cross-blip stand-in | T3 | DEFERRED-FEED | +| 25 | sec: headingPointer needle + numeric | YawPitchRoll decomposition (engine-convention port of euler[0]) | LIVE: 089→120 while turning under BT_GOTO | T2 | CORRECT | +| 26 | sec: numericSpeed | LinearSpeed (abs(adv)/dt) | LIVE: 0 parked → 176-182 walking | T2 | CORRECT (units question stays open) | +| 27 | sec: digitalClock MISSION TIME | engine mission clock (format enum arg) | LIVE: 09:19→06:04 counting | T0/T2 | CORRECT | +| 28 | sec: sectorDisplay (overlay) | localOrigin → Round(−Z·0.01)+500 / Round(X·0.01)+500 | commit 4a4ec68 [BT_SECTOR_LOG live]; NOTE: overlay plane not composited into the dev sec cell (polish item) — data verified by log | T2 | CORRECT | +| 29 | sec: schematic ARMOR view (cmArmor/multiArmor, dama.pcc) | zone damageLevel ×100 → adpal ramp | incr.3/4; all-green pristine LIVE | T2 | CORRECT (AUTH-STATIC all-green solo) | +| 30 | sec: schematic CRITICAL view (cmCrit) | subsystem simulationState/damage | LIVE this audit: N-cycle shows the full subsystem list (GEN A-D, LOOP 1-6, HUD, SENSORS, GYRO, TORSO, weapons) | T2 | CORRECT | +| 31 | sec: schematic HEAT view (cmHeat) | subsystem currentTemperature tint | #6 pixel-verified; re-cycled this audit (mask 0x450421→0x490421→0x510421) | T2 | CORRECT | +| 32 | sec: view cycling (N / pod 0x15) | CycleDisplayMode → vtbl+0x4C @4d1ae4 | #6 resolution re-verified live ([mode] display notify 0/1/2) | T2 | CORRECT | +| 33 | sec: CONTROL MODE lamp (BAS/MID/ADV) | ControlsMapper/DisplayMode oneOfSeveralPixInt | attr wave incr.5; M-cycle verified #6 | T2 | CORRECT | +| 34 | sec: duck / searchlight button lamps | duckState (ctor-zeroed only) / Searchlight LightOn | duckState writer missing (P3 leftover) | T3 | DEFERRED-FEED (duck); CORRECT (light attr published) | +| 35 | sec: messageBoard ticker | StatusMessagePool (NULL stub) + kill ticker strip 0 | 7fc4acb; kill ticker live 2026-07-12, other strips unsurveyed | T2/T3 | DEFERRED-FEED (partial) | +| 36 | MFD preset paging (J/K/L, pod RIO banks) | SetPresetMode table @0051dbf0 (little-endian re-decode) | #9; re-verified live this audit: cycles visit EXACTLY the populated set (MFD1: 1,2,4; MFD2: 1-4; MFD3: 1,2) | T2 | CORRECT | +| 37 | HUD: range ladder + caret (500 m/s slide) | BTSetHudTargetRange ← targeting; HudSimulation :5652-5670 | task #37 [T1] + live | T1/T2 | CORRECT | +| 38 | HUD: weapon pips (loaded/dark/hidden; group filter) | attr 0x1C WeaponState == stateConst2; damage state 1 hides | 2026-07-18 fix (projectile pips un-pinned); pips visible in audit shots | T2 | CORRECT | +| 39 | HUD: bottom 21-tick TORSO-TWIST tape | RotationOfTorsoHorizontal / HorizontalTorsoLimit (attrs 4/5/6) | task #58; BLH fixed-torso → centred (authentic) in audit shots | T2 | CORRECT (AUTH-STATIC on BLH) | +| 40 | HUD: compass + threat trail | CompassHeading (attr 0xD, yaw+twist :5676); ThreatVector (attr 0xC) ← TakeDamage push | task #37/#38; compass rose renders in shots | T2 | CORRECT | +| 41 | HUD: lock ring (4°/frame spin) | Lock rule :5619-5634 (own zone <0.75, target zone <1.0) | task #38 | T2 | CORRECT | +| 42 | HUD: target hotbox / edge arrows | HotBoxVector projection (live per-axis) | task #37 | T2 | CORRECT | +| 43 | HUD: simple-X mode | PrimaryHudOn mask 0x20 state switch | ctor @4689-4705 | T1/T2 | CORRECT | +| 44 | HUD: PNAME1-8.bgf 3D name marker chain | — | deferred | T4 | DEFERRED-FEED | +| 45 | HUD attr table @005110b8 (ids 3-0xD) | hud.hpp/CLASSMAP corrected this audit | finding B below | T1 | CORRECT (fixed) | + +**Counts: 34 CORRECT · 1 WRONG (filed: SeekVoltageGraph) · 6 AUTH-STATIC (incl. dual-verdict rows +13/29/39) · 6 DEFERRED-FEED (rows 18/24/34-duck/35/44 + the sec messageBoard partial).** +(Dual-verdict rows counted in both columns.) + +### Finding A — the "SYSTEM 10 PPC ammo readout" (issue entry point a) — ROOT-CAUSED, FILED +**The page header, label and screen→subsystem mapping are CORRECT** ([vss] live dump matches the +authored sub+0x1dc assignments; CFG prepEngr(ModeMFD3Eng2, 10, ...) pairing consistent; +FUN_004c7e48 reconstruction line-exact). The ammo readout on the SYSTEM 10 (PPC) page is a +**GHOST — stale pixels from the previously-shown SYSTEM 9 (Streak 6) page** on the SAME shared +Eng3 bit-plane. Reproduced live (audit3): SYSTEM 04 ERMED LASER (emitter) showed the "0019" +ammo box left by the SYSTEM 02 Streak page; SYSTEM 08's POWER box showed the SENSOR RANGE label +left by SYSTEM 05. Mechanism chain, all decomp-verified: +- Ballistic/sensor eng children (ammoCountB @(0xdc,0x104)-(0x140,0x12C), jam/fire lamps, reload + numeric — ctor @004c9558) draw INSIDE the top data box (0x97,0x80)-(0x17d,0x13b). +- On a page switch the engine moves gauges active↔inactive (DeactivateGaugeBases, + GAUGREND.cpp:3909) — BecameInactive() is a no-op everywhere [T0]: NOTHING erases on deactivation. +- PrepEngrScreen::BecameActive (@004c7e48) erases the top box ONLY for the ballistic/sensor/gauss + cases (topBox=1); the emitter/PPC and myomer cases pass 0 — **because in the binary those pages' + box is owned by the SeekVoltageGraph**: its localView IS the box rect (ctor @004c6798 + SetPositionWithinPort(0x97,0x80,0x17d,0x13b)), BecameActive @004c6920 poisons the cached + sample (this+0xAC = 10000.0f), and the next Execute (@004c6934) calls the clear helper + @004c6be4 (SetColor 0 + filled rect 1000×1000 clipped to the view = FULL BOX ERASE) before + re-plotting the voltage curve. Every topBox=0 page has a graph child (EnergyWeaponCluster + @004c93b0 :2137; MyomerCluster @004c8df4) — the design is coherent and ghost-free on the pod. +- **The port's SeekVoltageGraph::Execute is a marked bring-up NO-OP** (btl4gau2.cpp:331), so the + erase never runs and the POWER curve never draws → the ghost. +**Fix required (LARGE — filed, not half-fixed per the no-stand-ins rule):** full SeekVoltageGraph +reconstruction — publish the 4 CurrentSeekVoltageIndex/MinSeekVoltageIndex/MaxSeekVoltageIndex/ +SeekVoltage attributes on Emitter(+PPC)+Myomers, identify + reconstruct the subsystem vtbl+0x3C +voltage-response sampler, x87-recover the plot math (@004c6934 loop constants _DAT_004c6bdc/_be0, +DrawTicks @004c6c6c, DrawCursor @004c6c30, clear @004c6be4 — Ghidra dropped the FPU args; +tools/disas2.py). An erase-only patch was deliberately NOT made (it would be a stand-in for the +missing curve). + +### Finding B — HUD binary attr table @005110c0 conflict (issue entry point b) — RESOLVED, FIXED +Re-dumped section_dump.txt @5110b8: the table starts ONE RECORD EARLIER than transcribed — a full +16-byte record {id 3, "FlickerRate", 0x1D8|1, 0} (the old reading took the FlickerRate name ptr +as a "category label"), so every subsequent offset in hud.hpp was shifted one slot. Binary truth: +Rotation@0x1DC ← Torso::currentTwist(+0x1D8), LimitR@0x1E0, LimitL@0x1E4, Speed@0x1E8, +RangeToTarget@0x1EC, (0x1F0 unbound), Visible@0x1F4(init 1), Lock@0x1F8(init 0), HotBoxVector +P3D@0x1FC, ThreatVector P3D@0x208, CompassHeading Scalar@0x214 (= yaw euler[0] + twist :5676). +Cross-checked against the ctor @004b7f94 and HudSimulation @004b7830 — ALL consistent (the Lock +rule :5622/:5633 writes @0x1F8; the 1200-default + 500 m/s slide writes @0x1EC). **Behavioral +impact: NONE** — the port reticle feeds through its own globals (gBTHudLockState etc.) which +implement the correct semantics, l4gauge.cfg never binds HUD attrs, and the mislabeled +SetCompassHeading (@004b7810, actually SetThreatVector — writes @0x208) has no port caller. +FIXED: hud.hpp member renames + table comment, hud.cpp ctor inits (Visible=1/Lock=0 per binary), +SetThreatVector rename, CLASSMAP.md entry. The ids 4-0xD NAME↔USE pairings from task #37 stand. + +### Finding C — [T3]/[T4] tag review (issue entry point c) +context/gauges-hud.md contains NO [T3] tags (all previously-T3 claims were promoted in the +#1/#6/#7/#9 waves). The single [T4] — the un-exported reticlePosition writer / fixed-torso +free-aim channel (mech+0x36c) — remains a genuine unknown and stays flagged. In-code BEST-EFFORT +markers audited as table rows 18/21/22/24/34. + +### Finding D — BT_SHOT dock-capture regression (found + fixed during this audit) +The 2026-07-18 capture reorder left BT_SHOT BEFORE BTDrawGaugeInset, so BT_DEV_GAUGES_DOCK +screenshots silently omitted the gauge panel (audit runs 1-2 captured cockpit-only frames). +Moved the capture after the inset (L4VIDEO.cpp) — dock shots again show what the screen shows. +Also noted: BT_DEV_GAUGES_DOCK alone does NOT enable the composite — DevGaugeComposite() gates +on BT_DEV_GAUGES (L4VB16.cpp:105); set BOTH. From 63b168cb922e08f2db911cc6cee3935774349955 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 20:27:39 -0500 Subject: [PATCH 17/17] SeekVoltageGraph: full reconstruction -- the eng-page POWER graph + top-box eraser (Gitea #11) The #10 audit's one WRONG: the port Execute was a bring-up no-op, so the emitter/myomer engineering pages never erased their top data box -> stale sibling-page ghosts on the shared Eng bit-plane (SYSTEM 10 PPC showing the Streak ammo box, etc). Full faithful widget landed: - btl4gau2: ctor/BecameActive/Execute/clear/ticks/cursor recovered from the capstone disasm of @004c6798/@004c6920/@004c6934/@004c6be4/@004c6c30/ @004c6c6c (Ghidra dropped every x87 arg). Plot: v=0..12000 step 1200, x=Round(response(v)*230), y=Round(v*(1/12000)*187) (ld80 @004c6bd0/@004c6d74 = exactly 1/12000); change-test samples the response at 12000V vs the 9999 activation sentinel; XOR op for tick/cursor move-by-redraw; destroyed branch centres edestryd.pcc and revives via own vtbl+0xC (BecameActive, slot 3 of PTR_0051a1fc -- vtable-dump verified). - The vtbl+0x3C sampler identified from the binary vtables: Emitter slot 15 @004bb42c = sqrt(P(v)/2.0e8), P @004bb3f4 = damageFraction*v^2*0.5* energyCoefficient; Myomers slot 15 @004b8f94 = sqrt(AvailableOutput(v)*3.6/ 350). FUN_004dd138 == sqrt (part_015.c:4026): myomers' fabs reading corrected, GetSpeedReading renamed SeekVoltageResponse. Port dispatch via complete-type bridges BTSeekVoltageSample/BTMyomersSeekSample + BTSubsystemDestroyed (databinding rule). - Emitter's AUTHENTIC attribute table recovered (binary @0x511dd4, ids 0x1D-0x25) and published: Laser*/Seek*/OutputVoltage@0x414 (currentLevel, RAW volts -- the live-cursor feed). Field renames per the table: 0x3F8 minSeekVoltageIndex / 0x3FC maxSeekVoltageIndex (static_assert-locked). The MechWeapon 0x1D OutputVoltage PORT ALIAS retired (binary table ends 0x1C; Find walks lowest-id-first, the alias shadowed the authentic row). PPC::DefaultData -> Emitter::GetAttributeIndex(). - mech4: BT_PRESET_HOLD= (freeze the #9 preset cycler after n pulses) for steady-state pixel verification; BT_SEEK_LOG diag. Verified live (BLH, autofire): both #10 repro pairs held ghost-free for minutes (SYS09->SYS10 PPC, SYS02->SYS04 ERMed, SYS05->SYS06 Myomers); curves draw with live cursors; one replot per activation; quad panels/J-K-L/sec panel un-regressed. Co-Authored-By: Claude Fable 5 --- context/decomp-reference.md | 2 + context/gauges-hud.md | 54 ++++++- docs/GAUGE_COMPOSITE.md | 49 +++++- game/original/BT/PPC.CPP | 3 +- game/reconstructed/CLASSMAP.md | 12 +- game/reconstructed/btl4gau2.cpp | 257 +++++++++++++++++++++++++++----- game/reconstructed/btl4gau2.hpp | 46 ++++-- game/reconstructed/emitter.cpp | 104 ++++++++++++- game/reconstructed/emitter.hpp | 55 ++++++- game/reconstructed/mech4.cpp | 10 +- game/reconstructed/mechweap.cpp | 16 +- game/reconstructed/mechweap.hpp | 25 ++-- game/reconstructed/myomers.cpp | 44 ++++-- game/reconstructed/myomers.hpp | 11 +- game/reconstructed/powersub.cpp | 15 ++ 15 files changed, 594 insertions(+), 109 deletions(-) diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 3e0252f..8188bf9 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -286,6 +286,8 @@ default-ON (`'0'` disables). | `BT_MODE_LOG` | `[mode] display notify` / `[mode] preset (g,i)` — the ModeManager mask after each secondary-view swap (Gitea #6) or upper-MFD preset-page swap (SetPresetMode, Gitea #9) (btl4mppr.cpp) | | `BT_VIEWCYCLE_TEST=` / `BT_MODECYCLE_TEST=` | scripted verify (mech4.cpp): pulse a secondary-schematic cycle / a control-mode cycle at the frame and every 300 after — pixel-verifiable with `BT_DEV_GAUGES_DOCK`+`BT_SHOT` | | `BT_PRESET_TEST=` | scripted verify (mech4.cpp, Gitea #9): from the frame, pulse an upper-MFD preset-page cycle on all three MFDs every 120 frames (Quad → populated Eng pages → Quad) — pixel-verifiable with `BT_DEV_GAUGES_DOCK`+`BT_SHOT` | +| `BT_PRESET_HOLD=` | (with BT_PRESET_TEST, Gitea #11) stop after n pulses so a page is HELD — steady-state pixel verification; the 120-frame cycle phase-locks with BT_SHOT's 90-frame cadence, so cycling shots periodically capture the 1-frame page-switch transition | +| `BT_SEEK_LOG` | `[seek] BecameActive/replot` — SeekVoltageGraph activation sentinel + clear/replot events with the response-at-12kV value (Gitea #11, btl4gau2.cpp) | | `BT_CTRLMAP_LOG` | streamed `.CTL` record audit (L4CTRL.cpp CreateStreamedMappings): Direct records with the resolved member offset + EVENT records (button→subsystem message; found the pod's secondary-panel Cycle buttons 0x15/0x18) | | `BT_CAM_LOG` | camera-seat/broadcast trace: streamed camera network count, director pick + Players-group census, ship follow state, PlayerLink dispatch/receive, ranking-window render ([[multiplayer]] camera seat) | | `BT_SPEC_LOG` | manual spec-audit dump at subsystem ctor: torso speed/limits, heat-sink count, reservoir capacity, per-subsystem coolant loop ([[pod-hardware]] §Spec cross-check) | diff --git a/context/gauges-hud.md b/context/gauges-hud.md index 5603024..f96a0de 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -8,7 +8,7 @@ key_terms: [gauge, methodDescription, attribute-pointer, GaugeRenderer, MFD, PCC open_questions: - "MessageBoard LIVE 2026-07-12 (kill ticker); remaining: only strip 0 (kill) is produced -- survey the other btsmsgs.pcx strips for authentic producers" - "HUD attr table RESOLVED 2026-07-19 (Gitea #10 audit): re-dumped @5110b8 -- the table starts one record earlier (id 3 FlickerRate@0x1D8), every hud.hpp offset was shifted one slot; hud.hpp/CLASSMAP corrected, no behavioral impact (the reticle feeds via port globals). See GAUGE_COMPOSITE.md AUDIT finding B" - - "SeekVoltageGraph reconstruction FILED 2026-07-19 (Gitea #10 finding A): the port Execute is a bring-up no-op, so the emitter/myomer eng pages never erase their top data box (the binary graph IS the box eraser: view=(0x97,0x80)-(0x17d,0x13b), clear @004c6be4 on the @004c6920 activation sentinel) -> stale ammo/sensor ghosts from sibling pages (the 'SYSTEM 10 PPC ammo readout'). Needs: 4 Seek* attrs on Emitter+Myomers, the subsystem vtbl+0x3C sampler, x87 recovery of @004c6934/@004c6c6c/@004c6c30" + - "SeekVoltageGraph RECONSTRUCTED 2026-07-19 (Gitea #11, was #10 finding A): full widget landed (see §SeekVoltageGraph below) -- ghosts gone steady-state (BT_PRESET_HOLD verification); remaining polish: a 1-frame transition artifact when a BT_SHOT lands on the exact page-switch frame (label BecameActive vs the graph's next rated Execute -- same lag class as the binary; self-heals next frame)" - "Secondary-view cycling RESOLVED 2026-07-19 (Gitea #6): the selector is the DISPLAY mode (CycleDisplayMode -> vtbl+0x4C override @4d1ae4), NOT CycleControlMode; desktop 'N' / pad RightThumb wired; pixel-verified dama->crit->heat" - "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired; remaining: the always-active 0x28-0x2F / 0x1A-0x1D per-subsystem msg-4 records (subs 3-13 -- likely the condenser-valve/coolant buttons) unidentified" - "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)" @@ -22,9 +22,9 @@ system is COMPLETE** at the registration+binding level: all 50 config attribute (0 NULL) and every config gauge primitive is registered + built (0 parse-skips). [T2] **AUDIT 2026-07-19 (Gitea #10):** the full per-widget verdict table (45 rows: 34 CORRECT / 1 WRONG-filed / 6 AUTH-STATIC / 6 DEFERRED-FEED) is in `docs/GAUGE_COMPOSITE.md` §"AUDIT -2026-07-19" — the one WRONG is the missing SeekVoltageGraph reconstruction (the emitter/myomer -eng pages' POWER curve + the authentic top-box eraser; causes the stale-ammo "SYSTEM 10 PPC" -ghost). [T2] +2026-07-19" — the one WRONG was the missing SeekVoltageGraph reconstruction (the emitter/myomer +eng pages' POWER curve + the authentic top-box eraser; caused the stale-ammo "SYSTEM 10 PPC" +ghost). **RESOLVED (Gitea #11): full reconstruction landed — §SeekVoltageGraph below.** [T2] ## Architecture (three layers) 1. **The config** `content/GAUGE/l4gauge.cfg` — a text file the engine `GaugeInterpreter` parses @@ -240,7 +240,51 @@ cloud (psfx 19) spawns on the ReservoirState 0→1 edge. Desktop: **'H' HELD** Full chain + the two ctor decode corrections it surfaced: [[subsystems]] §coolant FLUSH. Diags: `BT_FLUSH_LOG`, `BT_FLUSH_TEST=`. The **MessageBoard** feed needs **StatusMessagePool** (a NULL stub) + the per-player status -queue. `SeekVoltageGraph`'s 4 Seek* attrs are a cluster-child (not config-called). [T2] +queue. [T2] + +## SeekVoltageGraph — the eng-page POWER graph AND the top-box eraser (Gitea #11, 2026-07-19) [T1→T2] +The emitter/PPC + myomer engineering pages' "POWER" box widget (btl4gau2.cpp/.hpp; ctor +@004c6798, BecameActive @004c6920, Execute @004c6934; a CLUSTER-CHILD — built by +EnergyWeaponCluster @004c93b0 / MyomerCluster @004c8df4, not a config keyword). Fully +reconstructed from the capstone disasm (Ghidra dropped every x87 arg; tools/disas2.py): +- **View/geometry:** localView = the page's top data box (0x97,0x80)-(0x17d,0x13b) — exactly + 230×187, the plot scales; view coords are view-relative (GRAPH2D.h `origin = + areaWithinPort.bottomLeft`). Ctor sets **SetOperation(Xor)** — ticks/cursor erase by redraw. +- **The eraser role:** BecameActive poisons the cached sample (previousVoltage@0xAC = 9999); + the next Execute's change-test (sample the response at **12000 V**) then runs the CLEAR + @004c6be4 (SetOperation(Replace), color 0, filled 1000×1000 clipped to the box) before + replotting — THIS is what erases the sibling pages' stale pixels on the shared Eng bit-plane + (the #10 ghosts). Every topBox=0 PrepEngr page owns a graph; the box erase design is coherent. +- **Plot math (recovered):** polyline v = 0..12000 step 1200 (_DAT_004c6bdc/_be0): + x = Round(response(v)·230), y = Round(v·(1/12000)·187) — the 80-bit consts @004c6bd0/@004c6d74 + are EXACTLY 1/12000. Ticks (@004c6c6c): per gear i in [*min..*max], the current gear draws a + full L (axis→point→axis), others 10-px axis stubs; XOR pair moves the highlight. Cursor + (@004c6c30, emitter pages only): 8×8 XOR square at the LIVE voltage (the ctor's + "OutputVoltage" attr pointer). Destroyed subsystem (simulationState==1 via the + BTSubsystemDestroyed bridge, powersub.cpp): centred edestryd.pcc once; revive calls own + BecameActive (vtbl+0xC, slot 3 of PTR_0051a1fc — verified by vtable dump). +- **The sampler == subsystem vtbl+0x3C (slot 15), reached via the BTSeekVoltageSample dispatch + bridge (emitter.cpp → myomers.cpp; databinding rule):** Emitter @004bb42c = + `sqrt(SeekPower(v)/2.0e8)` with SeekPower @004bb3f4 = `damageFraction·v²·0.5·energyCoefficient` + (a LINE in v — the emitter graph is authentically straight); Myomers @004b8f94 = + `sqrt(AvailableOutput(v)·3.6/350)` (steep near-vertical curve at BLH values). + **FUN_004dd138 = sqrt** (part_015.c:4026) — the old "fabs/fp-magnitude" reading was wrong; + Myomers' old best-effort name "GetSpeedReading" renamed SeekVoltageResponse. +- **The attributes:** Myomers already published all 4 Seek* (@0x320-0x330). Emitter's AUTHENTIC + table was recovered (binary @0x511dd4, ids 0x1D-0x25) and published (emitter.cpp): + LaserOn@0x418, LaserScale@0x42C, LaserRotation@0x41C, Current/Recommended/Min/Max + SeekVoltageIndex@0x3F0-0x3FC, SeekVoltage@0x400, **OutputVoltage@0x414 = currentLevel (RAW + volts — the cursor feed)**. ⚠ The MechWeapon 0x1D "OutputVoltage" PORT ALIAS (→ rechargeLevel + 0..1) was RETIRED: the binary MechWeapon table ends at 0x1C, and `AttributeIndexSet::Find` + walks lowest-id-first, so the alias SHADOWED the authentic Emitter row. Emitter renames: + @0x3F8 minSeekVoltageIndex / @0x3FC maxSeekVoltageIndex (were seekStepCounter/seekVoltageCount + guesses; the attr table proves the identities). PPC chains Emitter::GetAttributeIndex(). +- **Verified live (BLH, autofire):** page-cycling + BT_PRESET_HOLD steady-state shots — both #10 + repro pairs ghost-free (SYSTEM 09 Streak → SYSTEM 10 PPC held clean; SYSTEM 02 → SYSTEM 04 + clean; SYSTEM 05 → 06 Myomers clean); curves draw with moving cursors (charge cycling); + exactly ONE replot per activation ([seek] log). Diags: `BT_SEEK_LOG` ([seek] + BecameActive/replot), `BT_PRESET_HOLD=` (freeze the #9 preset cycler after n pulses — + steady-state pixel verification; the 120-frame cycle phase-locks with BT_SHOT's 90). ## Cockpit HUD reticle (main screen, inside view) — LIVE `BTReticleRenderable` (0x358 bytes, ctor @004cc40c) draws over the finished 3D frame in cockpit diff --git a/docs/GAUGE_COMPOSITE.md b/docs/GAUGE_COMPOSITE.md index 838a23d..fc7864d 100644 --- a/docs/GAUGE_COMPOSITE.md +++ b/docs/GAUGE_COMPOSITE.md @@ -567,8 +567,9 @@ dev-parse skips — the tool that pinned the last 3 (⚠ the gauge renderer buil long enough for BuildConfigurationFile; a too-early kill shows [gparse]=0 and looks like "not built"). Remaining gauge work is DATA FEEDS, not widgets — [UPDATE 2026-07-12: the 0xBD3 untangle landed (task #7) and the valve CONTROL landed (task #13, live MoveValve via the rookie-role gate); only the message half remains]: the -status-message queue (StatusMessagePool) — deferred systems, cleanly marked. `SeekVoltageGraph::Execute` (@004c6934) -is a cluster-child (not config-called) — its 4 Seek* attributes are the only remaining publish, non-blocking. +status-message queue (StatusMessagePool) — deferred systems, cleanly marked. [UPDATE 2026-07-19 Gitea #11: +`SeekVoltageGraph` (@004c6934, a cluster-child not config-called) is now FULLY reconstructed and its Seek* +attributes published on Emitter+Myomers — see the AUDIT finding A addendum below.] ### Phase 3 — SIM/MODEL - ✅ **Aggregate HeatSink-bank** (0xBBE) — DONE (commit 16f5f54). Reconstructed `AggregateHeatSink : HeatSink` @@ -679,7 +680,7 @@ reconfigure/externalConfigure); parse-skip list EMPTY ([gskip]=0), all 50 attr b | 16 | Eng pages: prepEngr SYSTEM NN + subsystem label + class cells | roster walk, sub+0x1dc auxScreen == screen (BTGetSubsystemAuxScreen) | FUN_004c7e48 matched line-by-line; [vss] dump: scr1/2/4=PPC/Strk6/ERMed, 5-8=Sens/Myo/ERMed/ERMed, 9/10=Strk6/PPC — page headers+labels CORRECT | T1/T2 | CORRECT | | 17 | Eng ballistic page: ammoCountB / jam / fire / reload numeric / destroyed lamp | bin count bridge + weaponAlarm@0x364 (MechWeapon layout static_assert-locked) | ctor @004c9558 coords matched; LIVE renders | T2 | CORRECT | | 18 | Eng ballistic page: eject wipe (BitMapInverseWipeScalar @004c61c8) | subsys+0x3f8 eject timer | port tracked NULL (class not reconstructed) | T3 | DEFERRED-FEED | -| 19 | Eng emitter/myomer pages: SeekVoltageGraph (POWER curve) | 4 Seek* attrs + subsystem vtbl+0x3C sampler | WRONG — port Execute is a bring-up no-op (btl4gau2.cpp:331); see finding A below | T1 (binary) | WRONG (filed) | +| 19 | Eng emitter/myomer pages: SeekVoltageGraph (POWER curve) | 4 Seek* attrs + subsystem vtbl+0x3C sampler | WAS WRONG (bring-up no-op Execute) — **FIXED (Gitea #11, 2026-07-19)**: full reconstruction, see finding A addendum | T2 | CORRECT (fixed) | | 20 | Eng emitter page: seek-step lamp (bteseek) | Emitter::seekVoltageIndex@0x3F0 (static_assert-locked) | @004c93b0 :2146 | T2 | CORRECT | | 21 | Eng myomer page: seek-step lamp | Myomers@0x320 INFERRED (ctor @004c8df4 not in the decomp export) | marked in code | T3 | CORRECT (best-effort; flagged) | | 22 | Eng page: linked heat-sink number (hs+0x1d4 guarded raw) | PrepEngr numeric #2 | marked BEST-EFFORT (guarded) | T3 | CORRECT (guarded; flagged) | @@ -707,8 +708,9 @@ reconfigure/externalConfigure); parse-skip list EMPTY ([gskip]=0), all 50 attr b | 44 | HUD: PNAME1-8.bgf 3D name marker chain | — | deferred | T4 | DEFERRED-FEED | | 45 | HUD attr table @005110b8 (ids 3-0xD) | hud.hpp/CLASSMAP corrected this audit | finding B below | T1 | CORRECT (fixed) | -**Counts: 34 CORRECT · 1 WRONG (filed: SeekVoltageGraph) · 6 AUTH-STATIC (incl. dual-verdict rows -13/29/39) · 6 DEFERRED-FEED (rows 18/24/34-duck/35/44 + the sec messageBoard partial).** +**Counts: 34 CORRECT · 1 WRONG (filed: SeekVoltageGraph — since FIXED, Gitea #11) · 6 AUTH-STATIC +(incl. dual-verdict rows 13/29/39) · 6 DEFERRED-FEED (rows 18/24/34-duck/35/44 + the sec +messageBoard partial).** (Dual-verdict rows counted in both columns.) ### Finding A — the "SYSTEM 10 PPC ammo readout" (issue entry point a) — ROOT-CAUSED, FILED @@ -741,6 +743,43 @@ DrawTicks @004c6c6c, DrawCursor @004c6c30, clear @004c6be4 — Ghidra dropped th tools/disas2.py). An erase-only patch was deliberately NOT made (it would be a stand-in for the missing curve). +**✅ RESOLVED (Gitea #11, 2026-07-19) — the full widget landed (btl4gau2.cpp/.hpp):** +- **x87 recovery (capstone, tools/disas2.py):** plot step/limit _DAT_004c6bdc/_be0 = 1200/12000; + the 80-bit consts @004c6bd0/@004c6d74 = EXACTLY 1/12000 (the voltage→y normaliser); + x = Round(response(v)·xScale 230), y = Round(v·(1/12000)·yScale 187); change-test = the + response sampled at 12000 V vs the @0xAC cache (9999 activation sentinel); clear @004c6be4 = + SetOperation(Replace)+color 0+filled 1000×1000 clipped to the view; ticks @004c6c6c = per-gear + loop [*min..*max], current gear = full L, others 10-px stubs, XOR pair to move; cursor + @004c6c30 = 8×8 XOR square at (x-4,y-4) from the LIVE voltage (*this[0x29]); destroyed branch = + centred edestryd.pcc via (Round(xScale)-w)/2; revive → own vtbl+0xC == BecameActive (slot 3 of + PTR_0051a1fc, vtable-dump verified). Members this[0x30..0x32] = cursorX/cursorY/tickIndexShown. +- **The vtbl+0x3C sampler identified from the vtable dumps** (Emitter @00512078 slot 15 = + @004bb42c; Myomers @005117dc slot 15 = @004b8f94): Emitter = sqrt(P(v)/2.0e8f [0x4d3ebc20]), + P @004bb3f4 = damageFraction@0x444 · v² · 0.5 [_DAT_004bb428] · energyCoefficient@0x454; + Myomers = sqrt(AvailableOutput(v)·3.6/350). **FUN_004dd138 == sqrt** (part_015.c:4026) — the + myomers.cpp "fabs / fp-magnitude" reading was corrected and the best-effort name + GetSpeedReading renamed **SeekVoltageResponse**. Port dispatch: `BTSeekVoltageSample` + (emitter.cpp) → `BTMyomersSeekSample` (myomers.cpp) complete-type bridges; + `BTSubsystemDestroyed` (powersub.cpp) for the simulationState==1 test. +- **Emitter's AUTHENTIC attribute table recovered + published** (binary @0x511dd4, ids + 0x1D-0x25 chained after MechWeapon's 0x1C): LaserOn@0x418(firingActive), LaserScale@0x42C, + LaserRotation@0x41C, CurrentSeekVoltageIndex@0x3F0, RecommendedSeekVoltageIndex@0x3F4, + MinSeekVoltageIndex@0x3F8, MaxSeekVoltageIndex@0x3FC, SeekVoltage@0x400, + OutputVoltage@0x414(currentLevel — RAW volts, the cursor feed). Emitter field renames per the + table: @0x3F8 minSeekVoltageIndex, @0x3FC maxSeekVoltageIndex (static_assert-locked). The + MechWeapon 0x1D "OutputVoltage" PORT ALIAS was RETIRED (binary table ends 0x1C; Find walks + lowest-id-first so the alias shadowed the authentic row — the cursor would have read the 0..1 + rechargeLevel instead of volts). PPC::DefaultData → Emitter::GetAttributeIndex(). +- **Verified live** (BLH DEV.EGG, BT_AUTOFIRE + BT_PRESET_TEST + the new **BT_PRESET_HOLD=** + steady-state hold): both #10 repro pairs ghost-free held for minutes (SYS09 Streak→SYS10 PPC; + SYS02 Streak→SYS04 ERMed; SYS05 Sensors→SYS06 Myomers); curves draw (emitter = straight line, + authentically — sqrt of v² is linear; myomer = steep low curve), cursors track the charge + cycle, gear ticks render; [seek] log (BT_SEEK_LOG) shows exactly ONE replot per activation, + all 6 graph instances. NOTE: a BT_SHOT landing exactly on a page-switch frame can capture the + 1-frame label-vs-box transition (PrepEngr labels draw in BecameActive; the box clears at the + graph's next rated Execute) — the binary has the same lag class; it self-heals next frame and + the 90/120 cadence phase-lock made it look persistent during cycling verification. + ### Finding B — HUD binary attr table @005110c0 conflict (issue entry point b) — RESOLVED, FIXED Re-dumped section_dump.txt @5110b8: the table starts ONE RECORD EARLIER than transcribed — a full 16-byte record {id 3, "FlickerRate", 0x1D8|1, 0} (the old reading took the FlickerRate name ptr diff --git a/game/original/BT/PPC.CPP b/game/original/BT/PPC.CPP index f067a69..3540b58 100644 --- a/game/original/BT/PPC.CPP +++ b/game/original/BT/PPC.CPP @@ -34,7 +34,8 @@ PPC::SharedData &PPC::ClassDerivations, Emitter::GetMessageHandlers(), // task #6: the port's accessor idiom -- inherits // MechWeapon's ConfigureMappables/ChooseButton - MechWeapon::GetAttributeIndex(), // gauge wave: inherit temps/InputVoltage/OutputVoltage/PercentDone (was empty) + Emitter::GetAttributeIndex(), // issue #11: the authentic Emitter table (Seek*/Laser*/ + // OutputVoltage=currentLevel) -- chains MechWeapon's PPC::StateCount ); diff --git a/game/reconstructed/CLASSMAP.md b/game/reconstructed/CLASSMAP.md index 995cce5..fe47bc4 100644 --- a/game/reconstructed/CLASSMAP.md +++ b/game/reconstructed/CLASSMAP.md @@ -107,7 +107,7 @@ Subsystem (MUNGA base — have source: RP/MUNGA/SUBSYSTM.HPP) │ │ resource (size 0x1B0) adds +0x190 VelocityEfficiency, +0x194 AccelerationEfficiency, +0x198 SeekVoltage[5](fractions×ratedVoltage), │ │ +0x1AC SeekVoltageRecommendedIndex (parser @4b9140). attr table @00511588; string pool @00511660 ("Myomers"@0051166d). │ │ vtable overrides vs PoweredSubsystem: slot0 dtor@4b9114, slot9 HandleMessage@4b8a8c(→4b0efc), slot10 ResetToInitialState@4b8aa4(→4b0e6c), -│ │ slot15 GetSpeedReading@4b8f94 (base 4b1780; km/h÷350 gauge readout, best-effort), slot16 HasAdequateVoltage@4b8f3c (overrides IsSourceShorted base 4b0b5c). +│ │ slot15 SeekVoltageResponse@4b8f94 (base 4b1780; the SeekVoltageGraph vtbl+0x3C sampler: sqrt(AvailableOutput(v)·3.6/350) — issue #11 resolved the old "GetSpeedReading km/h gauge" guess; FUN_004dd138 = sqrt not fabs), slot16 HasAdequateVoltage@4b8f3c (overrides IsSourceShorted base 4b0b5c). │ │ helpers: AvailableOutput@4b8ac0 (drive = MechBaseSpeed × V/recommendedSeekV × heatFactor × (1-damage)), │ │ RegisterMaxOutput@4b8ef0 (→ Mech maxSpeed@0x7A0; loop @part_012:10322 over Mech myomer list@0x1EB), │ │ MyomersSimulation@4b8d18 (Performance, per-tick work→heat accum@0x1C8, gated by FUN_004ad7d4; best-effort registration), @@ -487,11 +487,19 @@ Searchlight 0xBD8 and ThermalSight 0xBDE are recovered, not gaps.) NB: MechWeapon's recharge-level slot @0x320 is reused by `Emitter` as **outputVoltage** (the normalised discharge readiness; ==1.0 ⇒ fully charged, used in EMITTER.TCP). - `Emitter` adds from @0x3F0 (object ~0x478): seekVoltageIndex@0x3F0, seekVoltageRecommendedIndex@0x3F4, - seekVoltageCount@0x3FC, seekVoltage[5]@0x400, currentLevel@0x414, firingActive@0x418, + minSeekVoltageIndex@0x3F8, maxSeekVoltageIndex@0x3FC (issue #11 renames — the binary attribute + table @0x511dd4 publishes them as Min/MaxSeekVoltageIndex; the old seekStepCounter/seekVoltageCount + names were guesses), seekVoltage[5]@0x400, currentLevel@0x414, firingActive@0x418, beamOrientation@0x41C, beamScale@0x42C, beamLengthRatio@0x434, graphicLength@0x438, dischargeTime@0x43C, dischargeTimer@0x440, damageFraction@0x444, energyTotal@0x448, damagePortion@0x44C, heatPortion@0x450, energyCoefficient@0x454, seekRate@0x45C, beamEndpoint@0x460, beamFlag@0x46C, targetLocalFlag@0x470, targetEntity@0x474. Firing state machine carried in weaponAlarm level @0x364 (0=Firing,2=Loaded,3=Loading,4=TriggerDuringLoad). + Attr table @0x511dd4 (ids 0x1D-0x25, issue #11 dump): LaserOn@0x418, LaserScale@0x42C, + LaserRotation@0x41C, CurrentSeekVoltageIndex@0x3F0, RecommendedSeekVoltageIndex@0x3F4, + MinSeekVoltageIndex@0x3F8, MaxSeekVoltageIndex@0x3FC, SeekVoltage@0x400, OutputVoltage@0x414 + (currentLevel — RAW volts, the SeekVoltageGraph cursor feed). vtable slot 15 @4bb42c = + SeekVoltageResponse (the graph sampler): sqrt(damageFraction·v²·0.5·energyCoefficient / 2.0e8) + (power helper @4bb3f4; FUN_004dd138 = sqrt). ## Recovered resource structs (parsed by each CreateStreamedSubsystem) - HeatableSubsystem/HeatSink: +0xE4 StartingTemperature, +0xE8 Degradation, +0xEC Failure, +0xF0 ThermalConductance, +0xF4 ThermalMass, +0xF8 HeatSink index diff --git a/game/reconstructed/btl4gau2.cpp b/game/reconstructed/btl4gau2.cpp index d7b8292..8b85705 100644 --- a/game/reconstructed/btl4gau2.cpp +++ b/game/reconstructed/btl4gau2.cpp @@ -57,7 +57,9 @@ // 0x463b8000=12000.0f generator max voltage // 0x42c80000=100.0f HeatSinkCluster numeric scale base // _DAT_004c8df0 heat numeric scale _DAT_004c92e0 warning threshold -// _DAT_004c6bdc/_DAT_004c6be0 seek-curve plot step/limit +// _DAT_004c6bdc=1200.0f / _DAT_004c6be0=12000.0f seek-curve plot step/limit +// ld80 @004c6bd0 / @004c6d74 = 8.3333333333333e-05 (exactly 1/12000) -- +// the voltage->y normaliser (y = v * (1/12000) * yScale) // DAT_0051936c PrepEngr engineer-screen number (1..12) // @@ -84,6 +86,10 @@ static const Scalar SeekVoltageYScale = 187.0f; // 0x433b0000 static const Scalar RedrawSentinel = 9999.0f; // 0x461c3c00 static const Scalar GeneratorMaxVoltage = 12000.0f; // 0x463b8000 static const Scalar HeatNumericBase = 100.0f; // 0x42c80000 +// SeekVoltageGraph plot constants (capstone disasm of @004c6934, task/issue #11): +static const Scalar SeekCurveStep = 1200.0f; // _DAT_004c6bdc (10 segments) +static const Scalar SeekCurveLimit = 12000.0f; // _DAT_004c6be0 (y-axis full scale) +static const Scalar SeekCurveYNorm = (Scalar)(1.0 / 12000.0); // ld80 @004c6bd0/@004c6d74 // (DebugStream warning channel, DAT_00524e20, is provided by the shared // reconstruction headers -- heat.hpp / mechrecon.hpp.) @@ -97,15 +103,6 @@ static char DAT_00518f24[] = "GeneratorCluster"; // subsystem name to resolve static int DAT_00518ee0 = 0; // GaugeRate static char DAT_00518f68[] = ""; // background image name -// -// Original FUN_0041bfc0 = Entity::FindAttributeIndex(name) -> ordinal. The -// WinTesla engine exposes attribute lookup via Simulation::AttributeIndexSet -// (Find/FindEntry by name) rather than a name->ordinal call, so this best-effort -// shim stands in for it; the stored indices are consumed by the (summarised) -// SeekVoltageGraph::Execute plot. -// -static int FindAttributeIndex(Entity *, const char *) { return -1; } - // // FUN_0041bfc0 == Entity::FindAttributeIndex(name) returns a POINTER into the // subsystem (subsystem + attributeMemberOffset), or 0 on miss. The composite @@ -250,14 +247,31 @@ protected: //########################################################################### //########################################################################### // SeekVoltageGraph @004c6798 +// (issue #11: fully reconstructed from the capstone disasm of +// @004c6798/@004c6920/@004c6934/@004c6be4/@004c6c30/@004c6c6c -- +// Ghidra had dropped every x87 argument; tools/disas2.py recovered them.) //########################################################################### //########################################################################### +// +// The subsystem's voltage-response sampler == the binary subsystem vtbl+0x3C +// (slot 15): Emitter @004bb42c = sqrt(seekPower(v)/2.0e8), Myomers @004b8f94 = +// sqrt(speed(v)*3.6/350). Reached through a complete-type dispatch bridge +// (emitter.cpp -> myomers.cpp) per the databinding rule (gotcha 8). +// +extern Scalar BTSeekVoltageSample(void *subsystem, Scalar voltage); +// +// The destroyed test == *(subsystem+0x40) == 1 (MechSubsystem::simulationState, +// 1 = Destroyed). Named-member bridge in powersub.cpp (databinding rule). +// +extern int BTSubsystemDestroyed(Subsystem *subsystem); + // // @004c6798 -- ctor (vtable PTR_FUN_0051a1fc). GraphicGauge base, interns and // ref-counts the "destroyed" bitmap, resolves the four SeekVoltage attribute -// indices off the weapon subsystem, sets the port extent (0x97,0x80,0x17d,0x13b) -// and stashes the 230x187 plot scale. +// POINTERS off the weapon subsystem (FUN_0041bfc0 returns subsystem+offset or +// 0), sets the view XOR draw op + the port extent (0x97,0x80,0x17d,0x13b) and +// stashes the 230x187 plot scale. // SeekVoltageGraph::SeekVoltageGraph( GaugeRate rate, @@ -278,22 +292,34 @@ SeekVoltageGraph::SeekVoltageGraph( destroyedImage = (char *)destroyed_image; renderer->warehousePointer->bitMapBin.Get(destroyedImage); // FUN_00442aec (AddRef) - currentSeekIndex = FindAttributeIndex(subsystem, "CurrentSeekVoltageIndex"); // FUN_0041bfc0 - minSeekIndex = FindAttributeIndex(subsystem, "MinSeekVoltageIndex"); - maxSeekIndex = FindAttributeIndex(subsystem, "MaxSeekVoltageIndex"); - seekVoltageTable = FindAttributeIndex(subsystem, "SeekVoltage"); + // FUN_0041bfc0(subsystem, name) == the engine's name-keyed attribute-pointer + // resolve (subsystem+memberOffset, or 0 on a miss); the composite ctors gate + // the tick/cursor draws on a 0 result. + currentSeekIndexPtr = (int *)AttributePointerOf(subsystem, "CurrentSeekVoltageIndex"); // this[0x25] + minSeekIndexPtr = (int *)AttributePointerOf(subsystem, "MinSeekVoltageIndex"); // this[0x26] + maxSeekIndexPtr = (int *)AttributePointerOf(subsystem, "MaxSeekVoltageIndex"); // this[0x27] + seekVoltageTablePtr = (Scalar *)AttributePointerOf(subsystem, "SeekVoltage"); // this[0x28] - voltageSubsystem = (Entity *)seek_voltage_pointer; // this[0x29] + liveVoltage = (Scalar *)seek_voltage_pointer; // this[0x29] this->subsystem = (Subsystem *)subsystem; // this[0x2A] - // graphicsView -> GraphicGauge::localView. SetExtent == SetPositionWithinPort; - // the original SetClip() (vtbl+0x0C) constrained drawing to that extent. + // @004c6798: (vtbl+0x0C)(3) then (vtbl+0x08)(0x97,0x80,0x17d,0x13b) -- + // SetOperation(Xor) + SetPositionWithinPort (GRAPH2D.h vtable order). The + // XOR op is what lets DrawTicks/DrawCursorBox erase by redrawing. + localView.SetOperation((GraphicsDisplay::Operation)3); // vtbl+0x0C Xor localView.SetPositionWithinPort(0x97, 0x80, 0x17d, 0x13b); // vtbl+0x08 xScale = SeekVoltageXScale; // this[0x2C] yScale = SeekVoltageYScale; // this[0x2D] drawCursor = draw_cursor; // this[0x2F] destroyedShown = 0; // this[0x2E] + // (binary leaves this[0x2B]/[0x30..0x32] uninitialised -- activation's + // BecameActive sentinel forces the first replot, which seeds them before + // any read. Seeded here for determinism.) + previousVoltage = RedrawSentinel; // this[0x2B] + cursorX = -1; // this[0x30] + cursorY = -1; // this[0x31] + tickIndexShown = -1; // this[0x32] } // @@ -311,38 +337,187 @@ Logical SeekVoltageGraph::TestInstance() const } // -// @004c6920 -- BecameActive: previousVoltage = 9999.0f (force first redraw). +// @004c6920 -- BecameActive: previousVoltage = 9999.0f (poison the cached +// response so the next Execute clears + replots the whole box -- THE top-box +// eraser on a page switch; the binary body writes ONLY this[0x2B]). // void SeekVoltageGraph::BecameActive() { - previousVoltage = RedrawSentinel; // @0xAC force first redraw - destroyedShown = 0; + previousVoltage = RedrawSentinel; // @0xAC force full redraw + if (getenv("BT_SEEK_LOG")) + DEBUG_STREAM << "[seek] BecameActive sub=" + << (subsystem ? subsystem->GetName() : "?") << "\n" << std::flush; } // -// @004c6934 -- Execute (the seek-voltage curve plot). BRING-UP: the full plot -// (SetupClip/DrawTicks/DrawCursor over a 0..1 voltage polyline) needs the live -// seek-voltage sampler (subsys vtbl+0x3c) + the four attribute indices; the -// attribute resolve is summarised (FindAttributeIndex returns -1), so the plot is -// deferred and Execute is inert until the sampler + attribute reads land. Kept as -// a defined no-op so the gauge's vtable is complete (no /FORCE crash when the -// Myomer/Energy cluster ticks its SeekVoltageGraph child). +// @004c6be4 -- the full-box erase ("SetupClip" in the old notes; it is really +// a clear): SetOperation(Replace), SetColor(0), MoveToAbsolute(0,0), +// DrawFilledRectangleToAbsolute(1000,1000) -- clipped to the view = the whole +// (0x97,0x80)-(0x17d,0x13b) top data box painted black. NOTE: leaves the view +// in Replace mode (the polyline plot relies on that; Execute restores Xor). +// +void SeekVoltageGraph::ClearGraph() +{ + localView.SetOperation((GraphicsDisplay::Operation)0); // vtbl+0x0C Replace + localView.SetColor(0); // vtbl+0x18 + localView.MoveToAbsolute(0, 0); // vtbl+0x24 + localView.DrawFilledRectangleToAbsolute(1000, 1000); // vtbl+0x48 +} + +// +// @004c6c30 -- the live cursor: an 8x8 filled square centred on +// (cursorX,cursorY), drawn in the view's current (Xor) op so a second call +// erases it. MoveToAbsolute(x-4,y-4) + DrawFilledRectangleToRelative(8,8). +// +void SeekVoltageGraph::DrawCursorBox() +{ + localView.MoveToAbsolute(cursorX - 4, cursorY - 4); // vtbl+0x24 + localView.DrawFilledRectangleToRelative(8, 8); // vtbl+0x4C +} + +// +// @004c6c6c -- the per-gear tick marks (XOR). For each seek index i in +// [*min..*max]: V = seekVoltage[i]; x = Round(sample(V) * xScale); +// y = Round(V * (1/12000) * yScale) [ld80 @004c6d74]. The gear the highlight +// was last drawn at (this[0x32]) gets the full L (axis->point->axis); the rest +// get 10-pixel axis stubs. Called in pairs to move the highlight (XOR erase + +// redraw). +// +void SeekVoltageGraph::DrawTicks() +{ + // (port guard: the binary derefs min/max unguarded -- its call sites only + // guarantee current+table non-null; all four always resolve on the + // emitter/myomer subsystems that own a graph.) + if (minSeekIndexPtr == 0 || maxSeekIndexPtr == 0) + return; + int last = *maxSeekIndexPtr; // this[0x27] deref + for (int i = *minSeekIndexPtr; i <= last; i++) // this[0x26] deref + { + Scalar gearVoltage = seekVoltageTablePtr[i]; // this[0x28][i] + int x = Round(BTSeekVoltageSample(subsystem, gearVoltage) * xScale); // (vtbl+0x3C)(V)*230 + int y = Round(gearVoltage * SeekCurveYNorm * yScale); // V/12000*187 + if (i == tickIndexShown) // this+0xC8 + { + localView.MoveToAbsolute(0, y); // vtbl+0x24 + localView.DrawLineToAbsolute(x, y); // vtbl+0x30 + localView.DrawLineToAbsolute(x, 0); + } + else + { + localView.MoveToAbsolute(0, y); + localView.DrawLineToAbsolute(10, y); + localView.MoveToAbsolute(x, 0); + localView.DrawLineToAbsolute(x, 10); + } + } +} + +// +// @004c6934 -- Execute (the seek-voltage response plot; full x87 recovery). +// +// DESTROYED branch (subsys simulationState==1): once, clear the box and draw +// the centred "destroyed" bitmap (edestryd.pcc) at +// ((Round(xScale)-w)/2, (Round(yScale)-h)/2), color 0xFF. +// +// LIVE branch: +// * on the destroyed->alive edge, call BecameActive (own vtbl+0xC == slot 3 +// of PTR_0051a1fc = @004c6920) to poison the cache; +// * change-test: sample the response at 12000 V (0x463b8000) and compare with +// the cached this[0x2B]; on change (or the 9999 activation sentinel): +// CLEAR (@004c6be4 -- the ghost eraser), SetColor(0xFF), MoveTo(0,0), then +// the polyline v = 0,1200,...,10800 (step _DAT_004c6bdc=1200 while +// v < _DAT_004c6be0=12000): LineTo(Round(sample(v)*230), +// Round(v * [1/12000 * 187])), reset the cursor slots to -1, restore +// SetOperation(Xor), and draw the gear ticks at the current index; +// * tick maintenance: when the current seek index moved, XOR-erase the old +// highlight + redraw at the new (two DrawTicks calls); +// * cursor (draw_cursor pages only): XOR-erase the old box (if any), sample +// at the LIVE voltage (*this[0x29]) and draw the new 8x8 box at +// (Round(sample(V)*230), Round(V * [1/12000 * 187])). // void SeekVoltageGraph::Execute() { - // (seek-voltage plot deferred -- see note above.) -} + if (BTSubsystemDestroyed(subsystem)) // *(subsys+0x40) == 1 + { + if (destroyedShown == 0) + { + destroyedShown = 1; // this[0x2E] + ClearGraph(); // @004c6be4 + L4Warehouse *warehouse = (L4Warehouse *)renderer->warehousePointer; + BitMap *image = warehouse->bitMapBin.Get(destroyedImage); // FUN_00442aec + if (image != NULL) // (port guard; binary derefs unchecked) + { + localView.MoveToAbsolute( // vtbl+0x24 centre the bitmap + (Round(xScale) - image->Data.Size.x) >> 1, + (Round(yScale) - image->Data.Size.y) >> 1); + localView.SetColor(0xff); // vtbl+0x18 + localView.DrawBitMap(0, image, // vtbl+0x54 + 0, 0, image->Data.Size.x - 1, image->Data.Size.y - 1); + } + warehouse->bitMapBin.Release(destroyedImage); // FUN_00442c12 + } + return; + } -// -// @004c6934 (original notes preserved) -- Execute. If the subsystem is destroyed -// (subsys+0x40 == 1) draw -// the centred "destroyed" bitmap once. Otherwise, when the live seek voltage -// (subsys vtbl+0x3c sampler) changes: clear (SetupClip @004c6be4), re-plot the -// 0..1 voltage curve as a polyline (step _DAT_004c6bdc up to _DAT_004c6be0, -// Round each point), then draw the min/max tick marks (DrawTicks @004c6c6c) -// and, if drawCursor, the live cursor at the current seek index -// (DrawCursor @004c6c30). -// + if (destroyedShown != 0) + { + destroyedShown = 0; + BecameActive(); // own vtbl+0xC (@004c6920) + } + + Scalar yStep = SeekCurveYNorm * yScale; // fld tbyte @004c6bd0 * this[0x2D] + + // change-test: the response curve's value at max voltage. + Scalar response = BTSeekVoltageSample(subsystem, GeneratorMaxVoltage); // (vtbl+0x3C)(12000.0f) + if (previousVoltage != response) // this[0x2B] + { + if (getenv("BT_SEEK_LOG")) + DEBUG_STREAM << "[seek] replot sub=" + << (subsystem ? subsystem->GetName() : "?") + << " response12k=" << response << "\n" << std::flush; + previousVoltage = response; + ClearGraph(); // @004c6be4 -- erases the WHOLE box + localView.SetColor(0xff); // vtbl+0x18 (Replace mode from the clear) + localView.MoveToAbsolute(0, 0); // vtbl+0x24 + for (Scalar v = 0.0f; v < SeekCurveLimit; v += SeekCurveStep) + { + int x = Round(BTSeekVoltageSample(subsystem, v) * xScale); + int y = Round(v * yStep); + localView.DrawLineToAbsolute(x, y); // vtbl+0x30 + } + cursorX = -1; // this[0x30] + cursorY = -1; // this[0x31] + localView.SetOperation((GraphicsDisplay::Operation)3); // vtbl+0x0C back to Xor + if (currentSeekIndexPtr != 0 && seekVoltageTablePtr != 0) + { + tickIndexShown = *currentSeekIndexPtr; // this[0x32] + DrawTicks(); // @004c6c6c + } + } + + // gear-change tick maintenance (XOR pair: erase old highlight, draw new). + if (currentSeekIndexPtr != 0 && seekVoltageTablePtr != 0) + { + int currentIndex = *currentSeekIndexPtr; + if (currentIndex != tickIndexShown) + { + DrawTicks(); // erase at the old index + tickIndexShown = currentIndex; + DrawTicks(); // draw at the new + } + } + + // live-voltage cursor (drawCursor pages: the emitter/PPC graphs). + if (drawCursor != 0) // this[0x2F] + { + if (cursorX >= 0) + DrawCursorBox(); // XOR-erase the old box + Scalar live = *liveVoltage; // *this[0x29] + cursorX = Round(BTSeekVoltageSample(subsystem, live) * xScale); + cursorY = Round(live * yStep); + DrawCursorBox(); // @004c6c30 + } +} //########################################################################### diff --git a/game/reconstructed/btl4gau2.hpp b/game/reconstructed/btl4gau2.hpp index ac26752..44e2b20 100644 --- a/game/reconstructed/btl4gau2.hpp +++ b/game/reconstructed/btl4gau2.hpp @@ -86,13 +86,22 @@ //####################################################################### - // SeekVoltageGraph -- the energy-weapon "seek voltage" curve display. + // SeekVoltageGraph -- the energy-weapon / myomer "seek voltage" response + // graph on the engineering detail pages (the "POWER" box). // @004c6798 ctor / @004c68ac dtor / @004c6920 BecameActive / @004c6934 Execute. - // vtable PTR_FUN_0051a1fc. Reads the four SeekVoltage attribute indices off - // the weapon subsystem ("CurrentSeekVoltageIndex" / "Min" / "Max" / - // "SeekVoltage") and plots a 0..230 x 0..187 curve, with min/max tick marks - // and a live cursor at the current seek index. When the subsystem is - // destroyed (subsys+0x40==1) it shows a centred "destroyed" bitmap instead. + // vtable PTR_FUN_0051a1fc. Resolves the four SeekVoltage attribute + // POINTERS off the weapon subsystem (FUN_0041bfc0 returns subsystem+offset: + // "CurrentSeekVoltageIndex" / "MinSeekVoltageIndex" / "MaxSeekVoltageIndex" + // / "SeekVoltage") and plots the subsystem's voltage-response curve + // (vtbl slot 15 sampler: Emitter @004bb42c / Myomers @004b8f94) over the + // top data box (0x97,0x80)-(0x17d,0x13b): x = response(v)*230 (0..1 + // normalised sqrt response), y = v/12000*187 (voltage axis), with per-gear + // tick marks (the current gear drawn as a full L) and, when draw_cursor, + // a live 8x8 XOR cursor at the subsystem's live voltage. When the + // subsystem is destroyed (simulationState@0x40==1) it shows a centred + // "destroyed" bitmap instead. Its Execute's full-box CLEAR (@004c6be4) + // is what erases the sibling eng pages' stale pixels on the shared Eng + // bit-plane -- the widget IS the top-box eraser (Gitea #10 finding A). //####################################################################### class SeekVoltageGraph : public GraphicGauge @@ -109,19 +118,30 @@ void BecameActive(); // @004c6920 void Execute(); // @004c6934 protected: + void ClearGraph(); // @004c6be4 (full-box erase) + void DrawTicks(); // @004c6c6c (per-gear ticks, XOR) + void DrawCursorBox(); // @004c6c30 (8x8 cursor, XOR) + char *destroyedImage; // @0x90 this[0x24] - int currentSeekIndex; // @0x94 this[0x25] attr idx - int minSeekIndex; // @0x98 this[0x26] attr idx - int maxSeekIndex; // @0x9C this[0x27] attr idx - int seekVoltageTable; // @0xA0 this[0x28] attr idx - Entity *voltageSubsystem; // @0xA4 this[0x29] + int *currentSeekIndexPtr; // @0x94 this[0x25] attr ptr (subsys+off) + int *minSeekIndexPtr; // @0x98 this[0x26] attr ptr + int *maxSeekIndexPtr; // @0x9C this[0x27] attr ptr + Scalar *seekVoltageTablePtr; // @0xA0 this[0x28] attr ptr (array base) + Scalar *liveVoltage; // @0xA4 this[0x29] the ctor's + // seek_voltage_pointer ("OutputVoltage" + // attr ptr / cluster seekValue cache) -- + // Execute derefs it for the live cursor Subsystem *subsystem; // @0xA8 this[0x2A] - Scalar previousVoltage; // @0xAC this[0x2B] (9999 = redraw) + Scalar previousVoltage; // @0xAC this[0x2B] cached response at + // 12000 V (9999 sentinel = force replot) Scalar xScale; // @0xB0 this[0x2C] =230.0f Scalar yScale; // @0xB4 this[0x2D] =187.0f int destroyedShown; // @0xB8 this[0x2E] Logical drawCursor; // @0xBC this[0x2F] - // helpers: SetupClip @004c6be4, DrawCursor @004c6c30, DrawTicks @004c6c6c + int cursorX; // @0xC0 this[0x30] (-1 = no cursor drawn) + int cursorY; // @0xC4 this[0x31] + int tickIndexShown; // @0xC8 this[0x32] gear index the tick + // highlight was last drawn at }; diff --git a/game/reconstructed/emitter.cpp b/game/reconstructed/emitter.cpp index aa0d31d..37f626b 100644 --- a/game/reconstructed/emitter.cpp +++ b/game/reconstructed/emitter.cpp @@ -73,6 +73,7 @@ # include #endif #include // HostManager::GetEntityPointer -- the update-record target resolve (task #51) +#include // sqrt -- the SeekVoltageResponse sampler (issue #11) // E8 (bring-up): the player fire input. The controls mapper that would normally write // the weapon's fireImpulse is bypassed (mech4.cpp), so EmitterSimulation reads this @@ -153,13 +154,49 @@ Receiver::MessageHandlerSet& MechWeapon::GetMessageHandlers()); // copy-inherit return messageHandlers; } -Simulation::AttributeIndexSet Emitter::AttributeIndex; +// +// issue #11: the AUTHENTIC Emitter attribute table (binary @0x511dd4, dumped +// records {id, name*, offset|1, 0}, ids 0x1D..0x25 -- a dense run chained +// straight after MechWeapon's 0x1C WeaponState, so no pads needed). The four +// Seek* rows are what the SeekVoltageGraph ctor (@004c6798) resolves by name; +// OutputVoltage here binds currentLevel@0x414 -- the RAW charge voltage (the +// graph's live-cursor feed), which the retired MechWeapon 0x1D port alias +// (rechargeLevel, 0..1) used to shadow. Function-local statics per the +// static-init-order rule (reconstruction-gotchas #9). +// +// The dense-prefix lock (gotcha #11): the run must start exactly one past +// MechWeapon's last id (0x1C WeaponState) == the binary's own numbering. +static_assert((int)Emitter::LaserOnAttributeID == 0x1D, + "Emitter attr run must chain at 0x1D (binary table @0x511dd4) -- " + "did a MechWeapon attribute get added/removed?"); + +Emitter::AttributeIndexSet& + Emitter::GetAttributeIndex() +{ + static const Emitter::IndexEntry entries[] = + { + ATTRIBUTE_ENTRY(Emitter, LaserOn, firingActive), // 0x1D @0x418 + ATTRIBUTE_ENTRY(Emitter, LaserScale, beamScale), // 0x1E @0x42C + ATTRIBUTE_ENTRY(Emitter, LaserRotation, beamOrientation), // 0x1F @0x41C + ATTRIBUTE_ENTRY(Emitter, CurrentSeekVoltageIndex, seekVoltageIndex), // 0x20 @0x3F0 + ATTRIBUTE_ENTRY(Emitter, RecommendedSeekVoltageIndex, seekVoltageRecommendedIndex), // 0x21 @0x3F4 + ATTRIBUTE_ENTRY(Emitter, MinSeekVoltageIndex, minSeekVoltageIndex), // 0x22 @0x3F8 + ATTRIBUTE_ENTRY(Emitter, MaxSeekVoltageIndex, maxSeekVoltageIndex), // 0x23 @0x3FC + ATTRIBUTE_ENTRY(Emitter, SeekVoltage, seekVoltage), // 0x24 @0x400 (array base) + ATTRIBUTE_ENTRY(Emitter, OutputVoltage, currentLevel) // 0x25 @0x414 (RAW volts) + }; + static Emitter::AttributeIndexSet attributeIndex( + ELEMENTS(entries), entries, + MechWeapon::GetAttributeIndex() + ); + return attributeIndex; +} Emitter::SharedData Emitter::DefaultData( &Emitter::ClassDerivations, Emitter::GetMessageHandlers(), - MechWeapon::GetAttributeIndex(), // gauge wave: inherit temps/InputVoltage/OutputVoltage/PercentDone (was empty) + Emitter::GetAttributeIndex(), // own table (Seek*/Laser*/OutputVoltage) chained to MechWeapon's Emitter::StateCount ); @@ -590,7 +627,7 @@ int } int prev = seekVoltageIndex; // 0x3f0 - int modulus = seekVoltageCount + 1; // 0x3fc + 1 + int modulus = maxSeekVoltageIndex + 1; // 0x3fc + 1 int next = (prev + 1) % modulus; seekVoltageIndex = next; // 0x3f0 if (prev < next) @@ -663,6 +700,35 @@ void } } +// +// @004bb3f4 -- the seek discharge power at a supply voltage (issue #11, +// capstone disasm: fld v; fmul v; fmul [0x4bb428]=0.5f; fmul [this+0x454]; +// -> * [this+0x444]): +// P(v) = damageFraction * (v^2 * 0.5 * energyCoefficient) +// i.e. the damage share of the E = 1/2 k V^2 discharge energy at voltage v. +// +Scalar + Emitter::SeekPower(Scalar voltage) +{ + return damageFraction * (voltage * voltage * 0.5f * energyCoefficient); // 0x444 / 0x454 +} + +// +// @004bb42c (vtable slot 15) -- the SeekVoltageGraph voltage-response sampler +// (issue #11). Disasm: locals A = 0x4d3ebc20 (exactly 2.0e8f), B = 0; +// r = (SeekPower(v) - B) / (A - B); FUN_004dd138(r) == sqrt (part_015.c:4026 -- +// the old "fp magnitude" reading was wrong). So: the graph's x-axis is the +// square root of the discharge power normalised to a 2.0e8 full scale. +// +Scalar + Emitter::SeekVoltageResponse(Scalar voltage) +{ + Scalar r = (SeekPower(voltage) - 0.0f) / (2.0e8f - 0.0f); // 0x4d3ebc20 + // (FUN_004dd138 routes a negative operand to the matherr handler; P >= 0 + // always here -- guarded for port safety.) + return (r > 0.0f) ? (Scalar)sqrt((double)r) : 0.0f; +} + //############################################################################# // Subsystem virtual overrides @@ -855,7 +921,7 @@ Emitter::Emitter( energyCoefficient = 1.0f; // 0x454 seekVoltageIndex = 0; // 0x3f0 seekVoltageRecommendedIndex = 0; // 0x3f4 - seekVoltageCount = 0; // 0x3fc + maxSeekVoltageIndex = 0; // 0x3fc for (int sv = 0; sv < 5; ++sv) seekVoltage[sv] = 1.0f; // 0x400[] @@ -894,7 +960,7 @@ Emitter::Emitter( { if (subsystem_resource->seekVoltage[i] == -1.0f) // _DAT_004bb3b0 sentinel { - seekVoltageCount = i - 1; // 0x3fc + maxSeekVoltageIndex = i - 1; // 0x3fc (= count-1) break; } // the authored values are FRACTIONS of the generator's rated @@ -904,7 +970,7 @@ Emitter::Emitter( } seekVoltageRecommendedIndex = subsystem_resource->seekVoltageRecommendedIndex; // 0x3f4 <- +0x1d8 seekVoltageIndex = seekVoltageRecommendedIndex; // 0x3f0 - seekStepCounter = 0; // 0x3f8 + minSeekVoltageIndex = 0; // 0x3f8 // energyCoefficient = energyTotal / (seekVoltage[rec]^2 * 0.5) // -> E = 0.5 * V^2 * EC lands exactly on energyTotal at V=seekV[rec] @@ -935,7 +1001,7 @@ Emitter::Emitter( // damageFraction = damageAmount / (damageAmount + heatCostToFire) damageFraction = damageData.damageAmount / (damageData.damageAmount + heatCostToFire); // 0x444 - seekStepCounter = 0; // 0x3f0/0x45c bookkeeping + minSeekVoltageIndex = 0; // 0x3f8 (always 0 -- attr 0x22) Check_Fpu(); } @@ -1063,3 +1129,27 @@ Subsystem *CreateEmitterSubsystem(Mech *owner, int id, void *seg) return (Subsystem *) new (Memory::Allocate(0x478)) Emitter(owner, id, (Emitter::SubsystemResource *)seg, Emitter::DefaultData); } + +//===========================================================================// +// issue #11 bridge -- the SeekVoltageGraph's voltage-response sample (the +// binary calls the SUBSYSTEM vtbl+0x3C / slot 15 virtually; the port's gauge +// TU cannot include the weapon headers, so the dispatch lives here in a +// complete-type TU per the databinding rule, gotcha 8). Emitter (and PPC, +// which IS an Emitter both by derivation and by factory) -> @004bb42c; +// everything else forwards to the Myomers bridge (myomers.cpp, @004b8f94). +//===========================================================================// +extern Scalar BTMyomersSeekSample(void *subsystem, Scalar voltage); // myomers.cpp + +Scalar BTSeekVoltageSample(void *subsystem, Scalar voltage) +{ + Entity *entity = (Entity *)subsystem; + if (entity == 0) + { + return 0.0f; + } + if (entity->IsDerivedFrom(Emitter::ClassDerivations)) + { + return ((Emitter *)entity)->SeekVoltageResponse(voltage); // @004bb42c + } + return BTMyomersSeekSample(subsystem, voltage); // @004b8f94 (guarded) +} diff --git a/game/reconstructed/emitter.hpp b/game/reconstructed/emitter.hpp index 8559302..1444df9 100644 --- a/game/reconstructed/emitter.hpp +++ b/game/reconstructed/emitter.hpp @@ -89,7 +89,29 @@ class NotationFile; public: static Derivation ClassDerivations; static Receiver::MessageHandlerSet& GetMessageHandlers(); // inherits MechWeapon's (task #6) - static AttributeIndexSet AttributeIndex; + + // + // The AUTHENTIC Emitter attribute table (binary @0x511dd4, 9 records + // {id, name, offset|1, 0}, ids 0x1D..0x25 chained after MechWeapon's + // 0x1C WeaponState -- dumped issue #11). The Seek* rows feed the + // SeekVoltageGraph engineering-page widget (ctor @004c6798 resolves + // them by NAME); OutputVoltage binds the RAW charge voltage + // currentLevel@0x414 (the graph's live cursor feed) -- NOT the 0..1 + // rechargeLevel (the retired MechWeapon port alias). + // + enum { + LaserOnAttributeID = MechWeapon::NextAttributeID, // 0x1D @0x418 firingActive + LaserScaleAttributeID, // 0x1E @0x42C beamScale + LaserRotationAttributeID, // 0x1F @0x41C beamOrientation + CurrentSeekVoltageIndexAttributeID, // 0x20 @0x3F0 + RecommendedSeekVoltageIndexAttributeID, // 0x21 @0x3F4 + MinSeekVoltageIndexAttributeID, // 0x22 @0x3F8 + MaxSeekVoltageIndexAttributeID, // 0x23 @0x3FC + SeekVoltageAttributeID, // 0x24 @0x400 (array base) + OutputVoltageAttributeID, // 0x25 @0x414 currentLevel (RAW volts) + NextAttributeID // 0x26 + }; + static AttributeIndexSet& GetAttributeIndex(); static SharedData DefaultData; // Update record (replication) -- extends the MechWeapon record with the @@ -172,6 +194,8 @@ class NotationFile; // slot 9 TakeDamage @004bafc8 (NOT yet reconstructed) // slot 10 ResetToInitialState (MechWeapon base @004b96ec) // slot 13 PrintState (MechWeapon base @004b9d00) + // slot 15 SeekVoltageResponse @004bb42c (the SeekVoltageGraph sampler; + // Myomers' analog @004b8f94 shares the slot) // slot 16 ReadyToDischarge (Simulation interface) // slot 17 ComputeOutputVoltage (Simulation interface) // slot 18 FireWeapon @@ -184,6 +208,13 @@ class NotationFile; void PrintState(); // slot 13, @004bb014 + // @004bb42c (vtable slot 15) -- the SeekVoltageGraph's voltage-response + // sampler: sqrt((SeekPower(v) - 0) / (2.0e8f - 0)) [FUN_004dd138 = sqrt; + // consts from the capstone disasm, issue #11]. Maps a supply voltage to + // the graph's 0..1 x-axis (normalised sqrt of the discharge power). + Scalar + SeekVoltageResponse(Scalar voltage); // slot 15, @004bb42c + // // Beam-render accessors (the visible-beam layer draws each weapon's own // beam from this live sim state -- the authentic per-weapon fire look). @@ -233,6 +264,12 @@ class NotationFile; // Internal charge / discharge helpers (non-virtual; called by the sim) // protected: + // @004bb3f4 -- the seek discharge power at a supply voltage: + // damageFraction * v^2 * 0.5 [_DAT_004bb428] * energyCoefficient. + // (== the E = 1/2 k V^2 discharge energy scaled by the damage split.) + Scalar + SeekPower(Scalar voltage); // @004bb3f4 + // @004ba838 -- track the linked voltage source: derive a seek rate and // integrate currentLevel; dump the I^2R heat back into the source. void @@ -313,8 +350,15 @@ class NotationFile; // * beamLengthRatio -> is beamScale.z@0x434 int seekVoltageIndex; // @0x3F0 (param_1[0xfc]) current charge-curve index int seekVoltageRecommendedIndex;// @0x3F4 (param_1[0xfd]) recommended index - int seekStepCounter; // @0x3F8 (param_1[0xfe]) reset 0 - int seekVoltageCount; // @0x3FC (param_1[0xff]) index modulus / count-1 + // issue #11 IDENTITY CORRECTION (binary attribute table @0x511dd4): + // @0x3F8 is published as "MinSeekVoltageIndex" and @0x3FC as + // "MaxSeekVoltageIndex" -- the old names (seekStepCounter, "reset 0" / + // seekVoltageCount, "count-1") were best-effort guesses that happened + // to carry exactly those values (min is always 0; max = count-1, the + // AdvanceSeekVoltage modulus-1). Renamed to the attribute truth -- + // the exact analog of Myomers @0x328/@0x32C. + int minSeekVoltageIndex; // @0x3F8 (param_1[0xfe]) always 0 + int maxSeekVoltageIndex; // @0x3FC (param_1[0xff]) count-1 (index modulus - 1) Scalar seekVoltage[5]; // @0x400 (param_1[0x100..0x104]) per-index target voltages Scalar currentLevel; // @0x414 (param_1[0x105]) accumulated charge int firingActive; // @0x418 (param_1[0x106]) reset 0; FireWeapon sets 1 @@ -340,6 +384,11 @@ class NotationFile; struct EmitterLayoutCheck { static_assert(offsetof(Emitter, seekVoltageIndex) == 0x3F0, "Emitter::seekVoltageIndex @0x3F0 (MechWeapon must end 0x3F0)"); + // issue #11: the four attribute-published Seek* slots (table @0x511dd4) + static_assert(offsetof(Emitter, minSeekVoltageIndex) == 0x3F8, "Emitter::minSeekVoltageIndex @0x3F8 (attr 0x22)"); + static_assert(offsetof(Emitter, maxSeekVoltageIndex) == 0x3FC, "Emitter::maxSeekVoltageIndex @0x3FC (attr 0x23)"); + static_assert(offsetof(Emitter, seekVoltage) == 0x400, "Emitter::seekVoltage @0x400 (attr 0x24)"); + static_assert(offsetof(Emitter, firingActive) == 0x418, "Emitter::firingActive @0x418 (attr 0x1D LaserOn)"); static_assert(offsetof(Emitter, currentLevel) == 0x414, "Emitter::currentLevel @0x414"); static_assert(offsetof(Emitter, beamOrientation) == 0x41C, "Emitter::beamOrientation @0x41C (Quaternion 16B)"); static_assert(offsetof(Emitter, beamScale) == 0x42C, "Emitter::beamScale @0x42C"); diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 2c19f29..ea5dad5 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -5214,11 +5214,19 @@ void && getenv("BT_PRESET_TEST")) { static int s_ptFrame = 0; + static int s_ptPulses = 0; ++s_ptFrame; int t0 = atoi(getenv("BT_PRESET_TEST")); if (t0 < 1) t0 = 600; - if (s_ptFrame >= t0 && (s_ptFrame - t0) % 120 == 0) + // issue #11: BT_PRESET_HOLD= stops after n pulses so a page can + // be HELD indefinitely (steady-state ghost/graph verification -- + // the 120-frame cycle otherwise phase-locks with BT_SHOT's 90-frame + // cadence, so a fixed fraction of shots lands on transition frames). + int holdAfter = getenv("BT_PRESET_HOLD") ? atoi(getenv("BT_PRESET_HOLD")) : 0; + if (s_ptFrame >= t0 && (s_ptFrame - t0) % 120 == 0 + && (holdAfter <= 0 || s_ptPulses < holdAfter)) { + ++s_ptPulses; gBTPresetCycle[0] = 1; gBTPresetCycle[1] = 1; gBTPresetCycle[2] = 1; diff --git a/game/reconstructed/mechweap.cpp b/game/reconstructed/mechweap.cpp index 10db641..0dfc93e 100644 --- a/game/reconstructed/mechweap.cpp +++ b/game/reconstructed/mechweap.cpp @@ -118,10 +118,15 @@ static_assert((int)PoweredSubsystem::NextAttributeID const MechWeapon::IndexEntry MechWeapon::AttributePointers[]= { - // task #16: the FULL binary table @0x511890 (eleven entries) + the - // port-extra OutputVoltage alias. Every member offset matches the - // binary's (layout static_assert-locked); TriggerState stays the streamed - // fire-button binding (id 0x13 PINNED). + // task #16: the FULL binary table @0x511890 (eleven entries). Every member + // offset matches the binary's (layout static_assert-locked); TriggerState + // stays the streamed fire-button binding (id 0x13 PINNED). + // issue #11: the port-extra 0x1D "OutputVoltage" alias (-> rechargeLevel) is + // RETIRED -- the binary MechWeapon table ends at 0x1C, and the alias SHADOWED + // the authentic Emitter-level OutputVoltage (currentLevel@0x414, RAW volts -- + // the SeekVoltageGraph cursor feed) because AttributeIndexSet::Find walks + // lowest-id-first. Energy weapons now publish their own table (emitter.cpp + // ids 0x1D-0x25); no ballistic-weapon consumer ever bound the alias. // // The PADS fill our chain-vs-binary id gap (now 0x0F..0x11) with valid, // named, never-bound entries (the binary's own ids here are the powered/ @@ -142,8 +147,7 @@ const MechWeapon::IndexEntry ATTRIBUTE_ENTRY(MechWeapon, PipExtendedRange, pipExtendedRange), // 0x19 @0x348 ATTRIBUTE_ENTRY(MechWeapon, EstimatedReadyTime, estimatedReadyTime),// 0x1A @0x330 ATTRIBUTE_ENTRY(MechWeapon, RearFiring, rearFiring), // 0x1B @0x334 (task #68: real semantics) - ATTRIBUTE_ENTRY(MechWeapon, WeaponState, weaponAlarm), // 0x1C @0x350 - ATTRIBUTE_ENTRY(MechWeapon, OutputVoltage, rechargeLevel) // 0x1D (port alias) + ATTRIBUTE_ENTRY(MechWeapon, WeaponState, weaponAlarm) // 0x1C @0x350 (binary table END) }; MechWeapon::AttributeIndexSet& diff --git a/game/reconstructed/mechweap.hpp b/game/reconstructed/mechweap.hpp index 875f8a8..4109023 100644 --- a/game/reconstructed/mechweap.hpp +++ b/game/reconstructed/mechweap.hpp @@ -140,13 +140,14 @@ class CockpitHud; //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Attribute Support (gauge data-binding wave) // - // The energy/ballistic weapon engineering clusters read OutputVoltage (the - // charge/ready level -- animates as the weapon recharges after firing) and - // PercentDone (the recharge dial), both onto rechargeLevel (1.0 == ready). - // GetAttributeIndex() chains to PoweredSubsystem's so InputVoltage + the - // HeatSink temps also resolve; Emitter/PPC/ProjectileWeapon/MissileLauncher - // point their DefaultData at MechWeapon::GetAttributeIndex() to inherit it - // (they carried an EMPTY default-constructed index -> resolved NOTHING). + // The weapon engineering clusters read PercentDone (the recharge dial) onto + // rechargeLevel (1.0 == ready). GetAttributeIndex() chains to + // PoweredSubsystem's so InputVoltage + the HeatSink temps also resolve; + // ProjectileWeapon/MissileLauncher point their DefaultData at + // MechWeapon::GetAttributeIndex() to inherit it (they carried an EMPTY + // default-constructed index -> resolved NOTHING). Emitter/PPC now chain + // their OWN authentic table on top (emitter.cpp, ids 0x1D-0x25: Laser*/ + // Seek*/OutputVoltage=currentLevel -- issue #11). // public: enum { @@ -203,10 +204,12 @@ class CockpitHud; PipExtendedRangeAttributeID, // 0x19 @0x348 EstimatedReadyTimeAttributeID, // 0x1A @0x330 RearFiringAttributeID, // 0x1B @0x334 - WeaponStateAttributeID, // 0x1C @0x350 weaponAlarm - OutputVoltageAttributeID, // 0x1D PORT-EXTRA alias -> rechargeLevel (the - // engineering clusters bind it by name) - NextAttributeID + WeaponStateAttributeID, // 0x1C @0x350 weaponAlarm (binary table END) + // issue #11: the 0x1D "OutputVoltage" PORT-EXTRA alias is RETIRED -- + // it shadowed the authentic Emitter table's OutputVoltage + // (currentLevel@0x414 RAW volts, ids 0x1D-0x25 chain here exactly + // as in the binary; Find walks lowest-id-first). + NextAttributeID // 0x1D == the binary chain value }; private: static const IndexEntry AttributePointers[]; diff --git a/game/reconstructed/myomers.cpp b/game/reconstructed/myomers.cpp index 5eced4c..99f3370 100644 --- a/game/reconstructed/myomers.cpp +++ b/game/reconstructed/myomers.cpp @@ -40,8 +40,9 @@ // ConnectToMover/DisconnectFromMover bodies. // best-effort : MyomersSimulation (@004b8d18) is the per-tick Performance // (only reachable via function pointer; SetPerformance call -// site not captured -- see note); GetSpeedReading (@004b8f94, -// slot 15) purpose; ToggleSeekVoltage (@004b8a48) not present +// site not captured -- see note); [slot 15 @004b8f94 RESOLVED +// issue #11: SeekVoltageResponse -- the SeekVoltageGraph +// sampler, sqrt not fabs]; ToggleSeekVoltage (@004b8a48) not present // in the decompiled shards; the heat-base field @0x150 and // graphic-state block (this[7..9]) inherit-pattern stores. // excluded : @0x4b7f94..@0x4b881c (gimbal / torso-horizon display, base @@ -315,7 +316,7 @@ int Myomers::CreateStreamedSubsystem( //*************************************************************************** // // Core drive computation: returns the locomotion drive available for a given -// input voltage. (helper; called by RegisterMaxOutput and GetSpeedReading.) +// input voltage. (helper; called by RegisterMaxOutput and SeekVoltageResponse.) // // base = ownerMech[0x34C] * (input_voltage / seekVoltage[recommendedIndex]) // ^ Mech base-speed scalar ^ normalise to the recommended gear @@ -372,19 +373,40 @@ void Myomers::RegisterMaxOutput() //*************************************************************************** -// Myomers::GetSpeedReading slot 15 @004b8f94 +// Myomers::SeekVoltageResponse slot 15 @004b8f94 //*************************************************************************** // -// PoweredSubsystem virtual slot 15 (base @004b1780). Best-effort: converts -// AvailableOutput(input_voltage) into a normalised gauge reading -- output is -// scaled by 3.6 (m/s -> km/h) and divided by 350 (gauge full scale), then run -// through FUN_004dd138 (fp magnitude helper / sqrt). Drives a cockpit -// speed gauge. (Decompiled body discards the result -> returned via FPU.) +// PoweredSubsystem virtual slot 15 (base @004b1780) == the shared subsystem +// vtbl+0x3C sampler the SeekVoltageGraph's Execute (@004c6934) plots -- THIS +// is what draws the myomer engineering page's POWER curve (issue #11; the +// old "drives a cockpit speed gauge" guess and the GetSpeedReading name are +// retired). Converts AvailableOutput(input_voltage) into the graph's 0..1 +// x-response: scaled by 3.6 (m/s -> km/h), normalised to a 350 km/h full +// scale, then FUN_004dd138 == SQRT (part_015.c:4026 -- the old "fp magnitude +// / fabs" reading was wrong; sqrt is also what Emitter's analog @004bb42c +// applies to its power ratio). (Decompiled body discards the result -> +// returned via FPU.) // -Scalar Myomers::GetSpeedReading(Scalar input_voltage) +Scalar Myomers::SeekVoltageResponse(Scalar input_voltage) { Scalar output = AvailableOutput(input_voltage); // @004b8ac0 - return (Scalar)fabs((double)((output * 3.6f - 0.0f) / 350.0f)); // FUN_004dd138 (fp magnitude) + Scalar r = (output * 3.6f - 0.0f) / 350.0f; + // (FUN_004dd138 routes a negative operand to the matherr handler; output + // >= 0 in practice -- guarded for port safety.) + return (r > 0.0f) ? (Scalar)sqrt((double)r) : 0.0f; // FUN_004dd138 (sqrt) +} + +//*************************************************************************** +// issue #11 bridge -- the Myomers leg of the SeekVoltageGraph sample dispatch +// (see emitter.cpp BTSeekVoltageSample). Guarded: a non-Myomers subsystem +// samples 0 (only emitter/myomer pages own a graph in the shipped config). +//*************************************************************************** +Scalar BTMyomersSeekSample(void *subsystem, Scalar voltage) +{ + Entity *entity = (Entity *)subsystem; + if (entity == 0 || !entity->IsDerivedFrom(Myomers::ClassDerivations)) + return 0.0f; + return ((Myomers *)entity)->SeekVoltageResponse(voltage); // @004b8f94 } diff --git a/game/reconstructed/myomers.hpp b/game/reconstructed/myomers.hpp index 60ca9cd..0ef5416 100644 --- a/game/reconstructed/myomers.hpp +++ b/game/reconstructed/myomers.hpp @@ -245,10 +245,15 @@ class Mech; HandleMessage(int message); // slot 9, @004b8a8c (-> PoweredSubsystem @004b0efc) void ResetToInitialState(Logical powered = True); // slot 10, @004b8aa4 (-> PoweredSubsystem @004b0e6c) - // slot 15 (PoweredSubsystem base @004b1780) -- normalised speed/gauge readout - // (output km/h scaled to 350): GetSpeedReading @004b8f94 + // slot 15 (PoweredSubsystem base @004b1780) -- the SeekVoltageGraph's + // voltage-response sampler (issue #11: the shared subsystem + // vtbl+0x3C slot the graph's Execute @004c6934 calls; Emitter's + // analog is @004bb42c): sqrt(AvailableOutput(v)*3.6 / 350) + // -- normalised sqrt of the drive speed in km/h against a + // 350 km/h full scale. (Renamed from the best-effort + // "GetSpeedReading"; FUN_004dd138 is sqrt, not fabs.) Scalar - GetSpeedReading(Scalar input_voltage); // slot 15, @004b8f94 (name best-effort) + SeekVoltageResponse(Scalar input_voltage); // slot 15, @004b8f94 // slot 16 (PoweredSubsystem::IsSourceShorted base @004b0b5c) -- Myomers // requires the source to supply at least the selected seek voltage // AND be in the Ready (==2) state. diff --git a/game/reconstructed/powersub.cpp b/game/reconstructed/powersub.cpp index fdf9041..4bd0696 100644 --- a/game/reconstructed/powersub.cpp +++ b/game/reconstructed/powersub.cpp @@ -1449,3 +1449,18 @@ bool BTGetSubsystemAuxScreen(Subsystem *sub, int *screen, int *placement, char * if (label64 != NULL) strcpy(label64, ps->auxScreenLabel); // +0x10C return true; } + +// +// issue #11 bridge -- the SeekVoltageGraph's destroyed test: +// *(subsystem+0x40) == 1 in the binary (@004c6934) == MechSubsystem:: +// simulationState == 1 (Destroyed). Named-member read through the real type +// (databinding rule); every subsystem that owns a graph is MechSubsystem- +// derived (Emitter/PPC/Myomers), so the PoweredSubsystem gate is sufficient +// and a non-match reads "not destroyed". +// +int BTSubsystemDestroyed(Subsystem *sub) +{ + if (!BTIsPoweredSubsystem(sub)) + return 0; + return (((PoweredSubsystem *)sub)->GetSimulationState() == 1) ? 1 : 0; // +0x40 == 1 +}