From c2c4ab1f6796f031b73e4fad5e80331ba6bda5ea Mon Sep 17 00:00:00 2001 From: arcattack Date: Mon, 6 Jul 2026 18:52:53 -0500 Subject: [PATCH] docs: record the gauge attribute-wave (mechanism + 4 increments) + durable RE tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the cockpit-gauge attribute-pointer system in GAUGE_COMPOSITE.md (§Attribute wave) and CLAUDE.md §10: how a Subsystem/Attribute or bare-Attribute binding resolves (ParseAttribute -> FindSubsystem/GetName -> GetAttributePointer -> activeAttributeIndex), what a class must publish (AttributeID enum + AttributePointers[] + GetAttributeIndex chained + DefaultData wiring), the DENSE-TABLE HAZARD (Build leaves gap slots uninitialized, Find strcmps every slot -> tables must be a dense prefix), and the base-primitive vs BT-specific widget split. Records the 4 committed increments (HeatSink table, Mech table, vertBar, segmentArcRatio) and the follow-ups (oneOfSeveralPixInt/map/PlayerStatus/ vehicleSubSystems, the #if0'd HeatSink-bank AmbientTemperature, the Reservoir coolantCapacity shadow). Promote the two reverse-engineering helpers from the session scratchpad into tools/ (durable): disas2.py (capstone + PE parse -- recovers x87 float math Ghidra drops; FUN_004dcd94=round, FUN_004dcd00=fabs) and vtdump.py (dump a class vtable to find an override the assert-anchored decomp never exported, e.g. SegmentArcRatio::Execute). Doc references updated scratchpad/ -> tools/. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 23 ++++++++++++ docs/GAUGE_COMPOSITE.md | 78 +++++++++++++++++++++++++++++++++++++++++ tools/disas2.py | 59 +++++++++++++++++++++++++++++++ tools/vtdump.py | 30 ++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 tools/disas2.py create mode 100644 tools/vtdump.py diff --git a/CLAUDE.md b/CLAUDE.md index 14657b2..8d8d227 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -696,6 +696,29 @@ depth SMALLER than the RT is INVALID → all draws silently fail (window shows t unbind depth (Z is off anyway), restore after. Pod `BuildWindows`/`Update` path byte-unchanged. REMAINING = the widget recon (above) + the pod MFD port-name fallback. +**✅✅ GAUGE WIDGET RECONSTRUCTION — the cockpit instruments now read LIVE game state (attribute wave DONE; +full mechanism + technique in `docs/GAUGE_COMPOSITE.md` §Attribute wave).** After Milestone C put all 6 +surfaces on screen (drawing NULL), two waves made them read real data. (a) The **ColorMapper family + headingPointer** +(prior session): the palette-tint schematics (`cmHeat`/`cmCrit`/`cmArmor`/`colorMapperMultiArmor`) + the rotating +compass, each a reconstructed BT gauge class registered in `BTL4MethodDescription[]` (btl4grnd.cpp). (b) The +**attribute wave** (this session, 4 commits): gauges bind to game state by NAME via the engine attribute-pointer +system — the config's `Subsystem/Attribute` (e.g. `HeatSink/CurrentTemperature`) or bare `Attribute` (e.g. +`LinearSpeed`) resolves through `ParseAttribute`→`FindSubsystem`(stricmp on `GetName()`)→`GetAttributePointer`→ +the class's `activeAttributeIndex`. A class publishes attributes by declaring an `AttributeID` enum + a +`static AttributePointers[]` (`ATTRIBUTE_ENTRY`) + `GetAttributeIndex()` chained to its parent, and passing +`GetAttributeIndex()` to its `DefaultData`. **⚠ DENSE-TABLE HAZARD (systemic): `AttributeIndexSet::Build` leaves +gap slots uninitialized and `Find` strcmps EVERY slot → a published table MUST be a dense prefix from the parent's +`NextAttributeID`; fill gaps with a shared read-only pad member.** Done: **HeatSink table** (`CurrentTemperature` +→ Heat readout shows 77), **Mech table** (dense prefix 0x15..0x21, `LinearSpeed` → SPEED readout shows ~225), +**vertBar** (coolant bars; x87 pixel math recovered by disassembly), **segmentArcRatio** (speed arc; thin subclass +of engine `SegmentArc`). **Base primitives `numeric`/`numericSpeed`/`digitalClock`/`rankAndScore` are ENGINE +(L4GAUGE.cpp) — only need the data; `vertBar`/`segmentArcRatio`/`oneOfSeveralPixInt`/`map`/`PlayerStatus`/ +`vehicleSubSystems` are BT-specific (reconstruct + register).** Technique added: `tools/disas2.py ` +(recover FPU math Ghidra drops — `FUN_004dcd94`=round, `FUN_004dcd00`=fabs) + `tools/vtdump.py ` +(find a vtable override the assert-anchored decomp didn't export). REMAINING: `oneOfSeveralPixInt`/`map`/ +`PlayerStatus`/`vehicleSubSystems` (task #13) + the aggregate HeatSink-bank `AmbientTemperature` (`#if 0`'d 0xBBE) ++ the Reservoir `coolantCapacity` shadow. + **Alternate route considered:** run original `btrel410.exe` under DOSBox — **blocked**: it needs the VPX/IG board DOSBox can't emulate; bypassing it = the shim work anyway. diff --git a/docs/GAUGE_COMPOSITE.md b/docs/GAUGE_COMPOSITE.md index 77707bc..369719d 100644 --- a/docs/GAUGE_COMPOSITE.md +++ b/docs/GAUGE_COMPOSITE.md @@ -344,3 +344,81 @@ reads the public `currentTemperature`). Verified: parses, builds, all subsystems `segmentArcRatio` speed/`GeneratorCluster` — need `AttributePointers[]` on Mech/HeatableSubsystem, a separate pass); the XL items (`map`/`vehicleSubSystems`/`PlayerStatus`) last. The POD path needs the FULL 23-entry table with real ctors (the dev tolerant-skip is the on-ramp, not the finish). + +## Attribute wave DONE (2026-07) — the cockpit numeric/bar/arc gauges read LIVE data + +The gauge widgets bind to game state by NAME through the engine attribute-pointer system, mapped by the +`attr-wave-decomp-map` workflow and implemented as 4 committed increments. **This is the mechanism every +data-driven gauge depends on — read it before reconstructing any further widget.** + +### How a gauge binding resolves (engine, already complete) +The config binds either `Subsystem/Attribute` (e.g. `HeatSink/CurrentTemperature`) or a bare `Attribute` +(e.g. `LinearSpeed`). `GAUGREND.cpp ParseAttribute` (~2130) splits on `/`: +- `Subsystem/Attr` → `entity->FindSubsystem(subName)` (ENTITY.cpp:631 — `stricmp` on each subsystem's + `GetName()`) → `subsystem->GetAttributePointer(attrName)`. +- bare `Attr` → `entity->GetAttributePointer(name)`. +`Simulation::GetAttributePointer(name)` (SIMULATE.cpp:427) = `GetSharedData()->activeAttributeIndex-> +Find(name)` then `&(this->*attr)`. + +### What a class must publish (the per-class work) +1. An `AttributeID` enum chained from the parent (`= Parent::NextAttributeID … NextAttributeID`). +2. `static const IndexEntry AttributePointers[]` via `ATTRIBUTE_ENTRY(Class,Name,member)` (SIMULATE.h:284). +3. `static AttributeIndexSet& GetAttributeIndex()` chained to the parent (pattern: MOVER.cpp:83). +4. The class's `DefaultData`/`SharedData` ctor must PASS `GetAttributeIndex()` (MOVER.cpp:28-35) — this + sets `activeAttributeIndex`. Bind `ATTRIBUTE_ENTRY` to a REAL named member (compiled ptr-to-member; + NEVER a raw binary offset — the compiled layout != binary). The engine `Mover`/`JointedMover`/`Entity` + tables are dense and real; chain to them. + +### ⚠ DENSE-TABLE HAZARD (systemic-bug class — verified in SIMULATE.cpp:565/663) +`AttributeIndexSet::Build` sizes the merged index to `max(entryID)`, copies the inherited slots, places own +entries at `[id-1]`, and **leaves gap slots uninitialized** (`new IndexEntry[n]` on a POD = garbage +`entryName`). `Find(name)` then `strcmp`s EVERY slot → a gap between the parent's `NextAttributeID` and the +highest id you publish = strcmp on a garbage pointer = AV. So a published table MUST be a **dense prefix** +from the parent's `NextAttributeID` up to the max id it includes. You cannot ship just the ids you need: +fill the gaps (bind not-yet-needed ids to a shared read-only pad member). + +### Base engine primitives vs BT-specific widgets (don't reconstruct the base ones) +- **Engine base primitives (already registered + drawing; only need the DATA):** `numeric`, `numericSpeed`, + `digitalClock`, `rankAndScore`, `bgPixelMap`, `bgBitMap` (L4GAUGE.cpp). `digitalClock`'s `time` arg is a + FORMAT enum, NOT an attribute — the value comes from the mission clock inside the widget. +- **BT-specific (need reconstruction + registration in `BTL4MethodDescription[]`):** `vertBar`, + `segmentArcRatio`, `oneOfSeveralPixInt`, `GeneratorCluster`, `map` (radar), `PlayerStatus`, + `vehicleSubSystems`, plus the done ColorMapper family + headingPointer. + +### Committed increments (each verified live in the dev gauge window; combat un-regressed; heap-clean) +1. **HeatSink attribute table** (heat.{hpp,cpp}) — `CoolantMass`→coolantLevel, `CoolantCapacity`→ + thermalCapacity, `CurrentTemperature`→currentTemperature. Condenser/Reservoir inherit it (both derive + from HeatSink). → Heat surface temp readout `S` now shows **77** (was 0). +2. **Mech attribute table** (mech.{hpp,cpp}, mech4.cpp) — full enum 0x15..0x38 declared; DENSE PREFIX + 0x15..0x21 published (gap ids share `attrPad`; `CurrentSpeed`→legCycleSpeed, `MaxRunSpeed`→ + reverseStrideLength, `LinearSpeed`→new `linearSpeed` member set to |adv|/dt per frame). Chained to + `JointedMover::GetAttributeIndex()`. → radar SPEED readout now shows **~225** (was 0). +3. **vertBar** (VertTwoPartBar, btl4gaug) — the COOLANT bars. x87 pixel math recovered by disassembly + (`FUN_004dcd94`=`(int)ROUND(ST0)`): `warnPix/valPix = clamp(round(height*low_or_value/high + 0.5),0, + height)`; tile-blit `[0,warnPix)`, fill `[warnPix,valPix)`, clear `[valPix,height)`. +4. **segmentArcRatio** (SegmentArcRatio, btl4gaug) — the SPEED arc. Thin subclass of engine `SegmentArc`; + Execute override (`FUN_004dcd00`=fabs): `currentValue = clamp(|num/den * segmentSpan|,0,1)` then + `SegmentArc::Execute()`. `segmentSpan = (Scalar)(|n|/(|n|-1))*0.75f` (int division → 0.75 for 36 segs). + +### Reconstruction-technique additions (durable) +- **x87 float math Ghidra drops** (`FUN_004dcd94()`/`FUN_004dcd00()` show no args = FPU-stack operands): + disassemble the fn with `tools/disas2.py ` (capstone + PE parse; annotates `.data` float + constants + known call targets). `FUN_004dcd94`=round-to-int, `FUN_004dcd00`=fabs. +- **Find a vtable override the assert-anchored decomp didn't export** (e.g. SegmentArcRatio::Execute wasn't + in `part_*.c`): dump the class vtable with `tools/vtdump.py `; a slot pointing into the + module's own code range (btl4gaug = 0x4c2f94..0x4c6771) is the override → disassemble it. +- **GraphicsView method vtable map** (GRAPH2D.h): +0x08 SetPositionWithinPort, +0x10 SetOrigin, +0x18 + SetColor, +0x24 MoveToAbsolute, +0x38 DrawThickLineToAbsolute, +0x48 DrawFilledRectangleToAbsolute. + Resource fetch = `renderer->warehousePointer->bitMapBin.Get/Release(name)` (FUN_00442aec/00442c12). + +### Deferred / follow-ups +- `HeatSink/AmbientTemperature` maps to `ambientTemperature`@0x1D4 on the AGGREGATE HeatSink-bank class + (0xBBE, ctor @4ae8d0) which is `#if 0`'d — reviving it + its own table is a separate task (Heat `A` + readout stays 0 until then). +- Reservoir shadow: `Reservoir::coolantCapacity`@0x128 shadows the inherited `thermalCapacity` the gauge + reads → `Reservoir/CoolantCapacity` reads the base default; fix = delete the shadow + write thermalCapacity + in the Reservoir ctor (bundle with the Reservoir coolant-bar verification). +- Remaining widgets (task #13): `oneOfSeveralPixInt` (button lamps — `ControlsMapper/DisplayMode` already + publishes; `DuckState`/`Searchlight/LightOn` need the Mech dense table extended to 0x37 + a Sensor/ + Searchlight table), `map` (radar — needs Mech RadarRange/LinearPos/AngularPos published + Sensor + RadarPercent), `PlayerStatus`, `vehicleSubSystems`. diff --git a/tools/disas2.py b/tools/disas2.py new file mode 100644 index 0000000..5617b21 --- /dev/null +++ b/tools/disas2.py @@ -0,0 +1,59 @@ +import struct, sys +from capstone import * + +PATH = r"C:\git\bt411\content\BTL4OPT.EXE" +data = open(PATH, "rb").read() +e_lfanew = struct.unpack_from(" 2 else 0x140 +code = read_va(FN, LEN) +md = Cs(CS_ARCH_X86, CS_MODE_32); md.detail = True +known = {"0x408328":"SinCos","0x4dcd94":"Round(ftol ST0)","0x40954c":"Quat->Euler", + "0x4700bc":"NumericDisplay::ctor","0x4700ac":"nameCopy","0x442aec":"bitMapBin.Get", + "0x442c12":"bitMapBin.Release","0x444818":"GraphicGauge::ctor","0x474855":"GaugeConnDirectOf::ctor", + "0x4c2ff8":"tiledBlit","0x402298":"operator new","0x4dcdec":"Round64"} +print(f"=== disasm @0x{FN:x} len=0x{LEN:x} ===") +for ins in md.disasm(code, FN): + line = f"0x{ins.address:x}: {ins.mnemonic:<7} {ins.op_str}" + for tok in ins.op_str.replace("[", " ").replace("]", " ").split(): + if tok.startswith("0x"): + try: + a = int(tok, 16) + if 0x4b0000 <= a <= 0x520000: + fv = fconst(a) + if fv is not None: + line += f" ; [0x{a:x}]={fv:g}f" + except ValueError: + pass + if ins.mnemonic == "call" and ins.op_str in known: + line += f" ; {known[ins.op_str]}" + print(line) + if ins.mnemonic == "ret": + break diff --git a/tools/vtdump.py b/tools/vtdump.py new file mode 100644 index 0000000..1403e62 --- /dev/null +++ b/tools/vtdump.py @@ -0,0 +1,30 @@ +import struct, sys +PATH = r"C:\git\bt411\content\BTL4OPT.EXE" +data = open(PATH, "rb").read() +e_lfanew = struct.unpack_from(" 2 else 32 +o = va_to_off(VT) +print(f"vtable @0x{VT:x}:") +for i in range(N): + fp = struct.unpack_from("