From e869b001814aba9ad1dfb58e1f47d11aa1cf9fde Mon Sep 17 00:00:00 2001 From: arcattack Date: Mon, 6 Jul 2026 16:05:47 -0500 Subject: [PATCH] gauges: reconstruct headingPointer -- the rotating compass + heading readout [widget recon 2] 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) --- docs/GAUGE_COMPOSITE.md | 29 +++- game/reconstructed/btl4gaug.cpp | 255 +++++++++++++++++++++++++++++--- game/reconstructed/btl4gaug.hpp | 45 ++++-- game/reconstructed/btl4grnd.cpp | 1 + 4 files changed, 290 insertions(+), 40 deletions(-) diff --git a/docs/GAUGE_COMPOSITE.md b/docs/GAUGE_COMPOSITE.md index 8c8ebbc..b173ecc 100644 --- a/docs/GAUGE_COMPOSITE.md +++ b/docs/GAUGE_COMPOSITE.md @@ -257,9 +257,32 @@ Execute→palette-push runs end-to-end; combat un-regressed (TARGET DESTROYED, 0 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.** -**Next increments (priority order from the map):** `headingPointer` (rotating compass + numeric heading — -reconstruct from prose `btl4gaug.cpp:644`, reads owner quaternion directly, no attribute table/connection -needed — the visible money-shot); then `cmArmor`/`colorMapperMultiArmor` (armor schematic, needs the +**✅ 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.** + +**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 diff --git a/game/reconstructed/btl4gaug.cpp b/game/reconstructed/btl4gaug.cpp index 4ab72c1..2bd5c8c 100644 --- a/game/reconstructed/btl4gaug.cpp +++ b/game/reconstructed/btl4gaug.cpp @@ -689,35 +689,244 @@ static void //########################################################################### // -// @004c562c -- ctor (vtable PTR_FUN_00518ae0). GraphicGauge base, then builds -// a GraphicsViewRecord (this+0x29, FUN_0044a5b4) for erase tracking, sets the -// port origin, stores colours/spacing, interns + ref-counts the pointer image, -// and -- centring on the image's mid-point -- constructs an owned NumericDisplay -// (this[0x2F], FUN_004700bc, 0x44 bytes) offset by half the glyph metrics. -// half-extent maths: w' = (imageWidth/14)*3 ; centre = (-w'/2, -h/2). +// Reconstructed from the binary (raw pseudo-C part_013.c:14560-14791 + a +// disassembly of Execute@004c5914 and ctor@004c562c to recover the x87 endpoint +// arithmetic Ghidra dropped). The compass is a radial NEEDLE (a thick line from +// innerRadius to outerRadius at the heading angle) plus a NumericDisplay showing +// the heading in whole degrees. // -// @004c573c -- dtor: release the image, delete the NumericDisplay -// (FUN_0047018c), tear down the GraphicsViewRecord (FUN_0044a5dc), -// GraphicGauge::~GraphicGauge. + +MethodDescription + HeadingPointer::methodDescription = + { + "headingPointer", + HeadingPointer::Make, + { + // + // CFG shape (L4GAUGE.CFG:4941): + // headingPointer( rate, mode, needleColor, numericFg, eraseColor, + // innerRadius, outerRadius, font ) + // e.g. headingPointer(E, ModeAlwaysActive, 2,1,0, 20,39, helv15.pcc); + // + { ParameterDescription::typeRate, NULL }, // rate ID + { ParameterDescription::typeModeMask, NULL }, // mode mask + { ParameterDescription::typeColor, NULL }, // needle colour -> @0x90 + { ParameterDescription::typeColor, NULL }, // numeric fg -> NumericDisplay + { ParameterDescription::typeColor, NULL }, // erase/bg -> @0x94 + ND bg + { ParameterDescription::typeInteger, NULL }, // inner radius -> @0x98 + { ParameterDescription::typeInteger, NULL }, // outer radius -> @0x9C + { ParameterDescription::typeString, NULL }, // font name + PARAMETER_DESCRIPTION_END + } + }; + // -// @004c57d0 -- ShowInstance: prints "HeadingPointer" + the fg/bg colours -// (this+0x94 / this+0x90) then chains to GraphicGauge::ShowInstance. +// @004c554c -- Make. Allocate + construct the gauge, then probe that the glyph +// font exists (== NumericDisplayScalar::Make, L4GAUGE.cpp:626-640); the readout +// cannot draw without it. // -// @004c58e8 -- BecameActive: previousX = previousY = -999 (0xfffffc19) and -// reset the NumericDisplay (FUN_004703f4). +Logical + HeadingPointer::Make( + int display_port_index, + Vector2DOf position, + Entity * /*entity -- unused*/, + GaugeRenderer *gauge_renderer + ) +{ + ParameterDescription *p = methodDescription.parameterList; + + HeadingPointer *gauge = (HeadingPointer *)operator new(0xc0); // FUN_00402298(0xc0) + if (gauge != NULL) + { + new (gauge) HeadingPointer( // FUN_004c562c + p[0].data.rate, p[1].data.modeMask, + (L4GaugeRenderer *)gauge_renderer, + 0, // owner_ID + display_port_index, // graphics port number (the runtime port) + position.x, position.y, + p[2].data.color, // needle colour + p[3].data.color, // numeric fg colour + p[4].data.color, // erase/bg colour + p[5].data.integer, // inner radius + p[6].data.integer, // outer radius (tip) + p[7].data.string, // font name + "HeadingPointer"); + } + + L4Warehouse *warehouse = (L4Warehouse *)gauge_renderer->warehousePointer; + if (warehouse->bitMapBin.Get(p[7].data.string) == NULL) // FUN_00442aec + { + DebugStream << "HeadingPointer: missing font '" << p[7].data.string << "'\n"; + return False; + } + warehouse->bitMapBin.Release(p[7].data.string); // FUN_00442c12 + return True; +} + // -// @004c5914 -- Execute. Resolve the owner's heading: take the renderer owner -// entity (Resolve renderer+0x40), extract Euler yaw from its orientation -// quaternion (entity+0x10c, FUN_0040954c), negate it, SinCos (FUN_00408328), -// and Round the rotated pointer endpoint. When the rounded position changes, -// erase the previous drawing (GraphicsViewRecord), re-blit the pointer image -// rotated to the new heading (BeginClip/SetColor/MoveTo/LineTo/EndClip), then -// draw the numeric heading via NumericDisplay::DrawAt (FUN_00470430), guarding -// the readout below _DAT_004c5acc (0.0). +// @004c562c -- ctor (vtable PTR_FUN_00518ae0). GraphicGauge base; the embedded +// GraphicsViewRecord (previousDrawing) default-constructs as a member (== the +// binary's FUN_0044a5b4). Set the port origin, store the colours/radii, hold a +// ref to the glyph font, and build an owned NumericDisplay centred on the compass +// mid-point (half the 3-digit glyph extent). // -// (@004c591a is the same routine re-listed from a frame-pointer-relative -// disassembly view; it carries no distinct code.) +HeadingPointer::HeadingPointer( + GaugeRate rate, + ModeMask mode_mask, + L4GaugeRenderer *renderer_in, + unsigned int owner_ID, + int graphics_port_number, + int x, + int y, + int needle_color, + int numeric_color, + int erase_color, + int inner_radius, + int outer_radius, + const char *font_name, + const char *identification_string +): + GraphicGauge(rate, mode_mask, renderer_in, owner_ID, // FUN_00444818 + graphics_port_number, identification_string) +{ + localView.SetOrigin(x, y); // this+0x48 vtbl+0x10 + + needleColor = needle_color; // @0x90 + eraseColor = erase_color; // @0x94 + innerRadius = inner_radius; // @0x98 + outerRadius = outer_radius; // @0x9C + + // Own a copy of the font name (in Make it is the transient interpreter scratch + // buffer, so it MUST be copied -- the binary uses nameCopy/FUN_004700ac). + imageName = new char[strlen(font_name) + 1]; // @0xA0 + strcpy(imageName, font_name); + + L4Warehouse *warehouse = (L4Warehouse *)renderer_in->warehousePointer; + BitMap *font = warehouse->bitMapBin.Get(imageName); // FUN_00442aec -- held for the gauge's life + int fontW = (font != NULL) ? font->Data.Size.x : 0; // BitMap+0x0C + int fontH = (font != NULL) ? font->Data.Size.y : 0; // BitMap+0x10 + + int halfW = ((fontW / 14) * 3) / 2; // 14 = totalDigitsPerFont; centre the 3-digit readout + int halfH = fontH / 2; + + numericDisplay = new NumericDisplay( // @0xBC FUN_004700bc + warehouse, + -halfW, -halfH, // centre on the compass mid-point + imageName, + 3, // number_of_digits + NumericDisplay::unsignedFormat, // format 0 + erase_color, // background_color + numeric_color); // foreground_color +} + // +// @004c573c -- dtor. Release the font ref (keyed on imageName, so before the +// free), free imageName, delete the NumericDisplay. The base-dtor chain +// (~GraphicsViewRecord on previousDrawing, then ~GraphicGauge) runs implicitly. +// +HeadingPointer::~HeadingPointer() +{ + L4Warehouse *warehouse = (L4Warehouse *)renderer->warehousePointer; + warehouse->bitMapBin.Release(imageName); // FUN_00442c12 -- drop the ctor's Get ref + delete[] imageName; // FUN_004022e8 + imageName = NULL; + + delete numericDisplay; // FUN_0047018c + numericDisplay = NULL; +} + +Logical + HeadingPointer::TestInstance() const +{ + return GraphicGauge::TestInstance(); +} + +// +// @004c57d0 -- ShowInstance (debug dump). +// +void + HeadingPointer::ShowInstance(char *indent) +{ + DebugStream << indent << "HeadingPointer:\n"; + char deeper[80]; + strcpy(deeper, indent); + strcat(deeper, "..."); + DebugStream << deeper << "bgColor=" << eraseColor << "\n"; // @0x94 (printed first, per decomp) + DebugStream << deeper << "fgColor=" << needleColor << "\n"; // @0x90 + GraphicGauge::ShowInstance(deeper); +} + +// +// @004c58e8 -- BecameActive: force a full redraw + reset the readout. +// +void + HeadingPointer::BecameActive() +{ + previousX = -999; // 0xfffffc19 -- guaranteed != any real endpoint + previousY = -999; + numericDisplay->ForceUpdate(); // FUN_004703f4 +} + +// +// @004c5914 -- Execute. Read the owner mech's heading from its orientation +// quaternion, draw the needle as a radial line innerRadius..outerRadius at that +// angle (recording the strokes so the next frame can erase them), and update the +// numeric readout. Endpoint rounding is half-up: (int)(v + 0.5f), matching the +// binary's `fadd 0.5 ; _ftol`. +// +void + HeadingPointer::Execute() +{ + Entity *owner = renderer->GetLinkedEntity(); // FUN_00417ab4(renderer+0x40) + if (owner == NULL) + { + // BRING-UP: if the gauge renderer's entitySocket isn't wired to the viewpoint + // mech, fall back to the player so the compass tracks the mech we're driving. + // (In practice GetLinkedEntity resolves; this is belt-and-braces.) + if (application != NULL) + owner = (Entity *)application->GetViewpointEntity(); + if (owner == NULL) + return; + } + + // The binary read an EulerAngles component (FUN_0040954c) -- but the WinTesla MUNGA + // EulerAngles(Quaternion) decomposition is AMBIGUOUS for a yawing mech: as the yaw + // sweeps past +/-pi the quaternion double-cover flips it to a pitch=roll=pi branch, + // so euler.yaw jumps discontinuously (the needle spins erratically). YawPitchRoll + // applies yaw FIRST, so its .yaw is the clean, continuous heading with pitch=roll~0. + YawPitchRoll ypr; + ypr = owner->localOrigin.angularPosition; // entity+0x10c (the orientation quaternion) + Scalar heading = -(Scalar)ypr.yaw; // negate for the needle (as the binary did) + + SinCosPair sc; + sc = Radian(heading); // FUN_00408328 -- sin/cos of the heading + + // Needle endpoints: a radial line innerRadius..outerRadius along the heading. + int ax = (int)((Scalar)innerRadius * sc.sine + 0.5f); + int ay = (int)((Scalar)innerRadius * sc.cosine + 0.5f); + int bx = (int)((Scalar)outerRadius * sc.sine + 0.5f); + int by = (int)((Scalar)outerRadius * sc.cosine + 0.5f); + + if (bx != previousX || by != previousY) // redraw only when the tip moves + { + previousX = bx; + previousY = by; + previousDrawing.Draw(&localView, eraseColor); // FUN_0044a650 -- erase the last needle + previousDrawing.Clear(); // FUN_0044a630 + localView.AttachRecorder(&previousDrawing); // vtbl+0x64 -- record the new strokes + localView.SetColor(needleColor); // vtbl+0x18 + localView.MoveToAbsolute(ax, ay); // vtbl+0x24 + localView.DrawThickLineToAbsolute(bx, by); // vtbl+0x38 + localView.DetachRecorder(); // vtbl+0x68 + } + + // Numeric heading readout: whole degrees, normalized, displayed as (360 - deg). + int deg = (int)((Scalar)ypr.yaw * 57.29578f + 0.5f); // heading -> degrees (0x4c5ac8) + if ((Scalar)deg < 0.0f) // _DAT_004c5acc == 0.0f + deg = (int)((Scalar)deg + 360.0f); + int shown = (int)(360.0f - (Scalar)deg); + numericDisplay->Draw(&localView, (Scalar)shown); // FUN_00470430 +} //########################################################################### diff --git a/game/reconstructed/btl4gaug.hpp b/game/reconstructed/btl4gaug.hpp index 86476ad..64e1da7 100644 --- a/game/reconstructed/btl4gaug.hpp +++ b/game/reconstructed/btl4gaug.hpp @@ -410,12 +410,29 @@ public GraphicGauge { public: - static Logical Make(int, Vector2DOf, Entity *, GaugeRenderer *); + // Registered in BTL4MethodDescription[] (btl4grnd.cpp) as "headingPointer". + static MethodDescription methodDescription; + + static Logical Make(int, Vector2DOf, Entity *, GaugeRenderer *); // @004c554c + + // + // The binary ctor (@004c562c) takes 14 args -- the earlier header collapsed + // owner_ID/graphics_port_number and merged the colours. Also: fields + // @0x98/@0x9C are the needle's inner/outer RADIUS (Execute multiplies them + // by sin/cos of the heading), NOT a colour+spacing as the old names implied. + // HeadingPointer( // @004c562c - GaugeRate, ModeMask, L4GaugeRenderer *, int, - int x, int y, const char *font_name, - int numeric_color, int background_color, int spacing, - const char *image_name, const char *identification_string); + GaugeRate, ModeMask, L4GaugeRenderer *, + unsigned int owner_ID, + int graphics_port_number, + int x, int y, + int needle_color, // -> @0x90 the drawn needle colour + int numeric_color, // -> NumericDisplay foreground (readout) + int erase_color, // -> @0x94 erase + NumericDisplay background + int inner_radius, // -> @0x98 needle near-end radius + int outer_radius, // -> @0x9C needle tip radius (the change key) + const char *font_name, + const char *identification_string = "HeadingPointer"); ~HeadingPointer(); // @004c573c Logical TestInstance() const; @@ -424,17 +441,17 @@ void Execute(); // @004c5914 protected: - int x; // @0x90 this[0x24] - int numericColor; // @0x94 this[0x25] - int backgroundColor; // @0x98 this[0x26] - int spacing; // @0x9C this[0x27] - char *imageName; // @0xA0 this[0x28] + int needleColor; // @0x90 needle (fg) colour + int eraseColor; // @0x94 erase (bg) colour + int innerRadius; // @0x98 needle near-end radius + int outerRadius; // @0x9C needle tip radius (change key) + char *imageName; // @0xA0 interned font name GraphicsViewRecord - previousDrawing; // @0xA4 this[0x29] - int previousX; // @0xB4 this[0x2D] - int previousY; // @0xB8 this[0x2E] + previousDrawing; // @0xA4 erase-tracking record + int previousX; // @0xB4 + int previousY; // @0xB8 NumericDisplay - *numericDisplay; // @0xBC this[0x2F] + *numericDisplay; // @0xBC }; diff --git a/game/reconstructed/btl4grnd.cpp b/game/reconstructed/btl4grnd.cpp index ce5b2ed..03fdb88 100644 --- a/game/reconstructed/btl4grnd.cpp +++ b/game/reconstructed/btl4grnd.cpp @@ -130,6 +130,7 @@ MethodDescription // to the engine base table L4MethodDescription). // &ColorMapperHeat::methodDescription, // "cmHeat" -- heat-driven palette tint + &HeadingPointer::methodDescription, // "headingPointer" -- compass needle + heading &BTL4ChainToPrevious };