GAUGE_COMPOSITE.md + CLAUDE.md: add the OneOfSeveral/oneOfSeveralPixInt button- lamp reconstruction to the done list; narrow the remaining widgets to map (radar marquee), PlayerStatus, vehicleSubSystems, plus the follow-ups to make the duck/searchlight buttons dynamic (extend the Mech table to 0x37 + a Searchlight table). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
435 lines
37 KiB
Markdown
435 lines
37 KiB
Markdown
# 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 0x0100–0x8000 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.
|
||
|
||
### 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 above). To make its `DuckState`/`Searchlight/LightOn`
|
||
buttons DYNAMIC (they read frame 0 today): extend the Mech dense table to 0x37 (DuckState@0x3f8) + add a
|
||
Searchlight `LightOn` table.
|
||
- Remaining widgets (task #13): `map` (radar — the marquee; needs the Mech dense table extended to 0x31 for
|
||
RadarRange/RadarLinearPosition/RadarAngularPosition + a Sensor `RadarPercent` table + the map/radar
|
||
renderer reconstruction, base `MapDisplay` in btl4rdr.cpp), `PlayerStatus` (comm/score, 8 uses),
|
||
`vehicleSubSystems` (26 uses).
|