Files
BT412/docs/GAUGE_COMPOSITE.md
T
arcattackandClaude Opus 4.8 099871ef4c docs: record PlayerStatus done (mission-review scoreboard; cameraInit)
Mark PlayerStatus reconstructed + registered in the remaining-widget scope note;
record the key finding that it lives in cameraInit (the mission-review camera
cockpit) not MechInit, so it is build/link/mech-cockpit-safe but not runtime-
verifiable via a normal mech egg.  vehicleSubSystems (from-scratch, 26 uses)
remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 23:14:28 -05:00

477 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Gauge / MFD render pipeline — map + dev-composite plan
**Status:** pipeline MAPPED (workflow `gauge-pipeline-map`, 2026-07); the dev composite is **planned, not
yet built**. This is the execute-ready plan for making the pod's gauges/MFDs render + be VISUALLY testable
on a dev box — the "MFD compositing on dev" follow-up the platform profile gates (see §8 / the PLATFORM
PROFILE entry in CLAUDE.md). Companion: the platform-profile scaffold (`-platform pod|dev`).
## 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 `BuildConfigurationFile``gauge\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 → `ReportParsingError``Fail()` → a MODAL assert dialog that FREEZES the game
mid-parse (cdb confirmed: `MessageBoxW``abort``ReportParsingError``GetProcedureBody`). 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 (`Bhk1Init`
`MechInit`) 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):
`GetBackBuffer``SetRenderTarget`**`SetDepthStencilSurface(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/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).
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` 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).
-**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) — renders the view wedge. FOLLOW-UPS: the spatial-query contact
classification (0 contacts today) + the stubbed pip/name/video-object infrastructure (contacts + labels)
+ `SetTargetRange` (radar zoom, currently a 500m default).
- Remaining widgets (task #13): `PlayerStatus` (comm/score, 8 uses), `vehicleSubSystems` (26 uses).
### 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** (26 uses -- the most-used unregistered widget) — no class declared; fully
from-scratch. Config: `vehicleSubSystems(qjak0.pcc..qjak7.pcc)` (8 subsystem-group bitmaps, no attribute
bindings -- reads the mech's subsystem roster internally). Find its Make/ctor/Execute via the .data
methodDescription table (the "vehicleSubSystems" string is a .data const, not inlined in the decomp).
A focused effort (~250-300 lines across 3-5 functions).