Combat visible + killable: Wword root-cause fix, .PFX effect layer, RemakeEntity swap

The 'can't kill the enemy / no visible damage' cluster, root-caused and fixed
faithfully:

- STEP-6 unaimed path was INERT: the cylinder table was 'cached' at Wword(0x111)
  -- the recon ABSORBER bank (stores nothing, reads 0) -- so every unaimed hit
  silently no-op'd.  Promoted to the named member Mech::damageLookupTable
  (binary this[0x111], was mislabeled ammoExpended).  New gotcha class recorded
  (reconstruction-gotchas §2) + sweep; 2 dead multiplayer branches logged.

- Fire path migrated off the stale vital-zone aim onto the completed STEP-6
  unaimed dispatch (zone=-1 + beam entry point -> cylinder resolves the
  exterior zone).  No more invisible 1-shot kills; death via the authentic
  cascade (~14 center-mass hits).  Wreck stays TARGETED on kill (beams stop on
  it); scoring latches off.

- SendSubsystemDamage AV fixed: unbound critical-subsystem plug guard (43
  unbound plugs/mech logged as an open question -- the binding itself is a gap).

- RemakeEntity (render damage swap): the 1996 render state machine's missing
  Remake state, reconstructed as an in-place SetDrawObj mesh swap keyed by each
  segment's damage-zone graphic state (tree dtor doesn't cascade -> never
  rebuild).  Destroyed arms/guns visibly wreck (the only variants the RES
  registers).

