The visible money-shot: a green compass needle that rotates with the mech's facing plus a numeric heading in whole degrees (render-verified: the needle swung and the number went 247 -> 182 as the mech turned). Full class reconstruction from the binary (Make/ctor/dtor/TestInstance/ ShowInstance/BecameActive/Execute). Ghidra dropped the x87 float math feeding Execute's needle endpoints and the ctor's NumericDisplay centering, so it was recovered by disassembling BTL4OPT.EXE with capstone (scratchpad/disas_hp.py): the needle is a radial line from innerRadius(20) to outerRadius(39) at the heading angle, endpoints rounded half-up; the readout is round(360 - deg). Two corrections vs the map: - The header ctor was 12 args; the binary's is 14 -- widened it. Fields @0x98/ @0x9C are the needle's inner/outer RADIUS (Execute multiplies them by sin/cos), not "color/spacing" as the old field names implied; renamed accordingly. - ENGINE-CONVENTION FIX: the binary read the heading from EulerAngles index [0], but the WinTesla EulerAngles(Quaternion) decomposition is ambiguous for a yawing mech (the quaternion double-cover flips it to a pitch=roll=pi branch as yaw sweeps past +/-pi -> the needle spins erratically). Switched to YawPitchRoll, whose yaw-first .yaw is the clean continuous heading (pitch=roll~0). Faithful to the binary's intent (heading), correct for this engine's decomposition. renderer->GetLinkedEntity() resolves the viewpoint mech (a GetViewpointEntity fallback is kept belt-and-braces). Deps (NumericDisplay/GraphicsViewRecord/ GraphicGauge) are real engine classes -> no /FORCE risk; verified no unresolved HeadingPointer externals, no parse desync, combat un-regressed (TARGET DESTROYED, 0 crashes). Details + the disasm technique: docs/GAUGE_COMPOSITE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
25 KiB
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)
- DORMANT in BT.
BTL4GaugeRendererpassesL4GaugeRenderer(false, NULL,NULL,NULL)(btl4grnd.cpp:151) →NUMGAUGEWINDOWS=0; andMakeGaugeRendereronly builds anything ifgetenv("L4GAUGE")(btl4app.cpp:353). Both must change to wake it. With 0 surfaces,SVGA16::Updatemay index an emptymSurfaces[]→ guard needed. - RP↔BT PORT-NAME MISMATCH.
SVGA16::Updatehardcodes the Red-Planet MFD port namesauxUL2/auxC/auxUR2/auxLL/auxLR(L4VB16.cpp:4056-4061), but BT'scontent/GAUGE/L4GAUGE.CFGnames themHeat/Comm/Mfd1/Mfd2/Mfd3(:4395-4410).GetGraphicsPortreturns NULL for the RP names → the MFD branch early-outs ("No MFDs to draw"). ONLY thesec/radar surface matches today — the 5 MFDs render nothing until the names are reconciled. - 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 duringApplication::Initialize? Verify build order or defer texture creation.
Architecture options (dev)
- (A)
SVGA16dev-composite branch. InSVGA16::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 theSVGA16pod path untouched; add a composite pass in the main renderer that reads the sharedpixelBuffer+ palette directly and runs the expand + inset blit itself. Best protects the pod path; re-implements the expand + needspixelBuffer/palette accessors onVideo16BitBuffered/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.
- Wake it via a dev-composite opt-in (e.g.
BT_DEV_GAUGESenv, default OFF so DEV/pod defaults are unaffected; or gate on-platform podon a dev box). When on: setL4GAUGE(soMakeGaugeRendererbuilds)- set the composite flag. Guard
SVGA16::Updatefor 0 surfaces (no crash).
- set the composite flag. Guard
- Raster: the widgets already raster into
pixelBuffer(driven byGaugeRenderer::ProcessOneActiveGauge). - 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 viaGetGaugeRenderer().
- 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.CFGor inSVGA16::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→ theL4Warehousegauge-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 intopixelBuffer),pixelBufferL4VB16.h:140, RP port names :4056-4061.engine/MUNGA_L4/L4GREND.cpp:L4GaugeRendererctor:57 (buildsSVGA16fromL4GAUGE.INI),ExecuteBackgroundDisplayUpdate:369(callsgraphicsDisplay->Update),BuildGraphicsPort:485(every port shares ONEpixelBuffer, bit-plane per port).engine/MUNGA/GAUGREND.cpp:ExecuteForeground:3556/ProcessOneActiveGauge:3836(draw-all-gauges intopixelBuffer).game/reconstructed/btl4grnd.cpp: ctor:151 (the un-dormant point — NULL indices),NotifyOfNewInterestingEntity:250(routesGaugeImageStreamtype-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
sec0x003F +overlay0x00C0): the radarMapDisplay+ 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:
SVGA16::Update(L4VB16.cpp) — indexed emptymSurfaces[]; guardedDevGaugeComposite()||NUM<=0.SVGA16::Refresh— same; guarded.LBE4ControlsManager::MakeLinkedLamp(L4CTRL.cpp:2057) —GetGaugeRenderer()->GetLampManager()returns NULL (recon gap inBTL4GaugeRenderer); guarded (skip the linked lamp when null).L4GaugeRenderer::NotifyOfNewInterestingEntity(L4GREND.cpp:440) —warehousePointer->gaugeImageBinhas a garbage internal chain (recon shadow of the warehouse); guarded (skip the per-mech gauge-IMAGE cache underBT_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):
- ⭐
MakeGaugeRendererOVERRIDE SIGNATURE MISMATCH (real bug — FIXED). The reconstructedBTL4Application::MakeGaugeRenderer()took NO args, but the 2007 WinTesla engine had WIDENED the base virtual toMakeGaugeRenderer(int* secondaryIndex, int* aux1, int* aux2)(called fromApplication:: Initialize, APP.cpp:382). A no-arg method only HIDES the virtual — it never overrides it — so the engine ranL4Application::MakeGaugeRendererinstead and built a baseL4GaugeRendererwhose ctor never callsBuildConfigurationFile→gauge\l4gauge.cfgwas NEVER PARSED → empty symbol table → the observedGaugeInterpreter: undefined label 'bhk1Init'→ no ports, no gauges. FIX: match the 3-arg signature so it truly overrides (args ignored — BT is fullscreen/no-aux, theBTL4GaugeRendererctor hardcodesfalse,NULL,NULL,NULL). Same class of bug as theBTL4GaugeRenderer(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 binaryFUN_0046ff64does a plain copy +Str_Cat); the CFG definesBhk1Initbut the symbol-table lookup isstricmp(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>Initcalls that builds thesec/overlay/Mfd*/Heat/Comm PORTS via the baseconfigureprimitive. - 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 likePlayerStatusaren't reconstructed), soGaugeInterpreter::GetProcedureBodyhits an unknown primitive →ReportParsingError→Fail()→ a MODAL assert dialog that FREEZES the game mid-parse (cdb confirmed:MessageBoxW←abort←ReportParsingError←GetProcedureBody). FIX (GAUGREND.cpp, gatedBT_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 baseconfigureprimitives that build the PORTS. The baseconfigure(MakeConfigMethodDescription) IS inL4MethodDescription, so thesec/radar SURFACE builds even though the BT widgets are skipped. Pod keeps the strictFail(it needs a complete real table). - Gauge widgets AV on NULL data bindings (dev accommodation, gated). Two flavors: (a) value-bound gauges
(
NumericDisplayScalar) getvalue_pointer = parameterList[8].data.attributePointer= NULL (the mech data binding isn't reconstructed; the source even comments "(Scalar *) is VERY dangerous!") → theGaugeConnectionDirectOf<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::UpdaterunsExecute()under a SEH wrapperGauge::GuardedExecute()(a separate fn —__try/__exceptcan't share a frame with theChainIteratorOfunwinding local); a gauge that faults once isDisable(True)d (rate=0) so it never retries. Both gatedBT_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 theSVGA16once (via any port) and extracts each surface by its own plane mask. BT plane map (L4GAUGE.CFGMechInit @4395):sec=radar (palette, low byte) ·Heat=UL (0x4000) ·Mfd2=UC (0x0400) ·Comm=UR (0x8000) ·Mfd1=LL (0x0100) ·Mfd3=LR (0x1000).Eng1/2/3are 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 inSVGA16::Updateonly matter to the POD demux (a deferred pod-only follow-up); our compositor fetches the BT names (Heat/Comm/Mfd1/Mfd2/Mfd3) directly viaGetGraphicsPort(stricmp). - Two extract kernels in
SVGA16::DrawDevSurface: palette-LUT (sec/radar, ==Updatecase 0) and mono bit-plane → tint ((word & mask) ? tint : 0, the reduced core ofUpdatecases 1/2; MFD masks are single bits so no DWORD SIMD/pack).BTDrawGaugeSurfacesiterates 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 mainEndScene, before the main Present):GetBackBuffer→SetRenderTarget→SetDepthStencilSurface(NULL)→ Clear → BeginScene → 6 tiles → EndScene → restore RT+DS →swap->Present. The pod's own per-surfaceBuildWindows/Updatepath 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
SetRenderTargetto 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 amethodDescription) + wire the gauge→game-stateExecute()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_L4is 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):
- Add
static MethodDescription methodDescription;to the class in its.hpp. - 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.dataexactly — the parser reads one token per entry to thetypeEmptyterminator (GAUGREND.cpp:1458); a wrong count desyncs the parse. - Rewire
Maketo readmethodDescription.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. - Register
&<Class>::methodDescriptioninBTL4MethodDescription[](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/@0x9Care the needle's inner/outer RADIUS (Execute doesfild [@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'sfadd 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
EulerAnglesindex [0] (.pitch), but the WinTesla MUNGAEulerAngles(Quaternion)decomposition is AMBIGUOUS for a yawing mech — the quaternion double-cover flips it to apitch=roll=πbranch as the yaw sweeps past ±π, soeuler.yawjumps discontinuously (needle spins erratically). Switched toYawPitchRoll(yaw applied first →.yawis the clean continuous heading,pitch=roll≈0throughout). 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; aGetViewpointEntity()fallback is kept belt-and-braces). Deps (NumericDisplay,GraphicsViewRecord,GraphicGauge) are all real engine classes, so no compounding/FORCErisk. Verified: no unresolvedHeadingPointerexternals, 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 thefild/fmul/faddstream + the.datafloat pool.
Next increments (priority order from the map): cmArmor/colorMapperMultiArmor (armor schematic, needs 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).