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
+91
View File
@@ -0,0 +1,91 @@
import struct
data = open(r'C:/git/bt411/content/BTL4.RES','rb').read()
n = len(data)
def cstr(off, maxlen=32):
s = data[off:off+maxlen].split(b'\0')[0]
try: return s.decode('ascii')
except: return None
def f32(off): return struct.unpack_from('<f', data, off)[0]
def i32(off): return struct.unpack_from('<i', data, off)[0]
DMGTYPE = {0:'Collision',1:'Ballistic',2:'Explosive',3:'Laser',4:'Energy'}
# find ALL subsystem records: printable name @+0, classID @+0x20 in 0xBB0..0xBE0, size @+0x24 plausible
recs = []
i = 0
while i < n - 0x28:
cid = struct.unpack_from('<I', data, i+0x20)[0]
if 0xBB0 <= cid <= 0xBE5:
sz = struct.unpack_from('<I', data, i+0x24)[0]
if 0xE0 <= sz <= 0x300:
name = cstr(i)
if name and 2 <= len(name) <= 31 and all(32 < ord(c) < 127 for c in name):
# name buffer should be null-padded to 32
pad = data[i+len(name):i+0x20]
if pad.count(0) >= len(pad) - 2:
recs.append((i, name, cid, sz))
i += 0x28
continue
i += 1
print(f'total subsystem records: {len(recs)}')
# group into chains (consecutive: next record at p+sz)
chains = []
cur = []
for k,(p,name,cid,sz) in enumerate(recs):
if cur and cur[-1][0] + cur[-1][3] == p:
cur.append(recs[k])
else:
if cur: chains.append(cur)
cur = [recs[k]]
if cur: chains.append(cur)
# mech-name strings for labeling
mechnames = [b'madcat', b'bhk', b'vulture', b'thor', b'blackhawk', b'blh', b'owens', b'strider', b'sunder', b'cauldron', b'avatar', b'mdc']
low = data.lower()
namepos = []
for m in mechnames:
j = 0
while True:
j = low.find(m, j)
if j < 0: break
namepos.append((j, m.decode()))
j += 1
namepos.sort()
def nearest_label(p):
best = None
for pos, name in namepos:
if pos < p: best = (pos, name)
else: break
return best
WEAP = {0xBC8:'Emitter', 0xBD4:'PPC', 0xBCD:'MechWeapon/Projectile', 0xBCE:'GaussRifle?', 0xBD0:'MissileLauncher'}
for ci, ch in enumerate(chains):
lbl = nearest_label(ch[0][0])
has_weap = any(c[2] in WEAP for c in ch)
print(f'--- chain {ci} @0x{ch[0][0]:x} n={len(ch)} nearest-name={lbl} roster={[c[1] for c in ch]}')
for (p,name,cid,sz) in ch:
if cid not in WEAP: continue
rr, wr = f32(p+0x190), f32(p+0x194)
dmg = f32(p+0x19C); dt = i32(p+0x1A0); heat = f32(p+0x1A4)
pip = i32(p+0x1A8); pc = (f32(p+0x1AC), f32(p+0x1B0), f32(p+0x1B4)); per = i32(p+0x1B8)
line = (f' {name:24s} cid=0x{cid:X}({WEAP[cid]}) sz=0x{sz:X} Recharge={rr:g} Range={wr:g} '
f'Damage={dmg:g} Type={dt}({DMGTYPE.get(dt,"?")}) Heat={heat:g} Pip={pip} PipColor={pc} PipExt={per}')
if cid in (0xBC8, 0xBD4) and sz >= 0x1DC:
gl, dtm = f32(p+0x1BC), f32(p+0x1C0)
sv = [f32(p+0x1C4+4*k) for k in range(5)]
ri = i32(p+0x1D8)
line += f' | GraphicLen={gl:g} DischargeTime={dtm:g} SeekV={["%g"%v for v in sv]} RecIdx={ri}'
if cid in (0xBCD, 0xBD0) and sz >= 0x1D0:
ti, abi = i32(p+0x1BC), i32(p+0x1C0)
mtof, mvp, mjc = f32(p+0x1C4), f32(p+0x1C8), f32(p+0x1CC)
line += f' | TracerInt={ti} AmmoBin={abi} MinTOF={mtof:g} MinVolt%={mvp:g} MinJam={mjc:g}'
if cid == 0xBD0 and sz >= 0x1E0:
mc = i32(p+0x1D0); mv = (f32(p+0x1D4), f32(p+0x1D8), f32(p+0x1DC))
line += f' | MissileCount={mc} MuzzleVel={mv}'
print(line)