four ENG-page lamps were invisible, not missing: the ctors dropped their colour parameters

Playtest report: "generators don't seem to have an Auto setting" / "no auto mode
is ever displayed", plus the operator's own memory that the BUS MODE button once
stepped through three states.  The state machine was NEVER wrong: @004b0abc has
exactly two branches (<2 -> Auto(2); ==2 -> detach + Off(0)), Manual(1) is set
only by the four SelectGenerator buttons, and the auto-hunt gates on ==2 -- all
byte-verified, and the #62 re-attach fires live.  Manual is a one-way door out
of BUS MODE by design: a single cycle button cannot know WHICH generator manual
should mean.  Verified on-screen by the operator this session: A-D returns to
manual, BUS MODE toggles auto/off thereafter.

What was actually broken: the connect-mode lamp -- and three siblings -- never
drew a pixel.  OneOfSeveralStates (@004c5470) and OneOfSeveralInt (@004c5148)
forward caller-supplied background/foreground colours in the binary; the recon
dropped both parameters and hardcoded 0,0 into the base.  A BitMap-strip lamp
draws SetColor(bg) + DrawBitMapOpaque(fg,...), so 0/0 painted colour-0 on
colour-0: invisible.  Affected: btemode (connect mode, 1x3 -- THE report),
btecmode (coolant on/off, 1x2), and both bteseek gear-step lamps (1x4).  The
cluster call sites pass 0xff/0, byte-verified (@004c866c disasm;
part_014.c:1479-1481, :2146-2147).  Same dropped-element family as issue #42's
MoveToAbsolute.

THE TRAP THAT WAS NOT LANDED, recorded so it stays unlanded: with the lamp
first made visible, the frames appeared inverted against the levels (art reads
AUTO/MANUAL/OFF top-down; levels run OFF/MANUAL/AUTO), and a
row=(rows-1)-selected "fix" was proposed.  An adversarial workflow proved it
wrong: the gauge blit addresses SOURCE rows BOTTOM-UP
(Video16BitBuffered::DrawBitMapOpaque, L4VB16.cpp:3846-3850 -- sTop =
map_max_y - sTop, rows walked upward), the 1995 blit @0046bdfc performs the
identical flip, and the vertical strips are AUTHORED bottom-up to match.
Identity level->row therefore draws the pod-correct display; the inversion
would have created the very bug it claimed to cure.  Two of five investigators
(and the first human pass) assumed top-down; the engine source overruled all
three.  Convention + warning now recorded in context/gauges-hud.md.
btecmode doubles as the standing tripwire: a second vertical strip that must
show ON when coolant is available -- if it ever reads inverted, the bottom-up
verdict is falsified.

Also corrected: @004c552c is OneOfSeveralStates' EXECUTE override (clamp >= 0,
chain the base draw) -- the port had the body on BecameActive under a wrong
label; vtable-diffed against OneOfSeveral (0x518b24 vs 0x518bf0).  BecameActive
is inherited.  Behaviorally inert today (the state source never goes negative),
byte-faithful now.

README: the "KNOWN ISSUE -- automatic re-attach is not working yet" text is
replaced with how power routing actually works (A-D = manual, BUS MODE = auto
then off, two presses off / one back to auto).  That text shipped in 600, which
already contained the #62 fix -- players were being TOLD auto was broken while
it worked, which is half of how a painted-out lamp became "no auto mode".

