Files
BT412/docs/GAUGE_COMPOSITE.md
T
arcattackandClaude Fable 5 dd27238ceb Gauges: the CLOSEOUT wave -- radar pips, Myomers dedup, attribute parity, ledger sweep (tasks #14-#17)
TASK #17 -- AUTHENTIC RADAR SYMBOLOGY (the cross-blip stand-in retired).
The recon proved the 'missing pip raster set' never existed as BT game code:
pips are the ENGINE's L4GaugeImage vector-stroke system (T0 source in tree,
L4GAUIMA.cpp == FUN_0046f0c0 line-for-line), the 'pip table' is L4Warehouse::
gaugeImageBin keyed by Entity::resourceID, and BTL4.RES ships 110 type-0x12
shapes (every mech/vehicle/building/tree).  The six btl4rdr stubs are wired
to the real facilities: contacts draw their authentic model silhouettes with
LOD selection, the target gets the binary's 4px-inflated box highlight, and
player-name labels resolve via Mission::GetSmallNameBitmap (the prebuilt
64x16 egg rasters keyed by the player's bitmapindex).  DECODED: the invented
'VideoObject' was Entity::owningPlayer all along (+0x190; nameID =
playerBitmapIndex@0x1E0, target = BTPlayer::objectiveMech@0x284 -- new
bridge BTPlayerObjectiveMechOf); the 'LabelledEntity' class is Landmark
(cultural.h; label path dormant -- no landmark content ships, no runtime
landmarkID writer).  The L4GREND BT_DEV_GAUGES warehouse guard is removed
(its AV had a different culprit, below); resource type 18 corrected to
GaugeImageStream in decomp-reference (was mislabeled 'ModelList').

TASK #14 -- the Myomers ODR duplicate ELIMINATED: the powersub.cpp/hpp
'Myomers' (classID 0xBC3 -- actually Sensor) is retired whole; it duplicated
?DefaultData@Myomers@@ against the real class (dumpbin-verified) and /FORCE
picked the winner by link order.  The real Myomers (0xBC6) now chains
PoweredSubsystem's handler set (ids 4-8) and publishes its SEVEN binary
attributes (@00511588: SpeedEffect/Current/Recommended/Min/MaxSeekVoltage-
Index/SeekVoltage/OutputVoltage) -- the old empty unchained index starved
the Myomer engineering panel of every resolve.

TASK #16 -- attribute parity: MechWeapon publishes the FULL binary table
@0x511890 (11 entries; ids renumbered to binary truth -- the port aliases
had squatted the binary's DistanceToTarget/TargetWithinRange ids; the
streamed TriggerState 0x13 binding unchanged).  Binary names resolved two
TODO members: pipState -> estimatedReadyTime (attr 0x1A), and the EXT-model
flag is the binary's RearFiring (0x1B).  ThermalSight LightState published.
HUD (offset conflicts) + missile-side tables (id encoding suspect)
documented for a re-dump instead of publishing blind.
  THE CRASH THIS EXPOSED [T2, cdb-verified]: gotcha #11's dense-table gap
is a LATENT AV, not a guaranteed one -- the old table's 0x0D..0x12 gap
(task #5) survived on heap luck; the renumber reshuffled allocations and
Find() AV'd on a garbage entryName in WeaponCluster's PercentDone resolve.
Fixed with five named PAD entries (the mech.cpp attrPad idiom) + a
static_assert locking the pad base to PoweredSubsystem::NextAttributeID.
Gotcha #11 amended with the proof.

TASK #15 -- stale-ledger sweep: GAUGE_COMPOSITE ('composite not yet built',
Reservoir shadow, PlayerStatus/vehicleSubSystems 'remaining', valve-dormant-
until-0xBD3, sensor guard, the superseded 'Heat MFD near-static' reframe --
all banner-corrected), gauges-hud frontmatter, L4VB16 + powersub comments.

Verified live: 50/50 config attribute bindings resolve, 0 NULLs, 0 parse
skips, mech spawns and simulates 31/31 subsystems, no cross-blip fallbacks,
the pip cache fills through entity registration without the old guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 00:57:45 -05:00

55 KiB
Raw Blame History

Gauge / MFD render pipeline — map + dev-composite plan

Status (updated 2026-07-12): the dev composite is BUILT AND LIVE (Milestones A/B/C done — BT_DEV_GAUGES / BT_DEV_GAUGES_DOCK; all 6 MFD surfaces composite; 50/50 attr bindings, 0 parse-skips). The header below preserved the original PLAN framing; see the increments + Phase logs for what landed.

The draw model — CONFIRMED (the good news)

CPU-raster → texture-upload, DEVICE-INDEPENDENT. Every gauge widget (the radar included) is a software rasterizer that writes 16-bit R5G6B5 pixels into ONE shared CPU buffer (Video16BitBuffered::pixelBuffer, a PixelMap16, L4VB16.h:140). Logical surfaces are packed by BIT-PLANE into that one 640×480×16 buffer (each MFD = one bit mask; the radar/secondary = the low byte, palette-indexed). D3D is touched ONLY in SVGA16::Update (L4VB16.cpp:4041): LockRect a D3DPOOL_SYSTEMMEM staging texture → a CPU expand loop (palette-LUT for the radar, case 0; bit-plane→RGB-channel demux for the MFDs, case 1/2) → UnlockRect → UpdateTexture staging→D3DPOOL_DEFAULT → ONE fullscreen textured quad (TRIANGLEFAN) → Present, per surface on its own per-adapter device. So redirecting the content to a texture on the MAIN device is a small change: the entire raster stays byte-identical; only the upload target + the final blit move.

The 3 gotchas (why it's a multi-step build, not a one-liner)

  1. DORMANT in BT. BTL4GaugeRenderer passes L4GaugeRenderer(false, NULL,NULL,NULL) (btl4grnd.cpp:151) → NUMGAUGEWINDOWS=0; and MakeGaugeRenderer only builds anything if getenv("L4GAUGE") (btl4app.cpp:353). Both must change to wake it. With 0 surfaces, SVGA16::Update may index an empty mSurfaces[] → guard needed.
  2. RP↔BT PORT-NAME MISMATCH. SVGA16::Update hardcodes the Red-Planet MFD port names auxUL2/auxC/auxUR2/auxLL/auxLR (L4VB16.cpp:4056-4061), but BT's content/GAUGE/L4GAUGE.CFG names them Heat/Comm/Mfd1/Mfd2/Mfd3 (:4395-4410). GetGraphicsPort returns NULL for the RP names → the MFD branch early-outs ("No MFDs to draw"). ONLY the sec/radar surface matches today — the 5 MFDs render nothing until the names are reconciled.
  3. CROSS-DEVICE + STATE. Folding onto the main device means the gauge blit shares the main render state (save/restore around it) + an ORDERING concern: is l4_application->GetVideoRenderer()->GetDevice() valid when the gauge renderer builds during Application::Initialize? Verify build order or defer texture creation.

Architecture options (dev)

  • (A) SVGA16 dev-composite branch. In SVGA16::BuildWindows/Update, when a dev-composite flag is set, use the MAIN device for the staging/default textures, skip the per-adapter device/window/Present; the main renderer composites the DEFAULT textures as insets. Reuses the expand loops verbatim; branches the pod code (guard on the flag → inert on pod).
  • (B) Bypass SVGA16. Leave the SVGA16 pod path untouched; add a composite pass in the main renderer that reads the shared pixelBuffer + palette directly and runs the expand + inset blit itself. Best protects the pod path; re-implements the expand + needs pixelBuffer/palette accessors on Video16BitBuffered/SVGA16.
  • RECOMMEND (B) for the beachhead (pod path untouched); fall back to (A) if reusing the expand loops wholesale is preferred. Decide this with the operator before building — it's the main design fork.

Beachhead (Step 1) — one surface visible on dev

Goal: the secondary/radar surface composited as an INSET in the existing 800×600 window (no 2nd window yet). It's the one surface that works WITHOUT the port-name fix, so it proves the whole chain end-to-end.

  1. Wake it via a dev-composite opt-in (e.g. BT_DEV_GAUGES env, default OFF so DEV/pod defaults are unaffected; or gate on -platform pod on a dev box). When on: set L4GAUGE (so MakeGaugeRenderer builds)
    • set the composite flag. Guard SVGA16::Update for 0 surfaces (no crash).
  2. Raster: the widgets already raster into pixelBuffer (driven by GaugeRenderer::ProcessOneActiveGauge).
  3. Composite: each frame, in the main renderer's present, upload the secondary-expanded pixels to a main-device texture + draw an inset quad (XYZRHW|TEX1), main render state saved/restored. Reach the buffer via GetGaugeRenderer().
  • Verify: the radar/secondary inset appears; DEV default (no BT_DEV_GAUGES) un-regressed; pod path untouched; 0 crashes / 0 heap.

Follow-ups (after the beachhead)

  • Step 2 — reconcile the MFD port names (rename in L4GAUGE.CFG or in SVGA16::Update) → the 5 MFDs render.
  • Step 3 — the 2-window layout (3D-view window + an instrument-panel window tiling radar + the 5 MFDs).
  • Confirm the MFD widget CONTENT is actually fed each frame (entity→gauge routing BTL4GaugeRenderer::NotifyOfNewInterestingEntity → the L4Warehouse gauge-image bin).

Key files / hooks

  • engine/MUNGA_L4/L4VB16.cpp: SVGA16::Update:4041 (the CPU→texture→blit crux; expand loops :4124-4191), SVGA16::BuildWindows:101 (per-surface device/window/texture — the pod path), Video16BitBuffered::Draw* (:761/847/1607/1839, the software raster into pixelBuffer), pixelBuffer L4VB16.h:140, RP port names :4056-4061.
  • engine/MUNGA_L4/L4GREND.cpp: L4GaugeRenderer ctor:57 (builds SVGA16 from L4GAUGE.INI), ExecuteBackgroundDisplayUpdate:369 (calls graphicsDisplay->Update), BuildGraphicsPort:485 (every port shares ONE pixelBuffer, bit-plane per port).
  • engine/MUNGA/GAUGREND.cpp: ExecuteForeground:3556 / ProcessOneActiveGauge:3836 (draw-all-gauges into pixelBuffer).
  • game/reconstructed/btl4grnd.cpp: ctor:151 (the un-dormant point — NULL indices), NotifyOfNewInterestingEntity:250 (routes GaugeImageStream type-0x12 entities to moving/static bins).
  • game/reconstructed/btl4rdr.cpp: MapDisplay (the radar; draws into the secondary surface).
  • Main device: l4_application->GetVideoRenderer()->GetDevice() (L4VIDEO.h:376).
  • Config: content/GAUGE/L4GAUGE.CFG (port→bitmask map, MechInit :4395-4410), L4GAUGE.INI (640×480×16 page).

Surface → content map (for the 2-window layout)

  • Surface 0 = SECONDARY / radar (640×480, palette-indexed low byte sec 0x003F + overlay 0x00C0): the radar MapDisplay + secondary instruments (message board / heading / speed arc / armor-critical-heat maps).
  • Surface 1(+2) = the 5 MFDs (bit-planes 0x01000x8000 of the shared buffer, demuxed to R/G/B channels; pod spans them as one 1280×480): Mfd1=LL, Mfd2=UC, Mfd3=LR, Heat=UL, Comm=UR (+ Eng1-3).
  • Plasma (CFG port 10, L4PLASMA=com2) is an EXTERNAL serial annunciator — not a D3D surface.

Progress log

Milestone A DONE (2026-07) — the gauge renderer is woken + boots STABLY on a dev box (opt-in BT_DEV_GAUGES, default OFF; default DEV + pod paths verified un-regressed — DEV combat DESTROYED, 0 crashes). Option B chosen. BT_DEV_GAUGES sets L4GAUGE (so MakeGaugeRenderer builds the renderer) and SVGA16 does NO per-surface D3D (a file-static DevGaugeComposite() gate forces the no-surface path in BuildWindows + short-circuits Update/Refresh). ⚠ Reality-check: FindBestAdapterIndices hands the gauge renderer 3 non-null indices even on a dev box (all the primary adapter), so the earlier "passes NULL → 0 surfaces" assumption was wrong — the gate is keyed on BT_DEV_GAUGES, not the count.

Waking the (never-exercised) gauge subsystem exposed a cascade of 4 dormant-path bugs, each guarded:

  1. SVGA16::Update (L4VB16.cpp) — indexed empty mSurfaces[]; guarded DevGaugeComposite()||NUM<=0.
  2. SVGA16::Refresh — same; guarded.
  3. LBE4ControlsManager::MakeLinkedLamp (L4CTRL.cpp:2057) — GetGaugeRenderer()->GetLampManager() returns NULL (recon gap in BTL4GaugeRenderer); guarded (skip the linked lamp when null).
  4. L4GaugeRenderer::NotifyOfNewInterestingEntity (L4GREND.cpp:440) — warehousePointer->gaugeImageBin has a garbage internal chain (recon shadow of the warehouse); guarded (skip the per-mech gauge-IMAGE cache under BT_DEV_GAUGES).

KEY FINDING: the gauge subsystem's RECONSTRUCTION IS INCOMPLETE. It was dormant/never-exercised, so these latent recon bugs (lamp-manager offset, warehouse init) only surfaced on waking it. It now BOOTS stably, but whether the widgets raster MEANINGFUL content into pixelBuffer is UNVERIFIED until the composite (Milestone B) draws it. The per-mech gauge IMAGES (armor diagrams via the warehouse) are currently skipped; the radar/secondary map + instruments read the mech directly and may render partially. So the beachhead's real question shifted from "composite a working buffer" to "how complete is the gauge recon." FAITHFUL FOLLOW-UPS: fix the BTL4GaugeRenderer lamp-manager + warehouse reconstruction (shadowed members) so per-mech gauges + lamps work (needed for real MFD content anyway).

Milestone B DONE (2026-07) — THE SECONDARY/RADAR SURFACE COMPOSITES LIVE ON A DEV BOX (opt-in BT_DEV_GAUGES; default DEV + pod paths verified un-regressed). The inset in the bottom-left of the 800×600 window renders the authentic BT cockpit secondary MFD: the radar/tactical grid (SCALE/SECTOR + crosshair), the SPEED / HEADING / MAGNETIC / PROP dials, and the color-coded ARMOR DAMAGE mech schematic (green intact / red damaged) — real content (nzSec=27247 non-zero secondary-plane pixels), not garbage. The composite chain: SVGA16::DrawDevInset(device, secMask, paletteID) (L4VB16.cpp) lazily creates a MANAGED R5G6B5 640×480 texture on the MAIN device, palette-expands the secondary plane into it (mirrors SVGA16::Update case 0), and draws an XYZRHW|TEX1 inset quad; the free entry BTDrawGaugeInset(mDevice) reaches application->GetGaugeRenderer()->GetGraphicsPort("sec")->graphicsDisplay and is called from DPLRenderer::ExecuteImplementation as the LAST draw before EndScene (no state save/restore needed).

THREE reconstruction bugs had to be fixed to get from "woken but empty" to "real content" (each a genuine faithful fix or a clearly-gated dev accommodation):

  1. MakeGaugeRenderer OVERRIDE SIGNATURE MISMATCH (real bug — FIXED). The reconstructed BTL4Application::MakeGaugeRenderer() took NO args, but the 2007 WinTesla engine had WIDENED the base virtual to MakeGaugeRenderer(int* secondaryIndex, int* aux1, int* aux2) (called from Application:: Initialize, APP.cpp:382). A no-arg method only HIDES the virtual — it never overrides it — so the engine ran L4Application::MakeGaugeRenderer instead and built a base L4GaugeRenderer whose ctor never calls BuildConfigurationFilegauge\l4gauge.cfg was NEVER PARSED → empty symbol table → the observed GaugeInterpreter: undefined label 'bhk1Init' → no ports, no gauges. FIX: match the 3-arg signature so it truly overrides (args ignored — BT is fullscreen/no-aux, the BTL4GaugeRenderer ctor hardcodes false,NULL,NULL,NULL). Same class of bug as the BTL4GaugeRenderer(false,NULL,NULL,NULL) ctor fix: reconstruction written against the ORIGINAL signature, compiled against the WIDER 2007 engine virtual. (btl4app.cpp / btl4app.hpp.) Label facts: the label = mission->GetGameModel() + "Init" = bhk1Init (lowercase, no capitalization — the binary FUN_0046ff64 does a plain copy + Str_Cat); the CFG defines Bhk1Init but the symbol-table lookup is stricmp (case-insensitive), so the case doesn't matter — the only reason it was "undefined" was the empty table. MechInit (CFG:4390) is the shared macro every <Mech>Init calls that builds the sec/overlay/Mfd*/Heat/Comm PORTS via the base configure primitive.
  2. Gauge-config parse HUNG on undefined primitives (dev accommodation, gated). The BT-specific gauge primitive table BTL4MethodDescription (btl4grnd.cpp) is still a STUB (only the chain-to-base link; the BT gauge widget classes like PlayerStatus aren't reconstructed), so GaugeInterpreter::GetProcedureBody hits an unknown primitive → ReportParsingErrorFail() → a MODAL assert dialog that FREEZES the game mid-parse (cdb confirmed: MessageBoxWabortReportParsingErrorGetProcedureBody). FIX (GAUGREND.cpp, gated BT_DEV_GAUGES): when a primitive doesn't resolve, SKIP its parameter list (paren depth walk + UngetPreviousToken) and continue — the parse then registers ALL labels (Bhk1InitMechInit) and runs the base configure primitives that build the PORTS. The base configure (MakeConfigMethodDescription) IS in L4MethodDescription, so the sec/radar SURFACE builds even though the BT widgets are skipped. Pod keeps the strict Fail (it needs a complete real table).
  3. Gauge widgets AV on NULL data bindings (dev accommodation, gated). Two flavors: (a) value-bound gauges (NumericDisplayScalar) get value_pointer = parameterList[8].data.attributePointer = NULL (the mech data binding isn't reconstructed; the source even comments "(Scalar *) is VERY dangerous!") → the GaugeConnectionDirectOf<float> ctor derefs NULL (gauge.h:198). FIX: the ctor binds a NULL source to a static zero (gated). (b) game-state gauges (RankAndScore::Execute) directly deref unreconstructed mission/player data. FIX: Gauge::Update runs Execute() under a SEH wrapper Gauge::GuardedExecute() (a separate fn — __try/__except can't share a frame with the ChainIteratorOf unwinding local); a gauge that faults once is Disable(True)d (rate=0) so it never retries. Both gated BT_DEV_GAUGES.

⚠ KEY TAKEAWAY: the gauge subsystem is now PROVEN end-to-end on dev (renderer→parse→port config→raster→ palette-expand→composite), and the gauges whose data binds correctly (radar/armor/dials) show REAL content. The remaining work is the BT gauge WIDGET reconstruction — the BTL4MethodDescription method table (the BT gauge classes: PlayerStatus + the ~16 recovered methodDescription entries) AND the gauge→game-state DATA BINDINGS (why RankAndScore / some NumericDisplayScalar resolve NULL). Those are what the dev accommodations (skip/guard) stand in for. Files touched: L4VB16.{h,cpp} (DrawDevInset/BTDrawGaugeInset), L4VIDEO.cpp (the EndScene hook), btl4app.{cpp,hpp} (the override fix), GAUGREND.cpp (tolerant skip), gauge.h + GAUGE.{h,cpp} (NULL-source + SEH guards).

Milestone C DONE (2026-07) — ALL SIX instrument surfaces in a SEPARATE dev window

The beachhead's single-sec-inset is now a full 6-surface compositor in its own top-level window (the default under BT_DEV_GAUGES; BT_DEV_GAUGES_DOCK=1 docks the panel into the main window instead). The 960×384 gauge window tiles all six pod instrument screens with authentic content: Heat (COOLANT/BALANCE/ RES + condenser & temp-leak gauges), Comm (KILLS/DEATHS/SELECT TARGET scoreboard), Mfd1/Mfd2/Mfd3 (DISPLAY/PROGRAM/NEAREST/TEMP-STATUS frames), and the color radar (SCALE grid + speed/heading dials + ARMOR DAMAGE schematic). Verified live: gauge window renders all 6; main 800×600 3D view is un-occluded; default DEV (no gauges) un-regressed (TARGET DESTROYED after 8 hits, 0 crashes).

Architecture (mapped by the mfd-multisurface-map workflow, then implemented):

  • One buffer, six bit-plane views. All gauge ports share ONE SVGA16/pixelBuffer; a "surface" is a MASK over that one 640×480×16 buffer. The compositor reaches the SVGA16 once (via any port) and extracts each surface by its own plane mask. BT plane map (L4GAUGE.CFG MechInit @4395): sec=radar (palette, low byte) · Heat=UL (0x4000) · Mfd2=UC (0x0400) · Comm=UR (0x8000) · Mfd1=LL (0x0100) · Mfd3=LR (0x1000). Eng1/2/3 are the engineering-mode alt planes of UC/LL/LR, not extra monitors.
  • No port-name reconcile needed on the dev path. The RP names auxUL2/auxC/… hardcoded in SVGA16::Update only matter to the POD demux (a deferred pod-only follow-up); our compositor fetches the BT names (Heat/Comm/Mfd1/Mfd2/Mfd3) directly via GetGraphicsPort (stricmp).
  • Two extract kernels in SVGA16::DrawDevSurface: palette-LUT (sec/radar, == Update case 0) and mono bit-plane → tint ((word & mask) ? tint : 0, the reduced core of Update cases 1/2; MFD masks are single bits so no DWORD SIMD/pack). BTDrawGaugeSurfaces iterates a {name,tint,cellRect} table over all 6.
  • Separate window = one ADDITIONAL SWAP CHAIN on the existing device (no 2nd D3D device — textures/verts are shared). BTGaugeWindowRenderAndPresent (called AFTER the main EndScene, before the main Present): GetBackBufferSetRenderTargetSetDepthStencilSurface(NULL) → Clear → BeginScene → 6 tiles → EndScene → restore RT+DS → swap->Present. The pod's own per-surface BuildWindows/Update path is byte-unchanged (dev mode already NULLs those objects).
  • ⚠ THE KEY BUG (cost a cycle): depth-stencil size mismatch. The main 800×600 depth surface stays bound when you SetRenderTarget to the 960×384 gauge backbuffer; a bound depth surface SMALLER than the render target is INVALID → every draw silently fails (window showed the Clear color but no tiles). Fix: SetDepthStencilSurface(NULL) before the gauge draws (Z is disabled anyway), restore after. Symptom to remember: render-to-a-second-swap-chain shows the clear color but no geometry ⇒ check the bound depth size.

Content reality (unchanged from Milestone B): the surfaces show the authored cockpit FRAME art + a few base-table gauges (heat numerics, radar) — the live animated MFD widgets are BT-specific gauge classes not yet reconstructed (parse-skipped). The separate window is now the live viewer for that widget-recon work.

Follow-ups (after Milestone C)

  • Widget recon (the real fix, the big remaining workstream): populate BTL4MethodDescription (give each BT gauge class a methodDescription) + wire the gauge→game-state Execute() data bindings, so the skipped/guarded widgets (map/GeneratorCluster/vehicleSubSystems/pilotList/cmArmor/heat clusters) render live data. The Milestone-C window shows them coming online as they're reconstructed.
  • Pod MFD port-name reconcile (pod-only): a ~5-line positional dual-name fallback in SVGA16::Update (UL = GetGraphicsPort("auxUL2"); if(!UL) UL = GetGraphicsPort("Heat"); …) so BT MFDs reach the pod's real monitors. NOT a CFG rename (the CFG uses BT names pervasively + MUNGA_L4 is shared with RP's aux names).
  • Polish: overlay-plane compositing over sec; a pod-accurate RGB-packing toggle; texture atlas (one lock/upload); device-reset recreate hardening for the additional swap chain.

Widget reconstruction — the real fix (in progress; mapped by bt-gauge-widget-recon-map)

Why the surfaces show only frames: the BT gauge method table BTL4MethodDescription[] (btl4grnd.cpp) was a chain-only STUB, and no BT gauge class registered a methodDescription, so every BT-specific gauge keyword (cmHeat/headingPointer/map/vertBar/GeneratorCluster/…) failed table lookup and was parse-skipped. The binary's real table @0x51c910 has 23 gauge entries; 19 keywords are actually invoked by this CFG. The reconstructed classes exist in btl4gaug/gau2/gau3.cpp but most have prose-only (undefined) ctors/Execute/connection-feeds — only a few chains are fully real code.

The registration recipe (per widget, mirrors the engine — NumericDisplayScalar, L4GAUGE.cpp:542):

  1. Add static MethodDescription methodDescription; to the class in its .hpp.
  2. Define <Class>::methodDescription = { "cfgKeyword", <Class>::Make, { {type,NULL}…, PARAMETER_DESCRIPTION_END } } in the class's .cpp. The param-type list MUST match the binary's .data exactly — the parser reads one token per entry to the typeEmpty terminator (GAUGREND.cpp:1458); a wrong count desyncs the parse.
  3. Rewire Make to read methodDescription.parameterList[] (the interpreter restores each instance's parsed params there before calling Make) instead of placeholder statics; the runtime port arg (display_port_index) is the graphics port, NOT a param slot.
  4. Register &<Class>::methodDescription in BTL4MethodDescription[] (btl4grnd.cpp), chain link LAST.

⚠ THE /FORCE TRAP (the load-bearing gotcha): register a widget ONLY when its whole Make→ctor→feed→every- vtable-slot chain is REAL code. A registered class instantiates its vtable; any prose-only/undefined virtual (dtor, BecameActive, a connection ctor) is /FORCE-stubbed to the image base and AVs the moment it's called. The build "succeeds" (exit 0) and lies — grep the link log for unresolved external on the class's symbols (filter out the known dead mech3.obj DefaultData/CreateStreamedSubsystem offline-factory externals).

Increment 1 — cmHeat (ColorMapperHeat) DONE (commit pending). The first registered BT gauge widget. Pure wiring proved the whole pipeline: added the methodDescription (R,Md,C,St,St,St, keyword cmHeat), rewired Make to parameterList[], registered it. Registering it EXPOSED two /FORCE-stubbed real functions in the chain (the map had missed them) — reconstructed both from the decomp: ColorMapper::BecameActive (@004c395c — invalidates the cached colour index + last RGB so the next Execute re-pushes the palette) and ColorMapperHeat::~ColorMapperHeat (empty — base chain releases the HeatConnection + palettes). Verified: register→parse→Make→ctor→FindSubsystem("GeneratorA") (resolves — no "does not exist")→HeatConnection→ Execute→palette-push runs end-to-end; combat un-regressed (TARGET DESTROYED, 0 crashes); the sec/heat schematic zones are now tinted by the REAL subsystem currentTemperature (stable tint since temp is stable — the visible-animation win is headingPointer next). Method established for every remaining widget.

Increment 2 — headingPointer (HeadingPointer) DONE — the rotating compass + live heading readout. The visible money-shot: a green needle that rotates with the mech's facing + a numeric heading in whole degrees (render-verified: needle swung down-left→right and the number went 247°→182° as the mech turned). Full class reconstruction from the binary (Make/ctor/dtor/TestInstance/ShowInstance/BecameActive/Execute) — the Ghidra pseudo-C for Execute@004c5914 + ctor@004c562c had the x87 endpoint math dropped, so it was recovered by disassembling BTL4OPT.EXE (capstone, scratchpad/disas_hp.py). Key facts learned:

  • The header ctor arity was wrong (12 vs the binary's 14). Widened it; fields @0x98/@0x9C are the needle's inner/outer RADIUS (Execute does fild [@0x98] ; fmul sin), not "color/spacing" as named. The needle is a radial line innerRadius(20)..outerRadius(39) at the heading angle; endpoints round half-up ((int)(r*trig + 0.5f) == the binary's fadd 0.5 ; _ftol). The readout = round(360 - normalize(deg)).
  • ENGINE-CONVENTION FIX (empirical, as the map's data-binding agent flagged): the binary read the heading from EulerAngles index [0] (.pitch), but the WinTesla MUNGA EulerAngles(Quaternion) decomposition is AMBIGUOUS for a yawing mech — the quaternion double-cover flips it to a pitch=roll=π branch as the yaw sweeps past ±π, so euler.yaw jumps discontinuously (needle spins erratically). Switched to YawPitchRoll (yaw applied first → .yaw is the clean continuous heading, pitch=roll≈0 throughout). This is a legitimate port of the binary's INTENT, not a stand-in: the original engine's euler put the yaw in [0]; WinTesla's puts it in a different, ambiguous slot, so read the unambiguous decomposition.
  • renderer->GetLinkedEntity() (Renderer::GetLinkedEntity, RENDERER.h:374) resolves the viewpoint mech (the entitySocket IS wired — the map's NULL-socket worry didn't materialize; a GetViewpointEntity() fallback is kept belt-and-braces). Deps (NumericDisplay, GraphicsViewRecord, GraphicGauge) are all real engine classes, so no compounding /FORCE risk. Verified: no unresolved HeadingPointer externals, no parse desync, combat un-regressed (TARGET DESTROYED, 0 crashes). TECHNIQUE (reusable): recover an x87 float computation Ghidra dropped by PE-parsing + capstone-disassembling the fn (scratchpad/disas_hp.py) — read the fild/fmul/fadd stream + the .data float pool.

Increment 3 — cmArmor (ColorMapperArmor) DONE — the ARMOR DAMAGE schematic, per-zone. Each of the mech's armor zones on the cockpit schematic is now tinted by that zone's LIVE damage. Render-verified: the schematic shows the Blackhawk silhouette all-green (all zones damageLevel=0 at spawn) — which REPLACED the static green+red the schematic showed before cmArmor was registered (i.e. cmArmor now owns those zone colors, showing the real undamaged state; damaged zones shift toward red via the decomp-verified path). Reuses the ColorMapper base (done in incr.1) + adds two pieces reconstructed from the binary + a disassembly of the x87 Ghidra dropped (scratchpad/disas_hp.py):

  • ArmorZoneConnection (@004c33a4 ctor / @004c3430 Transfer): resolves ONE zone from the owner's inherited Entity::damageZones[zone_index]; per-frame feed = zone==NULL ? 100 : Round(zone->damageLevel @0x158 * 100) (the damage ratio 0..1 → 0..100 percentage → the colour index ColorMapper::Execute pushes).
  • ColorMapperArmor::Make/ctor (@004c3aa4/@004c3b98): Make resolves the CFG zone NAME (dz_ltorso etc., the 6th param) to an index via Entity::GetDamageZoneIndex(CString) — which IS the binary's FUN_0042076c (scans damageZones[] matching each zone's name @0x15c); the ctor wires the ArmorZoneConnection.
  • DECOMP FACT (corrects the cmHeat note): the ColorMapper base ctor FUN_004c37dc takes NINE args — there is an owner_ID between renderer and graphics_port_number (Gauge(rate,mode,renderer,owner_ID,id) + colorSlot=param_7, graphicsPort=GetGraphicsPort(param_6)). Our 8-arg ColorMapper::ColorMapper folds owner_ID=0 in (gauges have owner 0), so it's equivalent — cmArmor's mapping matches cmHeat (gpn=port, colorSlot=p[2], palettes=p[3]/p[4]) plus the zone name (p[5]). Verified: parses, builds, all dz_* zones resolve (0 "not found"), no /FORCE unresolved, combat un-regressed (TARGET DESTROYED, 0 crashes). NOTE: the dynamic red-on-damage needs the PLAYER to take damage — the passive BT_SPAWN_ENEMY dummy never hits back, so a live demo of a zone reddening needs a player-damage path (real MP combat, or a test hook). ⚠ The sibling colorMapperMultiArmor (worst-of-8-zones, MultiArmorConnection @004c346c) + cmCrit (ColorMapperCritical, CriticalConnection @004c3598) are the same shape — easy follow-ups now the pattern
    • the ColorMapper base facts are pinned.

Increment 4 — colorMapperMultiArmor DONE — worst-of-N-zones tint. Tints one schematic colour (a whole torso SECTION) by the worst of up to 8 damage zones. Reconstructed MultiArmorConnection (@004c346c ctor / @004c34f4 Transfer: scan 8 zones via entity->damageZones[idx], keep max damageLevel, count==0 ? 100 : Round(worst*100)) + ColorMapperMultiArmor::Make/ctor (@004c3c48/@004c3d60 — resolves 8 zone names to indices via GetDamageZoneIndex, 13-param methodDescription). Verified: parses, builds, all zones resolve, combat un-regressed (TARGET DESTROYED, 0 crashes).

THE interpreterTable OVERFLOW — a latent heap-corruption fixed (every future widget needed this). Registering colorMapperMultiArmor CRASHED — but not in the gauge code: a heap corruption surfaced later in mission bitmap loading (ObjectNameList::AddEntry → malloc AV). ROOT CAUSE: GaugeInterpreter's bytecode buffer is a FIXED interpreterTableSize = 46864 (GAUGREND.h) tuned for RP's config, and its Insert() bounds guard is a Verify() that COMPILES OUT at DEBUG_LEVEL 0 (the same dead-Verify class as the BNDGBOX/heat bugs). As each BT gauge widget is registered, more of BT's (larger) L4GAUGE.CFG resolves into bytecode instead of being parse-skipped; cmHeat+headingPointer+cmArmor got close, colorMapperMultiArmor (13 params × ~hundreds of calls) tipped it past 46864 → silent overflow past the char[] → heap smash detected at the next big alloc. FIX: interpreterTableSize → 262144 (sized for BT's full config). ⚠ This was going to block EVERY further widget — the table is cumulative across all registered gauges. LESSON (add to the checklist): a heap corruption that surfaces in an INNOCENT later alloc, right after registering a gauge, is the interpreterTable overflow — the Insert bounds-Verify is dead at DEBUG_LEVEL 0.

Increment 5 — cmCrit (ColorMapperCritical) DONE — the ColorMapper family is COMPLETE. Tints a schematic colour by a subsystem's operational state (the "critical" secondary display mode). Reconstructed CriticalConnection (@004c3598/@004c3610: src==0 → 0; src->simulationState==1 [DestroyedState] → 100; else the subsystem's own damage-zone damageLevel × 100) + ColorMapperCritical::Make/ctor (@004c3ddc/ @004c3e40 — FindSubsystem by name + wire the CriticalConnection). Resolved the subsystem damageZone@0xE0 shadow: the recon declared MechSubsystem::damageZone as a ReconDamageZone*, but the assignment (mechsub.cpp: damageZone = (ReconDamageZone*)new DamageZone(...)) shows the pointer IS a real engine DamageZone — so cast it back and read damageLevel. Added two public accessors to MechSubsystem (GetSimulationState(), GetDamageZoneProxy()) since those fields are protected (mirrors how HeatConnection reads the public currentTemperature). Verified: parses, builds, all subsystems resolve (0 warnings), no /FORCE unresolved, combat un-regressed (TARGET DESTROYED, 0 crashes), stable.

Next increments (priority order from the map): the ArmorZoneConnection/MultiArmorConnection classes reconstructed); then the attribute-table wave (vertBar/ 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/Attrentity->FindSubsystem(subName) (ENTITY.cpp:631 — stricmp on each subsystem's GetName()) → subsystem->GetAttributePointer(attrName).
  • bare Attrentity->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 strcmps 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).
  5. oneOfSeveralPixInt (OneOfSeveral base + PixInt, btl4gaug) — the button-state LAMPS. Reconstructed the whole OneOfSeveral family (strip selector: col=selected%columns, row=selected/columns → blit that sub-rect via DrawPixelMap8(pixmap)/DrawBitMapOpaque(bitmap); strip resource via warehouse->pixelMap8Bin). PixInt forces the PixMap path + one GaugeConnectionDirectOf<int> → the frame index; added the missing OneOfSeveral::foregroundColor@0x9C. Config binds duck/searchlight/ display-mode/piloting-mode buttons; ControlsMapper/DisplayMode resolves live, the rest degrade to frame 0 until their tables land.
  6. map (MapDisplay, btl4rdr) — the RADAR / tactical display (the marquee). The renderer was already reconstructed (Execute phases: bounds → gather static/moving entities → DrawViewWedge/DrawStatic/ DrawMoving/DrawNames); only the registration glue + data were missing. Extended the Mech table to the full 0x15..0x38 (RadarRange/RadarLinearPosition[Point3D*]/RadarAngularPosition[Quaternion*]/DuckState; AttributePointer=int Simulation::* so the ATTRIBUTE_ENTRY reinterpret binds pointer members), published the Sensor("Avionics") RadarPercent table, and reconstructed MapDisplay::methodDescription+ Make from the config (the "center"/"bottom" enum keyword typed as a STRING + converted in Make → no ModeManager named-constant needed). Renders the view wedge (mech FOV cone) live. THREE pre-existing stubs the map was the first consumer of, each fixed: Sensor radarPercent went negative (un-normalized heatEnergy in the not-byte-exact heat-leaf → guarded to no-penalty); ResolveOperatorEntity returned NULL (→ viewpoint fallback); MapName::ExtractFromEntity didn't NULL-guard the entity. ⚠ Debug via BT_MAP_LOG (phase/draw trace). CONTACTS NOW RENDER (2026-07): two more fixes landed the enemy blip inside the FOV wedge. (a) The gauge renderer's entity grid was NEVER populated — the port dropped ExecuteImplementation's InterestingEntity feed; added GaugeRenderer::RebuildEntityGrid() (engine) that fills movingEntities from the world's DynamicMaster+Replicant entities (the vehicles; AllEntity floods it with ~311 cls-0x5E props), called from the map's guarded Execute; DrawMoving draws a cross+box blip for cls-0xBB9 mechs (the pip table is stubbed). (b) The blip PROJECTION was wrong — worldToView is a camera matrix whose baked scale is wrong for a point transform (a 120u contact projected to 167px not 44px); fixed by projecting delta = entity - viewpoint directly (rotate by heading, scale by pixelsPerMeter). Verified: the 120u enemy (dsq=14400, cls 0xBB9, own=0) plots at (0,44) inside the wedge. FOLLOW-UPS: the authentic pip symbol/name/video-object infrastructure (GetVideoObject/LookUpPip/GetNameID, still stubbed → the cross-blip is the stand-in); the blip-vs-wedge rotation convention (reads ahead/in-wedge, correct); SetTargetRange (radar zoom, 1km default).

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 — LANDED (AggregateHeatSink 0xBBE reconstructed, commit 16f5f54; cleared the last NULL binding; the bank is also the ambient radiator since task #9).
  • Reservoir shadow — LANDED (heatfamily_reslice.cpp now writes thermalCapacity from the resource coolantCapacity; the shadow member is gone).
  • oneOfSeveralPixInt DONE (increment 5). The Mech table now reaches 0x37 so DuckState resolves; Searchlight/LightOn still needs a Searchlight LightOn table to make that button dynamic.
  • map DONE (increment 6) — view wedge + live contact blips; SetTargetRange LANDED (radar zoom). REMAINING: the stubbed pip/name/video-object symbology (contacts draw the cross-blip stand-in, no labels) — under reconstruction (task #17, 2026-07-12).
  • Remaining widgets — BOTH LANDED: PlayerStatus (increment 7, commit b691863) and vehicleSubSystems (see docs/VEHICLE_SUBSYSTEMS.md Phase 2).

Remaining widgets — scope (assessed 2026-07; NOT quick registration wins)

  • PlayerStatus DONE (increment 7; commit b691863) — reconstructed the whole family (mapped by the playerstatus-decomp-map workflow): methodDescription + rewired Make (fixed the port/x/y bug), ctor/dtor/ BecameActive/TestInstance/Execute, PlayerStatusMappingGroup (28 ColorMapperArmor zone tints, reusing the EXISTING 10-arg ColorMapperArmor ctor), + ColorMapper/Armor::SetColor. KEY FINDING: PlayerStatus lives in the config's cameraInit block (the MISSION-REVIEW / spectator camera cockpit), NOT MechInit — so it is NOT part of the mech cockpit and does not build during normal mech play (Execute never runs, mech cockpit un-regressed). It is the post-mission scoreboard rendered by the BTCameraDirector game model. Registered so cameraInit builds it. Build/link-safe + mech-cockpit un-regressed, but NOT runtime- verified (a normal vehicle=<mech> egg never triggers the camera model). BRING-UP STUB: CreateMutant Pixelmap8 returns NULL (the mech-outline recolor needs the DynamicMemoryStream read API + Pixmap pixel-copy mapped) → the score/name-box/status-box render but not the recoloured mech schematic. Player resolve uses application->GetMissionPlayer() (WinTesla-clean) instead of the binary's raw App+0x24 "Players"-node walk.
  • vehicleSubSystems — LANDED (docs/VEHICLE_SUBSYSTEMS.md: 7 authentic engineering panels with real aux-screen positions via the BTGetSubsystemAuxScreen bridge). The from-scratch scoping note above is historical.

Gauge data-binding wave (2026-07) — publish attributes + reconstruct unbuilt widgets

Goal (user directive: "all of this needs to work as the game originally played. no shortcuts"): make every cockpit gauge read + reflect LIVE game state exactly as the 1995 pod. The gauge-databinding-map workflow (5 surface maps + 2 adversarial verifies) mapped every binding → subsystem/member → published-vs-missing → authentic value → fix. [SUPERSEDED 2026-07-11, tasks #9/#10: the reframe below was computed at the DEGENERATE bring-up heat scale. At the authentic 1e7-unit economy the Heat MFD is FULLY DYNAMIC — weapons 77→2000, condensers/generators 100-1400, bank ~600; see context/gauges-hud.md.] The original (superseded) reframe read: the Heat MFD is authentically NEAR-STATIC — currentTemperature = heatEnergy/thermalMass; ThermalMass=1.39e6, so heatEnergy_init = 1.07e8 whose float32 ULP is 8.0 — a 3.5-heat laser shot ROUNDS TO ZERO (reaching Degradation 5000 needs ~2 billion shots). Coolant is DAMAGE-gated (not fire), the valve moves only on player toggle, AmbientTemperature is a hardcoded 300. So the wave is "publish attributes + reconstruct unbuilt widgets + wire the 2 damage-driven formulas + the valve", NOT "revive a dynamic heat model". Diagnostic infra: BT_GAUGE_ATTR_LOG (GAUGREND ParseAttribute logs [attr] <binding> OK/NULL per config binding).

Phase 1 — PUBLISHING (DONE; commits 070af40, 64975ec)

Most gauges resolved NULL because the reconstructed subsystems published only a fraction of the bound attributes. All ids kept a dense prefix from each parent's NextAttributeID (AttributeIndexSet::Build strcmps every slot — a gap AVs). Done:

  • HeatSink table dense-append: DegradationTemperature@0x118 / FailureTemperature@0x11C (the condenser temp-bar warn/max endpoints — were NULL so the two-part bars couldn't scale), NormalizedPressure→heatLoad, DegradationPressure→coolantEfficiency, CoolantMassLeakRate→coolantDraw, HeatSink→linkedSinks. Condenser/ Reservoir inherit it → all 6 condenser temp bars resolve.
  • PoweredSubsystem::GetAttributeIndex() (new) publishes InputVoltage→voltageSource@0x1D0 — the cluster power-branch gate (SubsystemCluster ctor skips the power-lamp/generator-voltage/state-lamp sub-branch when it resolves NULL). Flows to Sensor/Myomers/weapons automatically (they chain to it).
  • MechWeapon::GetAttributeIndex() (new) publishes OutputVoltage/PercentDone→rechargeLevel@0x320; Emitter/PPC/ProjectileWeapon/MissileLauncher/GaussRifle DefaultData re-pointed at it (they carried an EMPTY default-constructed index → resolved NOTHING). VERIFIED: the ER-MED-LASER/PPC/STREAK weapon clusters now render live recharge dials (SegmentArc270) — were blank TEMP/STATUS.
  • Searchlight publishes "LightOn"→lightState@0x1D8 (config binds the NAME "LightOn"; enum renamed LightOnAttributeID). Mech::SetTargetRange un-stubbed (radarRange=range) → radar map scale + range readout track the mapper's zoom.
  • FIXED a genuine recon bug (heat.cpp UpdateCoolant + heatfamily_reslice RefrigerationSimulation): both read linkedSinks.Resolve()->heatEnergy where the binary reads *(this[0x38]+0x158) = the subsystem's OWN DamageZone.damageLevel (@0xE0). The misread would SLAM the live CoolantMass bars to empty (coolantDraw = master.heatEnergy~1.3e7 * heatLoad) and clamp massScale permanently to 1.0. Now reads this->Subsystem::damageZone->damageLevel (qualified past the MechSubsystem shadow) → 0 undamaged → no leak / massScale 3.0 = authentic pristine. After P1 every config-string binding resolves OK except HeatSink/AmbientTemperature (needs the aggregate bank, P3).

Phase 2 — WIDGET RECONSTRUCTION (COMPLETE — every config gauge primitive registered; [gskip]=0)

These config keywords were UNREGISTERED in BTL4MethodDescription[] (btl4grnd.cpp) so their lines parse-skipped and the gauge never built (even after publishing). The gauge-widget-decode workflow reconstructed each from the binary; registered widgets need a REAL Make/ctor/Execute/dtor (the /FORCE trap: a prose-only vtable slot AVs on first call).

  • LeakGauge (== BitMapInverseWipe @4c5b7c, config @6) — the class body already existed; added the Make factory + methodDescription("LeakGauge") + registration. The heat panel's LEAK column now builds (empty undamaged = authentic).
  • VertNormalSlider (condenser valve slider @2; vtable PTR_FUN_00518c34) — reconstructed all 7 fns (part_013.c:14051-14175; Make @004c4b08 by disassembly), fixed the jumbled header layout, published ValveSetting→coolantFlowScale@0x15C. The SET valve indicator renders (row=Round(span*value)).
  • PilotList (Comm KILLS/DEATHS roster; part_014.c:3156-3434, DAT_0051af88 8×3 layout PE-parsed) — the Comm surface now renders the live local-pilot row (KILLS 0 DEATHS 0 + name box). Feed = new BTResolveRosterPilot bridge (mechmppr.cpp) → mapper roster. DEATHS redirected from the dead pad_0x280 to the real deathCount@0x200. The scoring feed that moves KILLS/DEATHS is P3.
  • GeneratorCluster (4 generator panels, buttons 9-12) — the 4 panels (A/B/C/D) render with blue OutputVoltage bars (the @004c72ac ScalarBarGauge Scalar* variant + the Generator OutputVoltage table), labels, and status lamps. Root cause of the initial abort (cdb-attach to the frozen dialog): the engine base Gauge::Execute is Fail("not overridden")→abort (GAUGE.cpp:598), so an ACTIVE container that doesn't override Execute aborts by design (GuardedExecute's SEH can't catch an abort()). The decode's "overrides ONLY the dtor / renders via children" was wrong — the 1995 base Gauge::Execute was a no-op, the 2007 engine's aborts. FIX: BecameActive() (blit the background pixmap + don't self-inactivate) + Execute() (no-op; the 5 self-registered children draw the dynamic content), mirroring the sibling SubsystemCluster. ⚠ LESSON: a reconstructed container gauge MUST override Execute (+ a non-inactivating BecameActive) — the 2007 Gauge::Execute base aborts.
  • SectorDisplay (radar SECTOR X/Z read-out, Secondary overlay; commit 4a4ec68) — was PROSE-ONLY (placeholder Make). Reconstructed byte-verified from the disassembly (ctor @4c9e10, Execute @4ca07c, methodDescription PE-parse): SectorDisplay : GraphicGauge (0xC4), overrides LinkToEntity(9)/BecameActive(3)/ Execute(16). Execute reads the linked mech's localOrigin → two 100-unit sector numerics numericA=Round(-Z*0.01)+500, numericB=Round(X*0.01)+500 (Round=FUN_004dcd94, corrected from a "truncate" claim; 0.01 + -Z/+X asm-verified). VERIFIED LIVE (BT_SECTOR_LOG): Make port=1 (overlay), Execute -Z=960/X=362 → sectorA=510 sectorB=504.
  • PrepEngrScreen (per-engineering-screen static label overlay, 12 CFG calls; commit c0d2eb0) — was a MISLABELED stub (base Gauge/Execute/mech+screenNumber swapped). Corrected (vtables.tsv proof): base GraphicGaugeBackground (paint-on-activation, only dtor+BecameActive overridden — NO Execute virtual, so the Fail→abort hazard doesn't apply), screenNumber@0x6C/mech@0x70. BecameActive walks the roster (safe accessors + BTGetSubsystemAuxScreen), finds the subsystem whose auxScreenNumber==this screen (1..12), paints its labels dispatched on GetClassID. All 12 build; composite un-regressed.
  • MessageBoard (secondary-MFD comm/status ticker; commit 7fc4acb) — was PROSE-ONLY; the LAST parse-skip. Reconstructed (Make @4cb678 … Execute @4cb82c, 0xA4); fixed 3 header mislabels (enabled→trackedMech; previousMessageId/previousNameId swapped). DEFERRED/EMPTY by design (authentic for bring-up): the source is never bound (SetSource has no caller) AND the status queue (StatusMessagePool, btstubs.cpp:62) is a NULL stub → no messages → Execute early-returns (safe no-op). Data via a BTResolveMessageBoard /FORCE-safe bridge (btplayer.cpp). After this, the parse-skip list is EMPTY.

GAUGE SYSTEM COMPLETE (registration + binding): all 50 config attribute bindings resolve ([attr] 0 NULL — AggregateHeatSink cleared the last, HeatSink/AmbientTemperature), and every config gauge primitive is registered + built ([gskip]=0). Diagnostic BT_GAUGE_SKIP_LOG (GAUGREND.cpp, gated) logs each unregistered primitive the dev-parse skips — the tool that pinned the last 3 (⚠ the gauge renderer builds LAZILY, so verification runs must wait 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.

Phase 3 — SIM/MODEL

  • Aggregate HeatSink-bank (0xBBE) — DONE (commit 16f5f54). Reconstructed AggregateHeatSink : HeatSink (ctor @4ae8d0, GUID 0x50e590), byte-exact + static_assert-locked (heatSinkCount@0x1D0, ambientTemperature @0x1D4=300, helper@0x1D8, sizeof 0x1E4). Publishes HeatSinkCount + AmbientTemperature via a dense-prefix table chained to HeatSink::NextAttributeID. CreateHeatSinkBankSubsystem moved to heatfamily_reslice.cpp, builds the real class at 0xBBE. DELIBERATE DEVIATION: keep the base HeatSinkSimulation; do NOT install the authentic Performance @4ae73c (it derefs a raw self+0xE0→[+0x158] that doesn't map in our layout → AV/NaN for EVERY mech; ambientTemperature is a frozen constant so the gauge reads 300 either way). Clears the LAST config-binding NULL: [attr] HeatSink/AmbientTemperature OK, all 50 bindings resolve (0 NULL).
  • Condenser valve gauge — DONE (commits b91057a, aae3ce2). Two coupled fixes: (1) Dedup — heat.cpp's STUB Condenser ctor/dtor/TestClass/TestInstance/CreateStreamedSubsystem ODR-duplicated the REAL reslice bodies and WON under /FORCE → the real ctor (valveState=1) was shadowed, valveState=0xCDCDCDCD. #if 0'd the heat.cpp stubs (kept DefaultData/GetClassDerivations/ResetToInitialState). (2) RecomputeCondenserValves (FUN_0049f788) — FinishConstruction() at the Mech ctor tail was a no-op stub → coolantFlowScale never written (stayed 0). Reconstructed it (coolantFlowScale_i = valveState_i / ΣvalveState + condenserAlarm change-pulse), wired at the ctor post-init pass. Verified: [valve] the BLH's 6 condensers each read flow=0.166667 (=1/6). ValveSetting→coolantFlowScale now authentic. (3) MoveValve — the reconstructed @4afbe0 body was a MISLABEL (MechControlsMapper mode cycler); disassembled the real handler @4ae464 (valveState cycle 1→5→50→0 + RecomputeCondenserValves) and replaced it. [UPDATE 2026-07-12: LIVE (task #13) — the guard was never the 0xBD3 messmgr; FUN_004ac9c8 is the owning player's ROOKIE-ROLE lockout (roleClassIndex@0x274==0, task #12 bridge). MoveValve is registered (id 4, table @0x50E52C), verified valveState 1→5 / flow 1/6→5/10; 'C' key + BT_VALVE_SLOT.]
  • Combat SCORING feed (KILLS/SCORE) — DONE (commit 34aaa7d; scoring-feed-decode workflow). The scoreboard read 0 for 4 reasons (none was handler logic — the ScoreMessage/VehicleDead/ScoreInflicted handlers were already reconstructed): (1) no producer — added the posts (per-hit ScoreInflicted at the beam/projectile dispatch; KillScore at the TARGET-DESTROYED edge; senderMechID = the VICTIM so the local player is credited via the !=our-mech branch; dispatched to GetMissionPlayer()); (2) cross-family Mech offsets → GetPlayerLink() + tonnage/bias stubs; (3) NULL scenarioRole → CalcInflictedScore returns the neutral (damage+bias); (4) ownerless-dummy null-guards. Plus the databinding: the PilotList read KILLS/DEATHS at raw offsets → repointed to the compiled members via BTPilotKills/BTPilotDeaths bridges. ⚠ ROOT-CAUSE of the empty scoreboard: the PilotList SELECT-TARGET highlight did a raw-offset deref (*(local+0x284) then *(tgt+0x190)) that AV'd on our layout — caught silently by the SEH GuardedExecute, aborting Execute BEFORE the KILLS/DEATHS draw. Replaced with BTPilotIsSelected (accessors). And the 10s console flush now only zeros currentScore when a console host exists (solo keeps the running score). Verified: SCORE climbs +5.83/hit → 164; on the kill the Comm roster shows KILLS=1 / DEATHS=0, log killCount=1 deaths=0 score=164 rank=0. Env: BT_SCORE_LOG.
    • DEATHS + RANK fixed (commit a9210e0). RANK=-1: Player::DefaultFlags creates every player NonScoring (CalcRanking ranks only IsScoringPlayer()); call SetScoringPlayerFlag() in the DropZoneReply mech branch → RANK=0. DEATHS runaway: @004c012c (our VehicleDead MESSAGE_ENTRY) is actually the drop-zone respawn-retry helper — the engine's RequestDropZone (PLAYER.cpp:400) posts a 2s-delayed VehicleDead(deathCount=-2) timer, and our handler ++deathCount + re-posted it unconditionally → an infinite loop (the drop-zone handshake never completes in bring-up). Fix: if the player already has a vehicle (drop zone acquired), the retry is moot → return without counting/re-posting → DEATHS=0, loop gone. MP real-death DEATHS (destroyed vehicle via @004c05c4) remains deferred.
  • duckState writer (button-4 crouch lamp; only ctor-zeroed today).

Phase 4 — DEEP/DEFERRED

Byte-exact re-base of the PoweredSubsystem:HeatSink LEAF (Sensor/Emitter/Myomers) → radarPercent heat penalty (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]); numericSpeed units (open).

Authentically-static (do NOT "fix")

[currentTemperature "~77 static" REMOVED 2026-07-11 — dynamic at the authentic heat economy, tasks #9/#10.] 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.