SeekVoltageGraph: full reconstruction -- the eng-page POWER graph + top-box eraser (Gitea #11)

The #10 audit's one WRONG: the port Execute was a bring-up no-op, so the
emitter/myomer engineering pages never erased their top data box -> stale
sibling-page ghosts on the shared Eng bit-plane (SYSTEM 10 PPC showing the
Streak ammo box, etc).  Full faithful widget landed:

- btl4gau2: ctor/BecameActive/Execute/clear/ticks/cursor recovered from the
  capstone disasm of @004c6798/@004c6920/@004c6934/@004c6be4/@004c6c30/
  @004c6c6c (Ghidra dropped every x87 arg).  Plot: v=0..12000 step 1200,
  x=Round(response(v)*230), y=Round(v*(1/12000)*187) (ld80 @004c6bd0/@004c6d74
  = exactly 1/12000); change-test samples the response at 12000V vs the 9999
  activation sentinel; XOR op for tick/cursor move-by-redraw; destroyed branch
  centres edestryd.pcc and revives via own vtbl+0xC (BecameActive, slot 3 of
  PTR_0051a1fc -- vtable-dump verified).
- The vtbl+0x3C sampler identified from the binary vtables: Emitter slot 15
  @004bb42c = sqrt(P(v)/2.0e8), P @004bb3f4 = damageFraction*v^2*0.5*
  energyCoefficient; Myomers slot 15 @004b8f94 = sqrt(AvailableOutput(v)*3.6/
  350).  FUN_004dd138 == sqrt (part_015.c:4026): myomers' fabs reading
  corrected, GetSpeedReading renamed SeekVoltageResponse.  Port dispatch via
  complete-type bridges BTSeekVoltageSample/BTMyomersSeekSample +
  BTSubsystemDestroyed (databinding rule).
- Emitter's AUTHENTIC attribute table recovered (binary @0x511dd4, ids
  0x1D-0x25) and published: Laser*/Seek*/OutputVoltage@0x414 (currentLevel,
  RAW volts -- the live-cursor feed).  Field renames per the table:
  0x3F8 minSeekVoltageIndex / 0x3FC maxSeekVoltageIndex (static_assert-locked).
  The MechWeapon 0x1D OutputVoltage PORT ALIAS retired (binary table ends
  0x1C; Find walks lowest-id-first, the alias shadowed the authentic row).
  PPC::DefaultData -> Emitter::GetAttributeIndex().
