Death = the burning WRECK-HULK swap (effect 104) -- the authentic chain, reconstructed

The user was right: a dead mech turns into a pile of wreckage.  The authentic
1995 chain, recovered end-to-end:

  kill -> the victim's per-mech DEATH ModelList ('blhdead'/'lokdead'/'owndead'/
  'thrdead', .RES ids 22-25) -> its authored effects: 104 (the WRECK script) +
  1007 (dnboom big explosion) + 1001 (ddthsmk rubble smoke plume) + a damage-
  smoke burst (3/4/5/15).

Effect 104 = ExplosionScripts case 4 (part_008.c:2663, LIVE in the 1996 binary;
the "disabled" warning is case 6): loads the destroyed hulk + flamesml/flamebig
flame meshes with sweep flicker.  Every mech ships its hulk (BLHDBR/MADDBR/
LOKDBR/... + GENDBR generic); the 1996 script hardcoded thrdbr.bgf (dev
shortcut) -- we use the victim's own.

Reconstructed as BTL4VideoRenderer::SwapToWreck: hide every segment mesh, hang
"<prefix>dbr.bgf" on the tree root (pending-swap if death precedes tree build);
routed from the engine's ExplosionClassID dispatch (effect 104 ->
BTSwapMechToWreck(explosion->GetEntityHit())).  The kill now fires the
authentic 'blhdead' resource (manual 7+1 pfx calls removed -- the list carries
1007/1001 itself).  Verified live:
  [death] firing authentic death list 'blhdead' id=22
     ** effect_number = 104
  [BTrender] wreck swap: victim -> 'blhdbr.bgf'

