docs: record the gauge attribute-wave (mechanism + 4 increments) + durable RE tools

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) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-06 18:52:53 -05:00
co-authored by Claude Opus 4.8
parent a2ee1181e6
commit c2c4ab1f67
4 changed files with 190 additions and 0 deletions
+23
View File
@@ -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 `<Name>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 <VA> <len>`
(recover FPU math Ghidra drops — `FUN_004dcd94`=round, `FUN_004dcd00`=fabs) + `tools/vtdump.py <vtVA> <n>`
(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.
+78
View File
@@ -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 `<Name>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 <VA> <len>` (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 <vtableVA> <n>`; 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`.
+59
View File
@@ -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("<I", data, 0x3C)[0]
coff = e_lfanew + 4
num_sec = struct.unpack_from("<H", data, coff+2)[0]
opt_size = struct.unpack_from("<H", data, coff+16)[0]
opt = coff + 20
image_base = struct.unpack_from("<I", data, opt+28)[0]
sec_tbl = opt + opt_size
secs = []
for i in range(num_sec):
off = sec_tbl + i*40
vsize, va, rawsize, rawptr = struct.unpack_from("<IIII", data, off+8)
secs.append((va, vsize, rawptr, rawsize))
def va_to_off(va):
rva = va - image_base
for sva, vsize, rawptr, rawsize in secs:
if sva <= rva < sva + max(vsize, rawsize):
return rawptr + (rva - sva)
return None
def read_va(va, n):
o = va_to_off(va)
return data[o:o+n] if o is not None else None
def fconst(va):
b = read_va(va, 4)
return struct.unpack("<f", b)[0] if b else None
FN = int(sys.argv[1], 16)
LEN = int(sys.argv[2], 16) if len(sys.argv) > 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
+30
View File
@@ -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("<I", data, 0x3C)[0]
coff = e_lfanew + 4
num_sec = struct.unpack_from("<H", data, coff+2)[0]
opt_size = struct.unpack_from("<H", data, coff+16)[0]
opt = coff + 20
image_base = struct.unpack_from("<I", data, opt+28)[0]
sec_tbl = opt + opt_size
secs = []
for i in range(num_sec):
off = sec_tbl + i*40
vsize, va, rawsize, rawptr = struct.unpack_from("<IIII", data, off+8)
secs.append((va, vsize, rawptr, rawsize))
def va_to_off(va):
rva = va - image_base
for sva, vsize, rawptr, rawsize in secs:
if sva <= rva < sva + max(vsize, rawsize):
return rawptr + (rva - sva)
return None
VT = int(sys.argv[1], 16)
N = int(sys.argv[2]) if len(sys.argv) > 2 else 32
o = va_to_off(VT)
print(f"vtable @0x{VT:x}:")
for i in range(N):
fp = struct.unpack_from("<I", data, o + i*4)[0]
tag = ""
if 0x4c2f94 <= fp <= 0x4c6771: tag = " <-- BT btl4gaug fn"
print(f" [+0x{i*4:02x}] slot{i:2d} = 0x{fp:x}{tag}")