- BT .PFX particle layer (L4VIDEO.cpp): the 1995 explosion/damage effect layer,
  unported since 2007 (DPLIndependantEffect/ReadPSFX/ExplosionScripts all
  stubs).  Parses the authentic VIDEO/*.PFX definitions via the [pfx_day]
  psfxN mapping; premultiplied blending renders BOTH families from the same
  data (additive-style fire + occluding smoke -- DDAM2 is 30% grey, DDTHSMK
  ramps negative: impossible additively); depth-sorted billboards with a
  radial-masked grit sprite; impact-frame orientation (.PFX offsets are
  authored mech-local, -Z = out of the struck armor toward the shooter) for
  weapon hits AND damage bands (via lastInflictingID, now maintained -- was
  declared but never written).  Both effect-number encodings route (raw dpl
  <100 + WinTesla 1000+slot carried by the band resources).  Death fires the
  authentic dnboom (7) + ddthsmk smoke plume (1).

- Effects anchor at the impact point / damaged zone's segment, not the mech
  origin (no more fire at the feet).

- Dev force-input gates BT_AUTOFIRE / BT_AUTODRIVE for headless fire-chain
  verification; BT_PFX_ADD=1 flips the particle blend for A/B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-08 14:43:32 -05:00
co-authored by Claude Fable 5
parent 7c455303bd
commit a3d67cc639
16 changed files with 1205 additions and 62 deletions
+18
View File
@@ -0,0 +1,18 @@
import struct
data = open(r'C:\git\bt411\content\BTL4.RES', 'rb').read()
labOnly, maxID = struct.unpack_from('<ii', data, 4)
offs = struct.unpack_from('<%dI' % maxID, data, 12)
TN = {1: 'ModelList', 9: 'BoxedSolidStream', 10: 'VideoModel', 11: 'StaticAudioStream',
15: 'GameModel', 16: 'Animation', 18: 'GaugeImageStream', 30: 'ExplosionTableStream'}
res = {}
for o in offs:
if o == 0:
continue
rid, rt = struct.unpack_from('<ii', data, o)
nm = data[o + 8:o + 40].split(b'\0')[0].decode('ascii', 'replace')
res[rid] = (rt, nm)
print('total resources:', len(res), 'maxID:', maxID)
print('=== descriptor effect ids (26-31) + weapon explode (13) ===')
for i in [13, 26, 27, 28, 29, 30, 31]:
r = res.get(i)
print(' id=%3d' % i, ('type=%d(%s) name=%r' % (r[0], TN.get(r[0], '?'), r[1])) if r else 'ABSENT')
+65
View File
@@ -0,0 +1,65 @@
import struct, collections
RES = r"C:\git\bt411\content\BTL4.RES"
data = open(RES, "rb").read()
ver = data[0:4]
labOnly, maxID = struct.unpack_from("<ii", data, 4)
offsets = struct.unpack_from("<%dI" % maxID, data, 12)
Res = collections.namedtuple("Res", "rid rtype name prio flags off length")
resources = {}
for o in offsets:
if o == 0: continue
rid, rtype = struct.unpack_from("<ii", data, o)
name = data[o+8:o+40].split(b"\0")[0].decode("ascii","replace")
prio, flags, roff, rlen = struct.unpack_from("<iIII", data, o+40)
resources[rid] = Res(rid, rtype, name, prio, flags, roff, rlen)
t29 = sorted([r for r in resources.values() if r.rtype == 29], key=lambda x: x.name)
t20 = {r.name: r for r in resources.values() if r.rtype == 20}
print("version", list(ver), "type29 (DamageLookupTableStream) count =", len(t29))
print("type20 (DamageZoneStream) count =", len(t20))
print("name-match: every t29 name also a t20 name?",
all(r.name in t20 for r in t29), "\n")
def hexdump(b, base=0, n=64):
out=[]
for i in range(0, min(n,len(b)), 16):
chunk=b[i:i+16]
hexs=" ".join("%02x"%c for c in chunk)
out.append(" %04x %-47s" % (base+i, hexs))
return "\n".join(out)
def parse_table(b, name):
print("="*70)
print("TABLE '%s' (%d bytes)" % (name, len(b)))
print(hexdump(b, 0, 48))
p = 0
rowCount = struct.unpack_from("<i", b, p)[0]; p += 4
print(" rowCount =", rowCount)
for r in range(rowCount):
rotate = struct.unpack_from("<i", b, p)[0]; p += 4
cellCount = struct.unpack_from("<i", b, p)[0]; p += 4
print(" row[%d] rotateWithTorso=%d cellCount=%d" % (r, rotate, cellCount))
for c in range(cellCount):
# cell: [int slen][slen chars]["\0"][int cnt][cnt*(float threshold, int zone)]
slen = struct.unpack_from("<i", b, p)[0]; p += 4
if slen < 0 or slen > 64 or p+slen > len(b):
print(" cell[%d] *** slen sanity fail slen=%d @%d ***" % (c, slen, p-4)); return
s = b[p:p+slen].decode("ascii","replace"); p += slen + 1 # +1 = trailing null
cnt = struct.unpack_from("<i", b, p)[0]; p += 4
entries=[]
for _ in range(cnt):
thr = struct.unpack_from("<f", b, p)[0]
zone = struct.unpack_from("<i", b, p+4)[0]; p += 8
entries.append((round(thr,4), zone))
if r == 0:
print(" cell[%d] str='%s' cnt=%d entries=%s" % (c, s, cnt, entries))
status = "EXACT" if p == len(b) else "MISMATCH (off by %d)" % (len(b)-p)
print(" consumed %d / %d bytes -> %s" % (p, len(b), status))
# ground on the first 2 tables
for r in t29[:2]:
b = data[r.off:r.off+r.length]
parse_table(b, r.name)
+27
View File
@@ -0,0 +1,27 @@
import struct
data = open(r'C:\git\bt411\content\BTL4OPT.EXE', 'rb').read()
e = struct.unpack_from('<I', data, 0x3C)[0]; coff = e+4
num = struct.unpack_from('<H', data, coff+2)[0]; osz = struct.unpack_from('<H', data, coff+16)[0]
opt = coff+20; base = struct.unpack_from('<I', data, opt+28)[0]; sect = opt+osz
secs = []
for i in range(num):
o = sect+i*40
vs, va, rs, rp = struct.unpack_from('<IIII', data, o+8)
secs.append((va, vs, rp, rs))
def off2va(off):
for va, vs, rp, rs in secs:
if rp <= off < rp+rs:
return base+va+(off-rp)
return None
targets = {0x49ed0c: 'FUN_0049ed0c (glue: table->zone) == the handler',
0x49de14: 'FUN_0049de14 (cell->zone roll)'}
cva, cvs, crp, crs = secs[0]
for i in range(crp, crp+crs-5):
if data[i] == 0xE8:
rel = struct.unpack_from('<i', data, i+1)[0]
site = off2va(i)
tgt = site+5+rel
if tgt in targets:
print('call site VA %08x -> %s' % (site, targets[tgt]))
+15
View File
@@ -0,0 +1,15 @@
import struct
data = open(r'C:\git\bt411\content\BTL4.RES', 'rb').read()
labOnly, maxID = struct.unpack_from('<ii', data, 4)
offs = struct.unpack_from('<%dI' % maxID, data, 12)
anims = []
for o in offs:
if o == 0:
continue
rid, rt = struct.unpack_from('<ii', data, o)
nm = data[o + 8:o + 40].split(b'\0')[0].decode('ascii', 'replace')
if rt == 16:
anims.append((rid, nm))
print('type-16 Animation resources:', len(anims))
for rid, nm in sorted(anims, key=lambda x: x[1]):
print(' id=%4d %s' % (rid, nm))
+17
View File
@@ -0,0 +1,17 @@
import sys
data = open(r'C:\git\bt411\content\BTL4.RES', 'rb').read()
low = data.lower()
for name in [b'blhd.bgf', b'blhdbr', b'blh_tor', b'blhd_', b'blhdlarm', b'blhdlule', b'blhdldle', b'blhdlfot']:
i = 0
hits = []
while True:
i = low.find(name.lower(), i)
if i < 0:
break
hits.append(i)
i += 1
print(name.decode(), 'x', len(hits), hits[:6])
# context around blhd.bgf hits
i = low.find(b'blhd.bgf')
if i >= 0:
print(repr(data[i-160:i+40]))