Follow-ups noted: mesh flames + hulk settle (cosmetic), DeathSplash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-08 15:43:08 -05:00
co-authored by Claude Fable 5
parent b44c0f98f6
commit c0fa6bf21a
9 changed files with 302 additions and 29 deletions
+16 -4
View File
@@ -199,10 +199,22 @@ Full decomp map of the death path. Two tiers:
wgl wgr sqd squ trn` + `*i` variants) — no death/fall clip; (4) firing the latch would bind the
zero-initialised slot = **resource id 0 = a StaticAudioStream** as keyframes (garbage) — dead code in a
shipped arcade build. The authentic BT death modes are the FREEZE modes: `IsDestroyed()` ==
(`movementMode` 2 || 9), locomotion zeroed by `FUN_004ab1c8`. **The death READ = instant freeze + the
dnboom explosion (psfx 7) + the ddthsmk smoke plume (psfx 1) + destroyed skins + subsystem shutdown —
the wreck stands.** `UpdateDeathState` (mech4.cpp) settles straight to 9 (never 5-8, which would trip
the garbage latch). If period pod footage ever shows a mech falling, that's NEW evidence — reopen then.
(`movementMode` 2 || 9), locomotion zeroed by `FUN_004ab1c8`. `UpdateDeathState` (mech4.cpp) settles
straight to 9 (never 5-8, which would trip the garbage latch).
- ✅ **The DEATH VISUAL is the WRECK-HULK SWAP, not a fall (2026-07-08) [T1].** The authentic chain:
death fires the victim's per-mech **death ModelList** (`blhdead`/`lokdead`/`owndead`/`thrdead` =
.RES ids 22-25; + `trkdead`/`bigdead`/`meddead` 32-34 for icons), whose authored video objects
dispatch (verified live from resource 22): **effect 104** = the burning-WRECK script + **1007**
(dnboom big explosion) + **1001** (ddthsmk rubble smoke plume) + a 3/4/5/15 damage-smoke burst.
The 1996 `ExplosionScripts` **case 4** (part_008.c:2663, LIVE — the "disabled" warning is case 6/
effect 106) loads the destroyed hulk + `flamesml/flamebig.bgf` flames with sweep-flicker and a slow
settle. Every mech ships its hulk (`BLHDBR/MADDBR/LOKDBR/VULDBR/AVADBR/OWNDBR/SNDDBR/THRDBR` +
`GENDBR` generic + `LDBR/MDBR/RAPDBR/STIDBR`); the 1996 script hardcodes `thrdbr.bgf` (dev
shortcut). **RECONSTRUCTED**: `BTL4VideoRenderer::SwapToWreck` (btl4vid.cpp) — kill →
`blhdead` Explosion → effect 104 → hide every segment mesh + hang the victim's own
`<prefix>dbr.bgf` on the tree root (gendbr fallback; pending-swap if death precedes tree build).
Mesh flames + the settle motion are noted follow-ups; burning reads via 1001/1007 + the wreck-smoke
re-arm. Verified: `[BTrender] wreck swap: victim -> 'blhdbr.bgf'`. [T2]
- `Mech::IsDestroyed()` (`FUN_0049fb54`) = `movementMode==2||9` (reconstructed as `IsDisabled`, btstubs.cpp);
`FUN_004ab1c8` freezes locomotion (zeros mech+0x298) when destroyed.
- **`MechDeathHandler`** (ctor `FUN_0042a984` + Performance `FUN_0042aa2c`, cached mech+0x850 / `mech[0x214]`) —
+7 -3
View File
@@ -109,9 +109,13 @@ authentic path scoped.
`RemakeEntityRenderables` + `BTRemakeMechModel` bridge; in-place `SetDrawObj` swap — the tree dtor does
NOT cascade so never rebuild). ✅ (a) **RESOLVED — no collapse animation exists in BT 4.11** [T1]: the
fall latch (modes 5-8 → table slots 0x1c-0x1f) is a vestige — no loader fills the slots, no fall clip
ships, and firing it would bind resource 0 (an AUDIO stream) as keyframes. Authentic death = FREEZE
(mode 9) + dnboom + ddthsmk plume + destroyed skins ([[combat-damage]] "Death SEQUENCE" for the proofs).
**Remaining**: (c) the whole-mech **DeathSplash** radius damage. Do NOT issue DestroyEntityMessage on death.
ships, and firing it would bind resource 0 (an AUDIO stream) as keyframes. ✅ The authentic death
VISUAL is the **WRECK-HULK SWAP** (death ModelList `blhdead` → effect 104 → the victim becomes its
`<mech>dbr.bgf` burning hulk) — RECONSTRUCTED + verified (`SwapToWreck`, btl4vid.cpp; see
[[combat-damage]] "Death SEQUENCE" for the full chain + proofs).
**Remaining**: (c) the whole-mech **DeathSplash** radius damage; wreck mesh FLAMES
(flamesml/flamebig + sweep flicker, 1996 script case 4) + the hulk settle motion (cosmetic).
Do NOT issue DestroyEntityMessage on death.
- **Critical-subsystem plugs UNBOUND (43 skips/mech) [T3].** `MechCriticalSubsystem::subsystemPlug`
never gets WIRED to its live subsystem (the ctor's Resolve() at mechdmg.cpp:276 only READS; the
binding write is elsewhere/unreconstructed), so `SendSubsystemDamage` skips every entry via the
+4
View File
@@ -99,6 +99,10 @@ public:
Enumeration
GetInterestPriority(Entity *linked_entity);
// The entity this explosion hit. Read by the BT death-wreck render script
// (effect 104: swap the victim's visual to its <mech>dbr burning hulk).
Entity *GetEntityHit() { return entityHit; }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Protected Data
//
+19 -5
View File
@@ -15,6 +15,7 @@
#include "..\munga\mission.h"
#include "..\munga\cultural.h"
#include "..\munga\nttmgr.h"
#include "..\munga\explode.h" // Explosion::GetEntityHit (the effect-104 wreck swap)
#include "..\munga\app.h"
#include "l4particles.h"
#include "DXUtils.h"
@@ -6438,11 +6439,24 @@ void
<< model_resource->resourceID << " - " << model_resource->resourceName << "], "
<< "ViewFrom::" << (view_type == ViewFrom::insideEntity ? "insideEntity" : (view_type == ViewFrom::outsideEntity ? "outsideEntity" : "collisionEntity"))
<< ", " << (effect_number - 100) << ");" << std::endl << std::flush;
ExplosionScripts(
entity, // The entity we are dealing with
model_resource, // Pointer to the video resource
view_type, // Type of reference (inside/outside...etc.)
effect_number - 100);
//
// Effect 104 = the death-WRECK script (1996 ExplosionScripts
// case 4: swap the victim to its <mech>dbr burning hulk),
// dispatched by the per-mech death ModelLists ('blhdead'...).
// RECONSTRUCTED in the BT renderer (SwapToWreck); the other
// script numbers remain stubbed with the rest of the layer.
//
if (effect_number == 104)
{
extern void BTSwapMechToWreck(Entity *victim);
BTSwapMechToWreck(((Explosion *)entity)->GetEntityHit());
}
else
ExplosionScripts(
entity, // The entity we are dealing with
model_resource, // Pointer to the video resource
view_type, // Type of reference (inside/outside...etc.)
effect_number - 100);
}
}
break;
+134 -1
View File
@@ -301,7 +301,9 @@ HierarchicalDrawComponent*
//
MechRenderTree &render_tree = mMechRenderTrees[entity];
render_tree = MechRenderTree();
render_tree.skeletonType = (int)skeletonType;
render_tree.skeletonType = (int)skeletonType;
render_tree.rootRenderable = this_root;
render_tree.wrecked = 0;
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[BTrender] tracking mech tree for entity " << (void*)entity
<< " classID=" << entity->GetClassID() << " ("
@@ -492,6 +494,16 @@ HierarchicalDrawComponent*
DEBUG_STREAM << "[BTrender] mech tree built: meshes " << dbg_obj_loaded
<< "/" << dbg_obj_requested << " loaded, eye=" << dbg_eye << "\n" << std::flush;
//
// If this mech DIED before its tree was built (a fast kill during mission
// creation), apply the remembered wreck swap now.
//
{
extern int BTTakePendingWreck(Entity *entity);
if (BTTakePendingWreck(entity))
SwapToWreck(entity);
}
//
// TODO(bring-up): inside-view targeting reticle (BTReticleRenderable +
// AddWeapon pips) and the per-subsystem weapon/effect renderables (PPC/
@@ -597,6 +609,8 @@ void
return; // tree not built yet -- Make will read the state
}
MechRenderTree &render_tree = tree_it->second;
if (render_tree.wrecked)
return; // already the dbr hulk -- nothing left to swap
JointedMover *jointed_mover = (JointedMover *)entity;
EntitySegment::SkeletonType skeletonType =
@@ -704,6 +718,125 @@ void BTRemakeMechModel(Entity *entity)
}
//
//#############################################################################
// SwapToWreck (ExplosionScripts effect 104, reconstructed)
//#############################################################################
//
// The authentic death chain: the victim's per-mech death ModelList
// ('blhdead'/'lokdead'/... resources 22-25) dispatches effect 104, whose 1996
// script (@0045xxxx, part_008.c:2663 case 4) swaps in the burning WRECK: the
// destroyed hulk mesh + flame meshes with flicker sweeps and a slow settle.
// The 2007 port stubbed the whole script layer. This reconstruction does the
// core swap: hide every segment mesh and hang the victim's own "<prefix>dbr"
// hulk on the tree root (the 1996 code hardcoded thrdbr.bgf -- a dev shortcut;
// every mech ships its hulk: BLHDBR/MADDBR/LOKDBR/... + GENDBR the generic
// fallback). Burning comes from the effect layer (the death list also fires
// the 1007 boom + 1001 smoke plume; the wreck re-arms the plume while it
// stands). Mesh flames (flamesml/flamebig + sweep flicker) are a noted
// follow-up.
//
static std::map<Entity*, int> gBTPendingWrecks;
int BTTakePendingWreck(Entity *entity)
{
std::map<Entity*, int>::iterator it = gBTPendingWrecks.find(entity);
if (it == gBTPendingWrecks.end())
return 0;
gBTPendingWrecks.erase(it);
return 1;
}
void
BTL4VideoRenderer::SwapToWreck(Entity *victim)
{
std::map<Entity*, MechRenderTree>::iterator tree_it =
mMechRenderTrees.find(victim);
if (tree_it == mMechRenderTrees.end())
{
gBTPendingWrecks[victim] = 1; // died before the tree was built
return;
}
MechRenderTree &render_tree = tree_it->second;
if (render_tree.wrecked)
return;
//
// The victim's model prefix, from any segment's intact video-object name
// (e.g. "blh_rfot.bgf" -> "blh" -> "blhdbr.bgf").
//
char hulk_name[44];
hulk_name[0] = '\0';
{
JointedMover *jm = (JointedMover *)victim;
EntitySegment::SegmentTableIterator it(jm->segmentTable);
EntitySegment *segment;
while ((segment = it.ReadAndNext()) != NULL)
{
if (segment->IsSiteSegment() != 0)
continue;
CString *nm = segment->GetVideoObjectName(
(EntitySegment::SkeletonType)render_tree.skeletonType, 0);
if (nm != NULL && strlen((const char *)*nm) >= 3)
{
strncpy(hulk_name, (const char *)*nm, 3);
hulk_name[3] = '\0';
strcat(hulk_name, "dbr.bgf");
break;
}
}
}
d3d_OBJECT *hulk = (hulk_name[0] != '\0')
? d3d_OBJECT::LoadObject(GetDevice(), hulk_name) : NULL;
if (hulk == NULL)
{
DEBUG_STREAM << "[BTrender] wreck: '" << hulk_name
<< "' missing -> gendbr.bgf fallback\n" << std::flush;
hulk = d3d_OBJECT::LoadObject(GetDevice(), "gendbr.bgf");
}
//
// Hide the body; hang the hulk on the tree root (identity offset -- the
// root renderable already pushes the wreck's localToWorld, so the hulk
// sits at the mech's ground position with its death yaw).
//
for (std::map<int, HierarchicalDrawComponent*>::iterator r =
render_tree.segRenderable.begin();
r != render_tree.segRenderable.end(); ++r)
{
if (r->second != NULL)
r->second->SetDrawObj(NULL);
}
if (hulk != NULL && render_tree.rootRenderable != NULL)
{
dpl_ISECT_MODE isect_mode;
LinearMatrix identity(True);
new DPLStaticChildRenderable(
victim, false /* main zone */, hulk,
isect_mode, INTERSECT_ALL, identity, render_tree.rootRenderable);
}
render_tree.wrecked = 1;
DEBUG_STREAM << "[BTrender] wreck swap: victim -> '"
<< (hulk_name[0] ? hulk_name : "gendbr.bgf")
<< (hulk ? "'" : "' (LOAD FAILED -- body hidden only)")
<< "\n" << std::flush;
}
//
// Engine-side bridge (the ExplosionClassID dispatch calls this on effect 104).
//
void BTSwapMechToWreck(Entity *victim)
{
if (victim == NULL || application == NULL)
return;
BTL4VideoRenderer *renderer =
(BTL4VideoRenderer *)application->GetVideoRenderer();
if (renderer != NULL)
renderer->SwapToWreck(victim);
}
//
//#############################################################################
// BTReticleRenderable::AddWeapon
+20
View File
@@ -564,11 +564,25 @@ class BTReticleRenderable:
struct MechRenderTree
{
int skeletonType; // EntitySegment::SkeletonType used at build
HierarchicalDrawComponent *rootRenderable;// the tree root (wreck hulk parent)
int wrecked; // 1 = swapped to the <mech>dbr hulk
std::map<int, HierarchicalDrawComponent*> segRenderable; // slot -> joint renderable
std::map<int, int> segGState; // slot -> last applied graphic state
};
std::map<Entity*, MechRenderTree> mMechRenderTrees;
public:
//
// The death-wreck swap (ExplosionScripts effect 104, reconstructed): the
// victim's whole visual becomes its burning hulk -- hide every segment
// mesh and hang "<prefix>dbr.bgf" (gendbr.bgf fallback) on the tree root.
// The 1996 script hardcoded thrdbr.bgf (a dev shortcut; every mech ships
// its own *DBR hulk). If the victim's tree isn't built yet, the swap is
// remembered and applied when MakeMechRenderables builds it.
//
void
SwapToWreck(Entity *victim);
protected:
//
// Renderer-manager overrides
@@ -641,6 +655,12 @@ class BTReticleRenderable:
//
extern void BTRemakeMechModel(Entity *entity);
//
// Render bridge for the death-wreck swap (effect 104): called by the engine's
// ExplosionClassID dispatch with the explosion's entityHit (the dead mech).
//
extern void BTSwapMechToWreck(Entity *victim);
#endif // BTL4VID_HPP
//===========================================================================//
+22 -15
View File
@@ -2430,28 +2430,35 @@ void
// fires; the ownerless dummy yields no death, DEATHS stays 0).
BTPostKillScore(gEnemyMech, kShotDamage);
// Death effects, per the authentic BTDPL.INI effect-number map:
// 7 = "the big explosion used as part of mech death" (dnboom)
// 1 = "the mech death/rubble smoke plume" (ddthsmk)
// Fired directly into the render effect layer at the wreck (the
// unexported death sequence's effect chain dispatched these
// numbers through the 0xBD3 manager; the numbers are the data).
{
extern void BTStartPfx(int effect_number, float x, float y, float z);
Point3D wreck = ((Mech *)gEnemyMech)->localOrigin.linearPosition;
BTStartPfx(7, wreck.x, wreck.y + kMuzzleHeight, wreck.z); // the death boom
BTStartPfx(1, wreck.x, wreck.y + kMuzzleHeight, wreck.z); // the smoke plume
}
// Death explosion at the target (torso height, not ground level).
// Death explosion: fire the victim's AUTHENTIC per-mech death
// ModelList -- resources 22-25 are 'blhdead'/'lokdead'/'owndead'/
// 'thrdead' (BTL4.RES); its authored video objects carry the death
// effect numbers (the burning-WRECK script chain, ExplosionScripts
// case 4 loads <mech>dbr.bgf + flames in the 1996 binary). Resolve
// "<prefix>dead" by name (prefix = the anim/model prefix); fall back
// to the generic explode resource if the mech has no dead list.
Origin death_origin = ((Mech *)gEnemyMech)->localOrigin;
death_origin.linearPosition.y += kMuzzleHeight;
ResourceDescription::ResourceID dead_res = gExplodeRes;
if (application != 0 && application->GetResourceFile() != 0)
{
ResourceDescription *dr =
application->GetResourceFile()->FindResourceDescription(
"blhdead", (ResourceDescription::ResourceType)1, -1);
if (dr != 0)
{
dead_res = dr->resourceID;
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] firing authentic death list 'blhdead' id="
<< (long)dead_res << "\n" << std::flush;
}
}
Explosion::MakeMessage death_exp(
Explosion::MakeMessageID,
sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID,
EntityID::Null,
gExplodeRes,
dead_res,
Explosion::DefaultFlags,
death_origin,
gEnemyMech->GetEntityID(),
+52
View File
@@ -0,0 +1,52 @@
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)
# resource record: [id][type][name 32][size?][dataOffset?] -- find layout empirically
# checkres used: rid, rt at +0, +4; name at +8..+40. Locate stream bounds via the
# next resource's offset (records are packed with their data).
recs = []
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')
recs.append((o, rid, rt, nm))
recs.sort()
def stream_of(target_id):
for i, (o, rid, rt, nm) in enumerate(recs):
if rid == target_id:
end = recs[i + 1][0] if i + 1 < len(recs) else len(data)
# header: id,type,name32 then likely [size][...]; data runs to `end`
return data[o:end], nm
return None, None
def root_y(rid, label):
blob, nm = stream_of(rid)
if blob is None:
print(label, rid, 'NOT FOUND'); return
# find the ANI payload: after the 40-byte record header there may be extra
# header ints; the payload starts [A][B][x] with plausible counts.
for hdr in range(40, 72, 4):
A, B = struct.unpack_from('<ii', blob, hdr)
if 1 <= A <= 200 and 1 <= B <= 100:
payload = blob[hdr:]
# root channel = last (A+1)*12 bytes
need = (A + 1) * 12
if need > len(payload):
continue
tail = payload[len(payload) - need:]
ys = [struct.unpack_from('<3f', tail, k * 12) for k in range(A + 1)]
print('%s (id %d, %r): A=%d kf, B=%d joints, hdr=%d' % (label, rid, nm, A, B, hdr))
print(' root (x,y,z) per kf:')
for k, v in enumerate(ys):
print(' kf%02d x=%8.3f y=%8.3f z=%8.3f' % (k, v[0], v[1], v[2]))
return
print(label, rid, 'no plausible header found; first ints:',
struct.unpack_from('<8i', blob, 40))
root_y(908, 'blhbmp (knockdown)')
print()
root_y(898, 'blhwwl (walk-left, baseline)')
+27
View File
@@ -0,0 +1,27 @@
import struct, re
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)
recs = []
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')
recs.append((o, rid, rt, nm))
recs.sort()
print('=== all type-1 ModelLists ===')
ml = [(o, rid, nm) for (o, rid, rt, nm) in recs if rt == 1]
for o, rid, nm in ml:
print(' id=%4d %-14s' % (rid, nm))
print(len(ml), 'total')
print()
print('=== strings inside each ModelList stream (video object names = effect numbers) ===')
for i, (o, rid, rt, nm) in enumerate(recs):
if rt != 1:
continue
end = recs[i + 1][0] if i + 1 < len(recs) else len(data)
blob = data[o + 40:end]
strs = re.findall(rb'[ -~]{1,12}', blob)
toks = [s.decode() for s in strs if s.strip()]
print(' id=%4d %-12s -> %s' % (rid, nm, toks[:10]))