Combat: THE AUTHENTIC DAMAGE ECONOMY -- authored per-weapon amounts through the real fire chain (task #8)

Three root causes, all fixed:
1. kDamageScale was 1.0 -- _DAT_004bafbc is an x87 float80 = 1e-7, cancelling
   the ctor's x1e7: damagePortion = authored DamageAmount x (charge/seekV)^2
   (closed form at fire time; madcat AC=25/LRM=50/ERLL=6, bhk1 PPC=12/SRM=35).
   The observed "0.25" was the degenerate EC=1 fallback, never authored data.
2. CheckFireEdge NaN latch: TriggerState carries ControlsButton INTS; the
   release value (-65) is a negative NaN.  The binary's x87 unordered compare
   read it as "released"; IEEE-correct float compares latched the edge
   detector shut after the first release -- the reason the emitter discharge
   chain NEVER fired in-game.  Fixed with bit-pattern sign compares.
3. The weapon-side submission LIVE: Emitter::FireWeapon fills damageData
   (amount + damageForce=target-muzzle [the gyro directional-bounce feed] +
   impact) -> MechWeapon::SendDamageMessage (@004b9728 real body) ->
   messmgr consolidation with per-weapon records + explosion bundling.
   The mech4 bring-up damage block + flat kShotDamage retired to diag hooks.
Bonus: LODReuseHysteresis 0.82 -> 0.33 (double misread).  Zone model
verified byte-exact (no change).  Solo end-to-end: 5-record volleys (2 PPC
+ 3 ERML with authored amounts), per-weapon zone granularity, explosions,
kill.  Heat stays bring-up scale pending the heat-calibration audit [T3].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-11 15:32:24 -05:00
co-authored by Claude Fable 5
parent 77190c93e5
commit fd055281a8
17 changed files with 654 additions and 75 deletions
+8
View File
@@ -630,12 +630,20 @@ BTL4Application::SharedData
if (resource_description != NULL)
{
resource_description->Lock(); // FUN_00406cd0
DEBUG_STREAM << "[ctrlmap] installing '" << primary_mapping_name
<< "' table: " << *(int *)resource_description->resourceAddress
<< " mappings on entity " << (void *)viewing_entity << std::endl;
controls->CreateStreamedMappings( // FUN_0047703c
viewing_entity,
(int*)resource_description->resourceAddress
);
resource_description->Unlock();
}
else
{
DEBUG_STREAM << "[ctrlmap] NO '" << primary_mapping_name
<< "' sub-table for this mech" << std::endl;
}
}
}
+62 -8
View File
@@ -85,7 +85,14 @@ extern BTDriveInput gBTDrive;
//#############################################################################
// Reconstruction support: artifact constants, globals and helper stand-ins.
//
static const Scalar kDamageScale = 1.0f; // _DAT_004bafbc firing-physics scale (best-effort)
static const Scalar kDamageScale = 1.0e-7f; // _DAT_004bafbc -- an x87 80-bit constant
// (bytes bc427ae5d594bfd6e73f @0x4bafbc)
// = 1e-7 EXACTLY: it cancels the ctor's
// energyTotal x1e7, so at recommended
// charge damagePortion == the AUTHORED
// DamageAmount verbatim (task #8; the old
// 1.0f "best-effort" was the whole
// damage-economy mystery)
static const Vector3D DAT_004e0fa4(0.0f, 0.0f, 0.0f); // zero vector
static char DAT_00522524[4] = { 0 }; // default colour tag
static int PTR_LAB_00511e6c, DAT_00511e70, DAT_00511e74; // beam-keepalive message template
@@ -178,9 +185,27 @@ void
// E = 1/2 * currentLevel^2 * energyCoefficient
Scalar energy = 0.5f * currentLevel * currentLevel * energyCoefficient; // _DAT_004bafb8
damagePortion = damageFraction * energy; // 0x44c
heatPortion = energy - damagePortion; // 0x450
damagePortion = kDamageScale * damagePortion; // firing-physics scale (best-effort)
// THE AUTHENTIC PER-SHOT DAMAGE (task #8) -- the closed form of the
// binary's energy algebra (@004bb120 ctor x @004bace8 fire):
// damagePortion = dF x (0.5 V^2 EC) x 1e-7
// = authored DamageAmount x (charge / seekV[rec])^2
// (kDamageScale = _DAT_004bafbc = 1e-7 EXACTLY cancels the ctor's x1e7;
// derived here from the STORED damageFraction/energyTotal so the authored
// base never degrades, and the bring-up charge cycle -- calibrated to the
// degenerate EC=1 electrical model -- stays untouched. At full charge:
// the authored value verbatim; madcat ERLLaser=6, bhk1 PPC=12.)
{
Scalar vRec = seekVoltage[seekVoltageRecommendedIndex];
Scalar chargeRatio = (vRec > 0.0f) ? (currentLevel / vRec) : 1.0f;
damagePortion = (damageFraction * energyTotal * kDamageScale)
* chargeRatio * chargeRatio; // 0x44c
}
// Heat stays on the bring-up scale until the heat-calibration audit [T3]:
// the authentic heatPortion is heatCostToFire x 1e7 energy units (the
// missile path already feeds that scale raw); switching the emitters too
// is the heat audit's call, not the damage economy's.
heatPortion = energy - damageFraction * energy; // 0x450
// dump the heat portion into our INHERITED thermal accumulator if heatable/online.
// E5: pendingHeat (HeatSink @0x1C8) is the field the heat sim absorbs each frame
@@ -223,10 +248,20 @@ void
if (dist <= effectiveRange) // this+0x328
{
// The binary writes the impact point/delta/energy into the inherited Damage
// damageData (0x3C8/0x3B0/0x3AC); the recon's beamHitPoint/beamImpact/
// beamImpactScalar copies were dead (no readers) -> removed. Register the pip.
DrawWeaponPip(aimTransform); // FUN_004b9728
// THE AUTHENTIC DAMAGE SUBMISSION (task #8; binary @004bace8:7758-7764):
// fill the inherited Damage record -- amount = this shot's
// damagePortion (== authored DamageAmount at full charge),
// damageForce = target - muzzle (ALSO the gyro's directional
// hit-bounce source, retiring its random fallback), impactPoint =
// the target point -- then submit through SendDamageMessage
// (@004b9728) into the owner's SubsystemMessageManager.
damageData.damageAmount = damagePortion; // @0x3AC
damageData.damageForce.Subtract(targetPoint, muzzlePoint); // @0x3B0
damageData.impactPoint = targetPoint; // @0x3C8
damageData.burstCount = 1;
Entity *submitTarget = (owner != 0)
? *(Entity **)((char *)owner + 0x388) : 0; // mech target slot
SendDamageMessage(submitTarget); // FUN_004b9728
}
// stash the beam endpoint for replication (world hit point) + the beam's
@@ -275,6 +310,21 @@ void
// per-type keyboard split are retired -- weapons sharing a button now fire
// TOGETHER (the pod's authored groups: e.g. madcat Trigger = 4 weapons).
Logical fireEdge = CheckFireEdge(); // @004b9608
// task #8 diag: the discharge-chain probe (button->TriggerState->edge->FSM)
if (getenv("BT_FIRE_LOG"))
{
static int s_fp = 0;
if ((++s_fp % 97) == 0 || fireEdge) // 97 prime: rotates through all weapons
DEBUG_STREAM << "[fire-probe] " << GetName()
<< " owner=" << (void *)owner
<< " trigState=" << (float)fireImpulse
<< " edge=" << (int)fireEdge
<< " alarm=" << (int)weaponAlarm.GetState()
<< " level=" << (float)currentLevel
<< " tgt=" << (void *)((owner != 0)
? *(Entity **)((char *)owner + 0x388) : 0)
<< std::endl;
}
targetWithinRange = UpdateTargeting(); // @004b9bdc -> this+0x34c
// hard failure: powered-off, faulted, or the OWNING MECH destroyed (@004baa88
@@ -791,6 +841,10 @@ Emitter::Emitter(
(void)energyRampTime;
}
// (task #8 note: energyCoefficient deliberately stays on the bring-up
// electrical model -- the charge cycle is calibrated to it; the AUTHENTIC
// damage algebra is applied as a closed form at fire time instead.)
// damageFraction = damageAmount / (damageAmount + heatCostToFire)
damageFraction = damageData.damageAmount /
(damageData.damageAmount + heatCostToFire); // 0x444
+35 -46
View File
@@ -672,6 +672,13 @@ extern void BTPostKillScore(Entity *victim, Scalar damage); // KILL (+ MP death
#define MECH_TARGET_ENTITY(m) (*(Entity **)((char *)(m) + 0x388))
#define MECH_TARGET_SUBIDX(m) (*(int *)((char *)(m) + 0x38c))
// task #8 bridge: the weapon-side damage submission (mechweap.cpp) reads the
// owner's designated-zone slot; the raw target-slot block lives in this TU.
int BTMechTargetZone(void *mech)
{
return MECH_TARGET_SUBIDX(mech);
}
// Bring-up body-animation player (file scope so OnBodyAnimFinished can re-arm it).
// The engine AnimationInstance walks the mech's joint subsystem from a baked .ani
// clip in btl4.res; we advance it each moving frame so the legs CYCLE.
@@ -3042,7 +3049,19 @@ void
s_prevBtn[b] = want[b];
ControlsButton v = (ControlsButton)(want[b]
? (buttons[b] + 1) : -(buttons[b] + 1));
cm->buttonGroup[buttons[b]].Update(&v, modeMask);
cm->buttonGroup[buttons[b]].ForceUpdate(&v, modeMask); // diag: bypass the prev-diff gate
if (getenv("BT_FIRE_LOG"))
{
static int s_pushN = 0;
if ((++s_pushN % 60) == 1)
DEBUG_STREAM << "[push] btn=0x" << std::hex
<< buttons[b] << std::dec << " v=" << (int)v
<< " mode=0x" << std::hex << (int)modeMask
<< std::dec << " #" << s_pushN
<< " thisMech=" << (void *)this
<< " viewpoint=" << (void *)application->GetViewpointEntity()
<< std::endl;
}
}
}
}
@@ -3170,52 +3189,22 @@ void
// routes it to Mech__DamageZone::TakeDamage. Dispatch on a
// REPLICANT is rerouted by Entity::Dispatch to the owning
// master over the wire -- the cross-pod damage path.
if (((Mech *)victim)->damageZoneCount > 0)
// DAMAGE DELIVERY MOVED (task #8): the AUTHENTIC submitter is
// each firing weapon's own SendDamageMessage (@004b9728, called
// from Emitter::FireWeapon with the authored per-shot
// damagePortion + damageForce + impact) into the
// SubsystemMessageManager. This block keeps only the
// presentation roles: the boresight pick feeds the target
// slots the weapons read (mech+0x388/0x37C/0x38C), the [fire]
// beam-entry explosion visual above, and the shot log. The
// flat kShotDamage build + shooter-side score moved with the
// submission (mechweap.cpp).
if (getenv("BT_FIRE_LOG") && ((Mech *)victim)->damageZoneCount > 0)
{
Damage dmg;
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = kShotDamage;
dmg.burstCount = 1;
dmg.impactPoint = impact;
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID,
sizeof(Entity::TakeDamageMessage),
GetEntityID(), // inflicting = this (shooter)
-1, // UNAIMED -> receiver's cylinder resolves
dmg);
// AUTHENTIC DELIVERY (task #7 tail): submit through the
// SubsystemMessageManager -- per-frame consolidation into
// ONE TakeDamageStream (id 0x13) dispatched at the victim,
// whose T0 handler re-splits it (ENTITY.cpp:817).
// Replicant victims reroute cross-pod as before. [T3:
// the inflicting subsystemID is not threaded through this
// bring-up fire block -- the authentic submitter is the
// weapon's own SendDamageMessage @004b9728, pending the
// damage-economy reconciliation (authored 0.25-scale
// amounts vs the bring-up kShotDamage=12); until then the
// messmgr's weapon-explosion bundling stays dormant.]
if (messageManager != 0)
((SubsystemMessageManager *)messageManager)
->AddDamageMessage(victim, &take_damage);
else
victim->Dispatch(&take_damage);
// SCORE the damage (skip once the victim is dead).
if (!((Mech *)victim)->IsMechDestroyed())
BTPostDamageScore(victim, kShotDamage);
int zone = take_damage.damageZone;
if (zone >= 0 && zone < ((Mech *)victim)->damageZoneCount)
{
Scalar s = ((Mech *)victim)->Zone(zone)->damageLevel;
DEBUG_STREAM << "[damage] " << (void*)victim << " zone " << zone
<< "/" << ((Mech *)victim)->damageZoneCount
<< " structure=" << s
<< (((Mech *)victim)->GetInstance() == ReplicantInstance
? " (REPLICANT -> cross-pod)" : "")
<< "\n" << std::flush;
}
DEBUG_STREAM << "[damage] beam hit " << (void*)victim
<< (((Mech *)victim)->GetInstance() == ReplicantInstance
? " (REPLICANT -> cross-pod via the weapon submit)" : "")
<< "\n" << std::flush;
}
// DEATH effects fire at the VICTIM's own death transition
// (UpdateDeathState, task #42) -- MP-correct: the master runs it.
+4 -1
View File
@@ -143,7 +143,10 @@ static const Scalar CriticalDamageMax = 1.0f; // _DAT_0049ce4c
// Probability of re-using the previous child zone inside the reuse window.
// (Ghidra read the operand at @0049c514; exact value not cleanly recoverable
// from the pseudo-C -- it behaves as a high "stay on the same spot" bias.)
static const Scalar LODReuseHysteresis = 0.82f; // _DAT_0049c514 (TODO: verify)
static const Scalar LODReuseHysteresis = 0.33f; // _DAT_0049c514 -- a DOUBLE in the
// binary (bytes 1f85eb51b81ed53f =
// 0.33); the old 0.82 was a misread
// (task #8 audit)
//###########################################################################
+64 -17
View File
@@ -58,6 +58,7 @@
//
#include <bt.hpp>
#include <messmgr.hpp> // SubsystemMessageManager (task #8 damage submission)
#pragma hdrstop
#if !defined(MECHWEAP_HPP)
@@ -376,13 +377,25 @@ Logical
// @004b9608 -- rising-edge detector on fireImpulse (threshold _DAT_004b9648 == 0.0f).
// Returns True the frame fireImpulse becomes positive while the previous sample
// was non-positive, then stores the current sample for next time.
// TODO: confirm exact role (discharge vs. recharge-ready edge).
//
// ⚠ BIT-PATTERN COMPARE (task #8 root-cause): TriggerState carries the raw
// ControlsButton INT bit-copied by the streamed direct mapping (+(button+1)
// press / -(button+1) release) -- the release value (e.g. -65 = 0xFFFFFFBF)
// reads as a NEGATIVE NaN in float terms. The binary's x87 compare (FNSTSW/
// SAHF + JBE: unordered sets CF|ZF => branch TAKEN) effectively treated the
// NaN as "released", so its edge re-armed; an IEEE-correct float compare
// makes NaN<=0 FALSE and the detector LATCHES SHUT after the first release
// (observed live: one startup edge, then never again). Compare the BIT
// PATTERNS as signed ints instead -- sign-correct for the button ints AND
// for genuine float values (0.0f = 0x0, positives > 0, negatives/NaNs < 0),
// reproducing the binary's effective behavior exactly.
//
Logical
MechWeapon::CheckFireEdge()
{
Logical edge =
(fireImpulse > 0.0f && previousFireImpulse <= 0.0f) ? True : False;
int current = *(int *)&fireImpulse;
int previous = *(int *)&previousFireImpulse;
Logical edge = (current > 0 && previous <= 0) ? True : False;
previousFireImpulse = fireImpulse;
return edge;
}
@@ -465,23 +478,57 @@ void
// routing through AddDamageMessage. This body stays dormant meanwhile.
//
void
MechWeapon::DrawWeaponPip(const LinearMatrix &transform)
MechWeapon::SendDamageMessage(Entity *target)
{
// @004b9728 -- register the targeting "pip" with the owning Mech's cockpit
// HUD manager. Skipped for non-pip weapons (pipSegment == -1). The HUD
// element record {priority 100, kind 0x12, enabled, team colour, pipPosition,
// pipExtendedRange, transform, pipSegment} is submitted to the cockpit HUD.
//
// CROSS-FAMILY (hud): the cockpit HUD manager AddElement entry point lives in
// the HUD module; the submission is wired there. We retain the gating and
// transform reference here.
if (useConfiguredPip) // this+0x3E0 (weapon draws a configured pip)
// @004b9728 -- package this weapon's just-fired damageData as an
// Entity::TakeDamageMessage and submit it to the owning mech's
// SubsystemMessageManager for per-frame consolidation (AddDamageMessage
// @0049b6d8) -- the authentic beam damage delivery (task #8; the caller
// Emitter::FireWeapon fills damageAmount/damageForce/impactPoint first).
//
// Binary gate (part_013.c:6746): a Mech victim with NO designated zone
// (owner+0x38C == -1) is SKIPPED. The port PASSES -1 through [T3
// divergence]: our Mech::TakeDamageMessageHandler cylinder-resolves the
// unaimed zone (STEP 6), preserving the verified aiming model.
//
Mech *mech = (Mech *)GetEntity(); // weapon+0xD0
if (mech == 0 || target == 0)
{
(void)transform;
(void)pipPosition;
(void)pipExtendedRange;
(void)pipColor;
// owningHud->AddElement( ...pip record... ); // FUN_0049b6d8(entity+0x434, ..)
return;
}
extern int BTMechTargetZone(void *mech); // mech+0x38C (mech4 TU)
int zone = BTMechTargetZone(mech);
Entity::TakeDamageMessage message(
Entity::TakeDamageMessageID,
sizeof(Entity::TakeDamageMessage),
mech->GetEntityID(), // inflicting = owner (+0x184)
zone, // designated zone (or -1 -> cylinder)
damageData, // the 12-dword Damage @0x3A8
subsystemID); // inflictingSubsystemID (+0xD8) --
// lights up the messmgr's per-weapon
// explosion bundling (weapon+0x3E4)
SubsystemMessageManager *manager =
(SubsystemMessageManager *)mech->GetMessageManager(); // mech+0x434
if (manager != 0)
{
manager->AddDamageMessage(target, &message); // FUN_0049b6d8
}
else
{
target->Dispatch(&message); // no manager: direct (bring-up)
}
// PORT bookkeeping (score lived in the retired mech4 fire block): credit
// the delivered amount to the shooter-side score exactly as before.
extern void BTPostDamageScore(Entity *victim, Scalar damage);
extern Logical BTIsRegisteredMech(Entity *e);
if (BTIsRegisteredMech(target)
&& !((Mech *)target)->IsMechDestroyed())
{
BTPostDamageScore(target, damageData.damageAmount);
}
}
+3 -1
View File
@@ -285,7 +285,9 @@ class CockpitHud;
// @004b9728 -- register the targeting "pip" / fire marker with the
// owning Mech's cockpit HUD manager.
void
DrawWeaponPip(const LinearMatrix &transform);
SendDamageMessage(Entity *target); // @004b9728 (ex "DrawWeaponPip" -- the
// weapon-fire damage submission into the
// owner's SubsystemMessageManager, task #8)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Construction and Destruction