- mech4: BT_PRESET_HOLD=<n> (freeze the #9 preset cycler after n pulses) for
  steady-state pixel verification; BT_SEEK_LOG diag.

Verified live (BLH, autofire): both #10 repro pairs held ghost-free for
minutes (SYS09->SYS10 PPC, SYS02->SYS04 ERMed, SYS05->SYS06 Myomers); curves
draw with live cursors; one replot per activation; quad panels/J-K-L/sec
panel un-regressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-19 20:27:39 -05:00
co-authored by Claude Fable 5
parent f1a6ccbaaf
commit 63b168cb92
15 changed files with 594 additions and 109 deletions
+97 -7
View File
@@ -73,6 +73,7 @@
# include <testbt.hpp>
#endif
#include <hostmgr.hpp> // HostManager::GetEntityPointer -- the update-record target resolve (task #51)
#include <math.h> // sqrt -- the SeekVoltageResponse sampler (issue #11)
// E8 (bring-up): the player fire input. The controls mapper that would normally write
// the weapon's fireImpulse is bypassed (mech4.cpp), so EmitterSimulation reads this
@@ -153,13 +154,49 @@ Receiver::MessageHandlerSet&
MechWeapon::GetMessageHandlers()); // copy-inherit
return messageHandlers;
}
Simulation::AttributeIndexSet Emitter::AttributeIndex;
//
// issue #11: the AUTHENTIC Emitter attribute table (binary @0x511dd4, dumped
// records {id, name*, offset|1, 0}, ids 0x1D..0x25 -- a dense run chained
// straight after MechWeapon's 0x1C WeaponState, so no pads needed). The four
// Seek* rows are what the SeekVoltageGraph ctor (@004c6798) resolves by name;
// OutputVoltage here binds currentLevel@0x414 -- the RAW charge voltage (the
// graph's live-cursor feed), which the retired MechWeapon 0x1D port alias
// (rechargeLevel, 0..1) used to shadow. Function-local statics per the
// static-init-order rule (reconstruction-gotchas #9).
//
// The dense-prefix lock (gotcha #11): the run must start exactly one past
// MechWeapon's last id (0x1C WeaponState) == the binary's own numbering.
static_assert((int)Emitter::LaserOnAttributeID == 0x1D,
"Emitter attr run must chain at 0x1D (binary table @0x511dd4) -- "
"did a MechWeapon attribute get added/removed?");
Emitter::AttributeIndexSet&
Emitter::GetAttributeIndex()
{
static const Emitter::IndexEntry entries[] =
{
ATTRIBUTE_ENTRY(Emitter, LaserOn, firingActive), // 0x1D @0x418
ATTRIBUTE_ENTRY(Emitter, LaserScale, beamScale), // 0x1E @0x42C
ATTRIBUTE_ENTRY(Emitter, LaserRotation, beamOrientation), // 0x1F @0x41C
ATTRIBUTE_ENTRY(Emitter, CurrentSeekVoltageIndex, seekVoltageIndex), // 0x20 @0x3F0
ATTRIBUTE_ENTRY(Emitter, RecommendedSeekVoltageIndex, seekVoltageRecommendedIndex), // 0x21 @0x3F4
ATTRIBUTE_ENTRY(Emitter, MinSeekVoltageIndex, minSeekVoltageIndex), // 0x22 @0x3F8
ATTRIBUTE_ENTRY(Emitter, MaxSeekVoltageIndex, maxSeekVoltageIndex), // 0x23 @0x3FC
ATTRIBUTE_ENTRY(Emitter, SeekVoltage, seekVoltage), // 0x24 @0x400 (array base)
ATTRIBUTE_ENTRY(Emitter, OutputVoltage, currentLevel) // 0x25 @0x414 (RAW volts)
};
static Emitter::AttributeIndexSet attributeIndex(
ELEMENTS(entries), entries,
MechWeapon::GetAttributeIndex()
);
return attributeIndex;
}
Emitter::SharedData
Emitter::DefaultData(
&Emitter::ClassDerivations,
Emitter::GetMessageHandlers(),
MechWeapon::GetAttributeIndex(), // gauge wave: inherit temps/InputVoltage/OutputVoltage/PercentDone (was empty)
Emitter::GetAttributeIndex(), // own table (Seek*/Laser*/OutputVoltage) chained to MechWeapon's
Emitter::StateCount
);
@@ -590,7 +627,7 @@ int
}
int prev = seekVoltageIndex; // 0x3f0
int modulus = seekVoltageCount + 1; // 0x3fc + 1
int modulus = maxSeekVoltageIndex + 1; // 0x3fc + 1
int next = (prev + 1) % modulus;
seekVoltageIndex = next; // 0x3f0
if (prev < next)
@@ -663,6 +700,35 @@ void
}
}
//
// @004bb3f4 -- the seek discharge power at a supply voltage (issue #11,
// capstone disasm: fld v; fmul v; fmul [0x4bb428]=0.5f; fmul [this+0x454];
// -> * [this+0x444]):
// P(v) = damageFraction * (v^2 * 0.5 * energyCoefficient)
// i.e. the damage share of the E = 1/2 k V^2 discharge energy at voltage v.
//
Scalar
Emitter::SeekPower(Scalar voltage)
{
return damageFraction * (voltage * voltage * 0.5f * energyCoefficient); // 0x444 / 0x454
}
//
// @004bb42c (vtable slot 15) -- the SeekVoltageGraph voltage-response sampler
// (issue #11). Disasm: locals A = 0x4d3ebc20 (exactly 2.0e8f), B = 0;
// r = (SeekPower(v) - B) / (A - B); FUN_004dd138(r) == sqrt (part_015.c:4026 --
// the old "fp magnitude" reading was wrong). So: the graph's x-axis is the
// square root of the discharge power normalised to a 2.0e8 full scale.
//
Scalar
Emitter::SeekVoltageResponse(Scalar voltage)
{
Scalar r = (SeekPower(voltage) - 0.0f) / (2.0e8f - 0.0f); // 0x4d3ebc20
// (FUN_004dd138 routes a negative operand to the matherr handler; P >= 0
// always here -- guarded for port safety.)
return (r > 0.0f) ? (Scalar)sqrt((double)r) : 0.0f;
}
//#############################################################################
// Subsystem virtual overrides
@@ -855,7 +921,7 @@ Emitter::Emitter(
energyCoefficient = 1.0f; // 0x454
seekVoltageIndex = 0; // 0x3f0
seekVoltageRecommendedIndex = 0; // 0x3f4
seekVoltageCount = 0; // 0x3fc
maxSeekVoltageIndex = 0; // 0x3fc
for (int sv = 0; sv < 5; ++sv)
seekVoltage[sv] = 1.0f; // 0x400[]
@@ -894,7 +960,7 @@ Emitter::Emitter(
{
if (subsystem_resource->seekVoltage[i] == -1.0f) // _DAT_004bb3b0 sentinel
{
seekVoltageCount = i - 1; // 0x3fc
maxSeekVoltageIndex = i - 1; // 0x3fc (= count-1)
break;
}
// the authored values are FRACTIONS of the generator's rated
@@ -904,7 +970,7 @@ Emitter::Emitter(
}
seekVoltageRecommendedIndex = subsystem_resource->seekVoltageRecommendedIndex; // 0x3f4 <- +0x1d8
seekVoltageIndex = seekVoltageRecommendedIndex; // 0x3f0
seekStepCounter = 0; // 0x3f8
minSeekVoltageIndex = 0; // 0x3f8
// energyCoefficient = energyTotal / (seekVoltage[rec]^2 * 0.5)
// -> E = 0.5 * V^2 * EC lands exactly on energyTotal at V=seekV[rec]
@@ -935,7 +1001,7 @@ Emitter::Emitter(
// damageFraction = damageAmount / (damageAmount + heatCostToFire)
damageFraction = damageData.damageAmount /
(damageData.damageAmount + heatCostToFire); // 0x444
seekStepCounter = 0; // 0x3f0/0x45c bookkeeping
minSeekVoltageIndex = 0; // 0x3f8 (always 0 -- attr 0x22)
Check_Fpu();
}
@@ -1063,3 +1129,27 @@ Subsystem *CreateEmitterSubsystem(Mech *owner, int id, void *seg)
return (Subsystem *) new (Memory::Allocate(0x478))
Emitter(owner, id, (Emitter::SubsystemResource *)seg, Emitter::DefaultData);
}
//===========================================================================//
// issue #11 bridge -- the SeekVoltageGraph's voltage-response sample (the
// binary calls the SUBSYSTEM vtbl+0x3C / slot 15 virtually; the port's gauge
// TU cannot include the weapon headers, so the dispatch lives here in a
// complete-type TU per the databinding rule, gotcha 8). Emitter (and PPC,
// which IS an Emitter both by derivation and by factory) -> @004bb42c;
// everything else forwards to the Myomers bridge (myomers.cpp, @004b8f94).
//===========================================================================//
extern Scalar BTMyomersSeekSample(void *subsystem, Scalar voltage); // myomers.cpp
Scalar BTSeekVoltageSample(void *subsystem, Scalar voltage)
{
Entity *entity = (Entity *)subsystem;
if (entity == 0)
{
return 0.0f;
}
if (entity->IsDerivedFrom(Emitter::ClassDerivations))
{
return ((Emitter *)entity)->SeekVoltageResponse(voltage); // @004bb42c
}
return BTMyomersSeekSample(subsystem, voltage); // @004b8f94 (guarded)
}