Diagnostic kept: BT_GENSEL_TEST=<id> now drives any of the five power-routing
message ids (4..8, default 7) and pulses four times, so the whole mode cycle is
observable headlessly under BT_FIRE_LOG ([gensel] lines).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joe DiPrima
2026-07-28 13:22:21 -05:00
co-authored by Claude Opus 5
parent c32d02b3cc
commit 901cf1b459
6 changed files with 130 additions and 26 deletions
+28
View File
@@ -654,6 +654,34 @@ Player CALLSIGN labels (kill/damage feed, radar/target tags, score display) are
BITMAPS from the egg, not text — format, renderers, and the operator-console generator:
[[multiplayer]] §OPERATOR-SET CALLSIGNS.
## Lamp strips: the BOTTOM-UP source-rect convention (do NOT "fix" the flip) [T0/T1]
**The gauge blit addresses SOURCE rows bottom-up, and the vertical strip artwork is authored
to match.** `Video16BitBuffered::DrawBitMapOpaque` converts the incoming source rect with
`sTop = map_max_y - sTop; sBottom = map_max_y - sBottom` and walks rows upward
(`engine/MUNGA_L4/L4VB16.cpp:3846-3850`, `UP_BITMAP` :2155) [T0]; the 1995 display blit
@0046bdfc performs the IDENTICAL flip [T1 disasm], and the PCC→BitMap loader stores the PCX
top scanline at memory row 0 unflipped (`GRAPH2D.cpp:27-174`). Net: in `OneOfSeveral::Execute`
`sy0 = row*frameHeight` selects frames counting from the **BOTTOM** of the image as authored.
The vertical strips are painted for exactly this: `BTEMODE.PCC` (1×3) reads AUTO/MANUAL/OFF
**top-down**, i.e. OFF is the bottom frame = value 0 — so identity level→row draws the pod-
correct display (`0=OFF, 1=MANUAL, 2=AUTO`). Horizontal strips (`btebus`/`btpbus`/`bteloop`,
rows=1) are insensitive to the convention. **A "row = (rows-1)-selected" correction here is a
trap** — it was nearly landed 2026-07-28 on the assumption the blit is top-down; it would
invert every vertical lamp. (An adversarial workflow settled this against two of five
investigators; the port and the binary agree end-to-end.) Residual [T3]: the `DrawPixelMap8`
branch's source-y convention is untraced — verify before authoring any vertical PixMap8 strip.
**The dropped-colour-parameter family (2026-07-28):** the binary's `OneOfSeveralStates`
(@004c5470) and `OneOfSeveralInt` (@004c5148) ctors forward caller-supplied
background/foreground colours; the recon dropped both parameters and hardcoded `0,0`, so a
BitMap-strip lamp drew `SetColor(0)` + `DrawBitMapOpaque(0,…)` = invisible. Hit every one of:
`btemode` (connect mode, 1×3 — reported as "no Auto mode exists"), `btecmode` (coolant on/off,
1×2), both `bteseek` gear-step lamps (1×4). Cluster call sites pass `0xff,0`
(part_014.c:1479-1481, :2146-2147). Same bug class as issue #42's dropped `MoveToAbsolute`.
Also corrected: @004c552c is OneOfSeveralStates' **Execute** override (clamp ≥0, chain base),
not `BecameActive` — vtable-diffed.
## Key Relationships
- Full history: `docs/GAUGE_COMPOSITE.md`; reticle recovery: `phases/phase-02-dpl2d-reticle.md`.
- Uses: [[attribute-pointer]] + [[reconstruction-gotchas]]; reads [[subsystems]] state.
+14 -6
View File
@@ -1466,8 +1466,9 @@ SubsystemCluster::SubsystemCluster(
engPort, 0x254, 0x13b, "bteloop.pcc", 7, 1, subsystem_in, "CoolingLoop");
modeLamp = new OneOfSeveralInt(ChildRate(), eng_mode, renderer_in, // @004c5148
engPort, 400, 10, "btecmode.pcc", 1, 2,
(int *)((char *)subsystem_in + 0x134), "OneOfSeveralInt"); // BEST-EFFORT raw offset
engPort, 400, 10, "btecmode.pcc", 0xff, 0, 1, 2, // colours byte-verified:
(int *)((char *)subsystem_in + 0x134), "OneOfSeveralInt"); // part_014.c:1479-1481
// (+0x134 = coolantAvailable)
if (inputVoltage == NULL)
{
@@ -1488,9 +1489,16 @@ SubsystemCluster::SubsystemCluster(
// stateLamp gate: IsDerivedFrom 0x50f4bc = POWEREDSUBSYSTEM (#47 correction --
// the old 'Generator' label was wrong; every powered subsystem passes).
if (IsGeneratorDerived(subsystem_in))
// bg=0xff fg=0 -- byte-verified from the binary's own call site
// @004c866c (`push 3 / push 1 / push 0 / push 0xff / push
// "btemode.pcc"`). These were previously dropped and hardcoded 0/0
// inside the ctor, which painted the lamp in colour 0: the connect
// mode cycled correctly but nothing was ever drawn, so Auto looked
// like it did not exist.
stateLamp = new OneOfSeveralStates(ChildRate(), eng_mode, renderer_in, // @004c5470
engPort, 0xbe, 0, "btemode.pcc", 1, 3,
(Entity *)((char *)subsystem_in + 0x2b8), "OneOfSeveralStates"); // BEST-EFFORT raw
engPort, 0xbe, 0, "btemode.pcc", 0xff, 0, 1, 3,
(Entity *)((char *)subsystem_in + 0x2b8), "OneOfSeveralStates"); // +0x2b8 = modeAlarm
// (offset static_asserted)
else
stateLamp = NULL;
}
@@ -1748,7 +1756,7 @@ MyomerCluster::MyomerCluster(
(AttributeAccessor *)&seekValue, "edestryd.pcc", False, "EngrGraph");
seekStepLamp = new OneOfSeveralInt(ChildRate(), eng_mode, renderer_in, // @004c5148
engPort, 0xf, 0, "bteseek.pcc", 1, 4,
engPort, 0xf, 0, "bteseek.pcc", 0xff, 0, 1, 4, // colours byte-verified: part_014.c:2146-2147
// gauge-complete wave: 0x800 was OOB for a 0x358 Myomers (garbage) -> the seek
// gear step is Myomers::currentSeekVoltageIndex@0x320 (the analog of the
// EnergyWeaponCluster's Emitter::seekVoltageIndex@0x3f0; same 1..4 semantics).
@@ -1934,7 +1942,7 @@ EnergyWeaponCluster::EnergyWeaponCluster(
"edestryd.pcc", True, "EngrGraph");
seekStepLamp = new OneOfSeveralInt(ChildRate(), eng_mode, renderer_in, // @004c5148
engPort, 0xf, 0, "bteseek.pcc", 1, 4,
engPort, 0xf, 0, "bteseek.pcc", 0xff, 0, 1, 4, // colours byte-verified: part_014.c:2146-2147
(int *)((char *)subsystem_in + 0x3f0), "OneOfSeveralInt"); // BEST-EFFORT raw
}
+33 -9
View File
@@ -1556,8 +1556,8 @@ void
//
// @004c5390 Make / @004c5470 ctor -- OneOfSeveralStates (vtable PTR_FUN_00518b24):
// OneOfSeveral(fromImageStrip=1) + a StateConnection (FUN_004c3324) reading the
// subsystem state word @0x14. BecameActive @004c552c clamps the state >= 0
// before chaining to OneOfSeveral::Execute @004c4f28. dtor @004c5500.
// subsystem state word @0x14. @004c552c is the EXECUTE override (clamp >= 0,
// chain OneOfSeveral::Execute @004c4f28); BecameActive is inherited. dtor @004c5500.
//
//
@@ -1917,13 +1917,20 @@ void HorizTwoPartBar::Execute()
// @004c5148 -- OneOfSeveralInt ctor (vtable 0x518bac): OneOfSeveral(fromStrip=1) +
// a GaugeConnectionDirectOf<int> feeding the selected frame. dtor @004c51d8.
//
// Colours are CALLER-SUPPLIED (fixed 2026-07-28, same dropped-parameter bug as
// OneOfSeveralStates): hardcoding 0/0 into the base drew every OneOfSeveralInt
// lamp in colour 0 -- btecmode (coolant on/off) and both bteseek gear-step
// lamps were invisible. Binary: FUN_004c5148 forwards its args; call sites
// pass 0xff/0 (part_014.c:1479-1481 and :2146-2147).
OneOfSeveralInt::OneOfSeveralInt(
GaugeRate rate, ModeMask mode_mask, L4GaugeRenderer *renderer_in,
int graphics_port_number, int x, int y, const char *image,
int background_color, int foreground_color,
int columns, int rows, int *value_pointer, const char *identification_string
):
OneOfSeveral(rate, mode_mask, renderer_in, graphics_port_number, x, y,
True, image, 0, 0, columns, rows, identification_string)
True, image, background_color, foreground_color, columns, rows,
identification_string)
{
selected = 0;
AddConnection(new GaugeConnectionDirectOf<int>(0, &selected, value_pointer)); // FUN_004749de
@@ -1933,16 +1940,28 @@ OneOfSeveralInt::~OneOfSeveralInt() {} // @004c51d8
//
// @004c5470 -- OneOfSeveralStates ctor (vtable 0x518b24): OneOfSeveral + a
// StateConnection reading the subsystem state word @0x14. BecameActive @004c552c
// clamps the state >= 0 then chains OneOfSeveral::Execute. dtor @004c5500.
// StateConnection reading the subsystem state word @0x14. @004c552c is the
// EXECUTE override (clamp >= 0, chain the base); BecameActive is inherited.
// dtor @004c5500.
//
// THE COLOURS ARE CALLER-SUPPLIED (fixed 2026-07-28). This ctor used to drop
// them and hardcode 0/0 into the base. For a BitMap strip Execute does
// `SetColor(backgroundColor)` then `DrawBitMapOpaque(foregroundColor, ...)`, so
// 0/0 draws the lamp in colour 0 and it is invisible -- which is why the ENG
// page's connect-mode lamp (btemode.pcc) never appeared and pilots reported
// "there is no Auto setting": the mode was cycling correctly underneath an
// unpainted lamp. The binary's ctor @004c5470 forwards its own arguments to
// the base (only fromImageStrip is a literal 1), and its eng-page caller
// @004c866c pushes `0xff` then `0` -- background 0xFF, foreground 0.
OneOfSeveralStates::OneOfSeveralStates(
GaugeRate rate, ModeMask mode_mask, L4GaugeRenderer *renderer_in,
int graphics_port_number, int x, int y, const char *image,
int columns, int rows, Entity *subsystem_source, const char *identification_string
int background_color, int foreground_color, int columns, int rows,
Entity *subsystem_source, const char *identification_string
):
OneOfSeveral(rate, mode_mask, renderer_in, graphics_port_number, x, y,
True, image, 0, 0, columns, rows, identification_string)
True, image, background_color, foreground_color, columns, rows,
identification_string)
{
selected = 0;
AddConnection(new StateConnection(&selected, subsystem_source)); // FUN_004c3324
@@ -1950,11 +1969,16 @@ OneOfSeveralStates::OneOfSeveralStates(
OneOfSeveralStates::~OneOfSeveralStates() {} // @004c5500
void OneOfSeveralStates::BecameActive() // @004c552c
// @004c552c -- the EXECUTE override (corrected 2026-07-28; the port had this
// body on BecameActive). The binary's OneOfSeveralStates vtable was dumped and
// diffed against OneOfSeveral's: the one differing slot is Execute, whose body
// clamps a negative state to 0 and chains the base draw. On the base class
// BecameActive just forces a redraw (previousSelected = -1) -- inherited.
void OneOfSeveralStates::Execute() // @004c552c
{
if (selected < 0)
selected = 0;
OneOfSeveral::BecameActive();
OneOfSeveral::Execute();
}
//
+16 -3
View File
@@ -390,8 +390,14 @@
{
public:
static Logical Make(int, Vector2DOf<int>, Entity *, GaugeRenderer *);
// background/foreground are CALLER-SUPPLIED -- the binary's cluster call
// sites pass 0xff/0 (btecmode: part_014.c:1479-1481, bteseek: :2146-2147).
// They were dropped here and hardcoded 0/0, which drew these lamps in
// colour 0 = invisible (the same dropped-parameter bug as
// OneOfSeveralStates / the btemode connect-mode lamp).
OneOfSeveralInt(GaugeRate, ModeMask, L4GaugeRenderer *, int,
int x, int y, const char *image, int columns, int rows,
int x, int y, const char *image,
int background_color, int foreground_color, int columns, int rows,
int *value_pointer, const char *);
~OneOfSeveralInt(); // @004c51d8
};
@@ -425,11 +431,18 @@
{
public:
static Logical Make(int, Vector2DOf<int>, Entity *, GaugeRenderer *);
// background/foreground are CALLER-SUPPLIED (@004c5470 forwards its own
// args to the base; only fromImageStrip is a literal). They used to be
// dropped here and hardcoded 0/0 in the ctor -- see the note there.
OneOfSeveralStates(GaugeRate, ModeMask, L4GaugeRenderer *, int,
int x, int y, const char *image, int columns, int rows,
int x, int y, const char *image,
int background_color, int foreground_color, int columns, int rows,
Entity *subsystem_source, const char *);
~OneOfSeveralStates(); // @004c5500
void BecameActive(); // @004c552c
// @004c552c is the EXECUTE override (vtable-diffed 2026-07-28): clamp a
// negative state to 0, then chain OneOfSeveral::Execute. It was
// mislabeled BecameActive here; BecameActive is inherited from the base.
void Execute(); // @004c552c
};
+16 -1
View File
@@ -5518,12 +5518,27 @@ void
// task #12 scripted verify (BT_GENSEL_TEST=1): one SelectGeneratorD
// press at frame ~2500 -- the PPC re-taps from its authored GeneratorA
// onto GeneratorD; the charging I^2R then lands on GenD ([heat-t]).
// BT_GENSEL_TEST=<id> -- the id defaults to 7 (SelectGeneratorD, the
// original task-#12 verify). Set it to 8 to drive ToggleGeneratorMode
// and watch the three-level cycle: SelectGenerator leaves the subsystem
// at Connected(1), then presses run 1 -> Auto(2) -> Manual/off(0) ->
// Auto(2) -> ... Pulsed FOUR times so the whole cycle is observable in
// one headless run ([gensel] lines under BT_FIRE_LOG).
if ((Entity *)this == application->GetViewpointEntity()
&& getenv("BT_GENSEL_TEST"))
{
static int s_gsFrame = 0;
static const int s_gsID =
(atoi(getenv("BT_GENSEL_TEST")) > 0) ? atoi(getenv("BT_GENSEL_TEST")) : 7;
++s_gsFrame;
gBTGenSelKey = (s_gsFrame >= 600 && s_gsFrame < 610) ? 7 : 0;
int pulse = 0;
for (int p = 0; p < 4; ++p)
{
const int at = 600 + p * 150;
if (s_gsFrame >= at && s_gsFrame < at + 10)
pulse = 1;
}
gBTGenSelKey = pulse ? s_gsID : 0;
}
// task #13 scripted verify (BT_VALVE_TEST=1): one MoveValve press at
+23 -7
View File
@@ -122,13 +122,29 @@ CONTROLS (keyboard) -- NEW DEFAULT LAYOUT THIS BUILD:
weapon panel's red PROGRAM button with the mouse and tap a fire key;
the joystick diagram on the panel shows the binding live.
KNOWN ISSUE THIS BUILD -- a weapon that goes dark after you press ENG-page
buttons: the BUS MODE button detaches that weapon from its generator on
its second press, and the automatic re-attach is not working yet. The
symptom is a weapon whose recharge arc and ready dot are BOTH blank and
which will not fire, while its panel still shows normal data. THE FIX:
press one of the GENERATOR SELECT buttons (A/B/C/D) on that weapon's
panel and it comes straight back. Not a lost mech -- just that button.
FIXED THIS BUILD -- the automatic re-attach now works. Previously a weapon
detached by the BUS MODE button stayed dark for the rest of the mission and
you had to rescue it with a GENERATOR SELECT button. It now recovers by
itself, as the arcade did.
HOW POWER ROUTING WORKS (the ENG page). Each weapon draws from one
generator, and it has three routing modes:
GENERATOR SELECT A/B/C/D -- you pick that generator by hand.
BUS MODE, first press ---- AUTO: the mech hunts for any live generator
and attaches to it by itself. This is what
recovers a weapon whose generator died.
BUS MODE, second press --- detached. The weapon goes dark on purpose --
blank recharge arc, no ready dot, will not
fire. Press BUS MODE again for AUTO, or a
GENERATOR SELECT button to choose one.
So it is TWO presses of BUS MODE to take a weapon off line, and one more to
put it back on AUTO. There is no on-screen label for the current mode --
that is authentic, the pod had none either. You tell by behaviour: on AUTO
a weapon with a working generator comes back on its own.
NOTE these are two different buttons: BUS MODE routes a WEAPON to a
generator, while the GENERATOR A-D buttons on the radar rail (F9-F12) turn
the GENERATORS THEMSELVES on and off.
An Xbox controller works out of the box. FLIGHT STICKS / HOTAS /
pedals: run joyconfig.bat ONCE -- a wizard asks you to move each