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) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-06 16:05:47 -05:00
co-authored by Claude Opus 4.8
parent 547f25e043
commit e869b00181
4 changed files with 290 additions and 40 deletions
+26 -3
View File
@@ -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
+232 -23
View File
@@ -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<int> 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
}
//###########################################################################
+31 -14
View File
@@ -410,12 +410,29 @@
public GraphicGauge
{
public:
static Logical Make(int, Vector2DOf<int>, Entity *, GaugeRenderer *);
// Registered in BTL4MethodDescription[] (btl4grnd.cpp) as "headingPointer".
static MethodDescription methodDescription;
static Logical Make(int, Vector2DOf<int>, 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
};
+1
View File
@@ -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
};