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>
20 KiB
id, title, status, source_sections, related_topics, key_terms, open_questions
| id | title | status | source_sections | related_topics | key_terms | open_questions | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| combat-damage | Combat & Damage — targeting, firing, damage zones, death | established | PROGRESS_LOG.md §10c; docs/HARD_PROBLEMS.md (P5) |
|
|
|
Combat & Damage
The drive→target→fire→damage→destroy loop. Full detail: docs/PROGRESS_LOG.md §10c; the death/
teardown forensics: docs/HARD_PROBLEMS.md (P5).
No AI — BT is PvP-only (verified, not a recovery gap)
The mission system is NETWORKED and BT never had NPCs/AI. Every mech is driven by a BTPlayer (or a
BTCameraDirector spectator). The BT registry knows only Mech/Projectile/Missile/BTPlayer/BTTeam; no
Bot/Brain/AI class exists; BTL4OPT.EXE has no AI strings. Crusher/Blocker/Runner are RED PLANET
classes, NOT BT. ⇒ a solo mission has no enemy by design; BT_SPAWN_ENEMY is a TEST DUMMY
(uncontrolled Mech). The authentic opponent path is multiplayer. [T1]
Entity spawn + the per-mech gate
Mech::Make(MakeMessage*) (FUN_004a2d48) allocs sizeof(Mech) + runs the ctor; the base Entity
ctor self-registers it (renders + ticks). Mech::PerformAndWatch runs for EVERY mech → gate
player-only logic on this == application->GetViewpointEntity() (else a spawned enemy is driven by
player input). [T1]
Targeting (mech offsets, decomp-reference §3)
mech+0x37c = target world Point3D; mech+0x388 = target Entity* (the HasActiveTarget() gate);
mech+0x38c = targeted sub-zone (−1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint,
refreshed each frame. [T1]
Firing arc + muzzles
There is NO firing-arc field in the binary (Emitter::FireWeapon fires whenever HasActiveTarget) —
the 1995 game got away with it because it was cockpit-only (a beam behind you is off-camera). The
port's external camera exposes it, so the arc is gated on the torso twist range
(Torso::GetHorizontalReach, 0 for the twist-disabled Blackhawk) + a base aim tolerance
(BT_FIRE_ARC, a labeled PORT presentation param). Beams fire from the mech's real gun-port SITE
segments (siterugunport etc.) resolved via GetSegment(name)→segmentToEntity×localToWorld. [T2]
Weapon fire is a PORT reconstruction (the dpl_* beam layer is unported)
Emitter::FireWeapon builds a muzzle→target beam but the dpl_* beam renderable was never ported →
the beam is drawn by BTPushBeam/BTDrawBeams (L4VIDEO — an additive quad / the real ermlaser.bgf
tube with the scrolling bexp grit). Fire rate is decoded-exact (DischargeTime 0.2 + RechargeRate 2.2
per ER-M laser; decomp-reference §5). Flying projectiles (LRM/autocannon) are also a PORT
reconstruction (BTPushProjectile — the 2007 Entity is too small for the binary's raw integrator
offsets). [T2]
Damage delivery + the real damage model
Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&) → target->Dispatch.
Base handler IGNORES zone==−1 (Entity::TakeDamageMessageHandler, ENTITY.cpp:878 — returns on
damageZone==−1; the message carries invalidDamageZone = damageZone<0 + damageData.impactPoint).
The Mech override that resolves an unaimed hit's zone from the impact point (the cylinder hit-location
model, STEP 6) is now RECONSTRUCTED + runtime-verified [T2] — see "STEP 6 COMPLETE" below.
(Historical: it used to be unreconstructed, meanwhile aiming a valid zone.) Investigated 2026-07 (durable): the earlier-cited addresses FUN_004a0230/FUN_0049ed0c do
NOT exist — the real pieces are: the CylinderDamageZoneTable = a list of 0x30-byte cylinder entries
(entry ctor FUN_0049e740) loaded from resource type 0x1d by the mech's cylinder-table NAME; the
table ctor is FUN_0049ea48 (vtable 0x50bd84, alloc 0x2c, cached at Mech[0x111]=byte 0x444) — the recon
DOES build it (mislabeled StandingAnimation, mech.cpp:1221) but with an EMPTY name → 0 entries → the
lookup is a no-op. Fully reversed (2026-07): the table is a passive nested-list height × angle grid — 3 container
classes: TABLE (0x2c, ctor FUN_0049ea48 / dtor 0x49eadc, vtable 0x50bd84, this[3]=mech,
this[4]=row list, this[10]=row count) → ROWS by HEIGHT (0x30, ctor FUN_0049e740 / dtor 0x49e814,
vtable 0x50bd90, this[3]=RotateWithTorso flag, this[4]=torso (mech+0x438), this[5]=cell list,
this[0xb]=cell count, cell key i·(2π/count) — _DAT_0049e810=6.2831855=2π) → CELLS by ANGLE
(0x2c, ctor FUN_0049deb0 / dtor 0x49df80, vtable 0x50bd9c, this[4]=a refcounted zone-dict filled
by FUN_0049e5e4, count-prefixed {int→int} entries) → zone ref(s).
⚠ CORRECTION (2026-07, verified from decomp): the lookup is NOT "entirely in the unexported handler"
— it is EXPORTED pseudocode [T1]. Two functions:
FUN_0049eb54(@0049eb54, TABLE-level,void(table, float* worldImpactPoint)— Ghidra mistypes the return; it tail-returns the found cell): (1) transform world impact → mech-local viaFUN_00408bf8+FUN_00408440reading the mech's transform atmech+0xd0; (2) HEIGHT→row:mechHeight = *(*(mech+0x2ec)+0xc); clamplocal.yto[0,mechHeight];rowIndex = clamp(floor(rowCount·y/mechHeight), 0, rowCount-1);row = rowList.Find(&rowIndex)(int key, list method vtable+0xc); (3) ANGLE: if|local.z|>eps && |local.x|>eps,angle = atan2(local.z, local.x)(FUN_004dc8ec=atan2, confirmed vias_atan2), wrap<0 → +2π; else degenerateangle = FUN_00408050()·2π; then callFUN_0049e678(row, angle).FUN_0049e678(@0049e678, ROW-level angle→cell): ifRotateWithTorso(row+0xc),angle += torso facing (*(torso+0x1d8))and wrap into[0,2π);cellIndex = floor(cellCount·angle/2π); reconstruct the exact float key(cellIndex+1)·2π/cellCount(cells stored underi·(2π/count), i=1..count);cell = cellList.Find(&key)(float key, list method vtable+0xc). Constants:_DAT_0049e810 = _DAT_0049e72c = _DAT_0049ed08 = 2π;_DAT_0049e734 = 1/(2π);_DAT_0049e730 = _DAT_0049ed00 = 0.0;_DAT_0049ed04 = eps;DAT_004e0f80/84/88 = (0,0,0). Within-cell selection (fully recoveredFUN_0049de14+ disasm): the cell → a cumulative hit-distribution;zone = FUN_0049de14(cell)rollsrandom()(FUN_00408050=(1/RAND_MAX)·engineRand, uniform [0,1)) and returns the first entry whose threshold > roll (entries sorted ascending) — classic BattleTech dice hit-scatter. The tiny glueFUN_0049ed0c(table,worldPt) = FUN_0049de14(FUN_0049eb54(...)); the realMech::TakeDamageMessageHandler(disasm @0x4a037a, two call sites 0x4a038c/0x4a04b9) is:if (msg->invalidDamageZone /*msg+0x28*/) { msg->damageZone /*+0x24*/ = FUN_0049ed0c(mech[0x111]/*+0x444*/, &msg->damageData.impactPoint /*+0x4c*/); msg->invalidDamageZone = 0; }then the basedamageZones[zone]->TakeDamage. STREAM FORMAT — BYTE-VERIFIED against the real type-29.RESbytes (EXACT consumption, 18 tables) [T1]:
Table { i32 rowCount; Row[rowCount] }
Row { i32 rotateWithTorso; i32 cellCount; Cell[cellCount] }
Cell { i32 nameLen; char name[nameLen]; u8 0x00; i32 entryCount; Entry[entryCount] }
Entry { f32 cumulativeThreshold; i32 zoneIndex } // zoneIndex → damageZones[]
Real sample (ava1, 7 rows × 8 cells): rows 0-2 rotateWithTorso=0 (feet/legs — fixed to chassis),
rows 3-6 =1 (upper body — rotates with torso twist); a foot cell = {0.5→16, 0.8→10, 1.0→5}. The table
is found by the mech's type-0x14 DamageZoneStream NAME (ResourceDescription::resourceName) →
ResourceFile::FindResourceDescription(name, DamageLookupTableStreamResourceType/*29*/).
So STEP 6 remaining = (6b) fix the name-load (current ResourceFindByName is a NO-OP template stub → empty
name → 0 rows); (6c) make the table functional + ResolveHit→zone; (6d) register the
Mech::TakeDamageMessageHandler override + verify. Nothing is guessed — geometry, roll, handler, and byte
format are all verified.
The classes ALREADY EXIST — game/reconstructed/dmgtable.cpp (2026-07 discovery)
The three container classes are already reconstructed in dmgtable.cpp/.hpp (in the build):
DamageLookupTable (layers=height) → PieSlice (slices=angle, SelectSlice) → DamageZonePercentTable
(leaf, SelectZone=the roll, ReadEntries=stream parse). It independently confirms this RE exactly.
BUT it is a NON-FUNCTIONAL SKELETON — it was written to capture the algorithm SHAPE and was never wired
or run, so it has real runtime bugs the never-executed path hid:
- Its
ReconTable<T>/NewTableEntry/NewRefCount/RandomUnitare no-op shim stubs (mechrecon.hpp —Insert(){},Lookup()→0) → the table stays empty + lookups return null. Must be backed by REAL storage (safe:ReconTableis used ONLY in dmgtable). Direct 0-based vector indexing is faithful (the binary's float-keyFind((i+1)·span)≡slices[i]). DamageZonePercentTable::ReadEntriesskips the cell name-string (FUN_00402948=[i32 len][len+1 bytes: chars+NUL]) that precedes the entry count → must read+discard it first or the parse misaligns.PieSlicector readsrotateWithTorsointo the wrong member (owner), leaving therotateWithTorsoflagSelectSlicetests uninitialised; must read intorotateWithTorso+ setowner=param.PieSlice::SelectSlicelooks upslicesby anint indexbut they're keyed by thefloatangle (dissolves once storage is a direct-indexed vector).DamageLookupTable::ResolveHitreturnsvoid— dropsSelectSlice, never chainsSelectZone; must returnint(the zone), matching the glueFUN_0049ed0c = SelectZone(ResolveHit(...)).Mech::WorldToLocal/TorsoOrientationSourceare DECLARED (mech.hpp) but UNDEFINED (never linked because dmgtable is never called); the height (mech+0x2ec=[0xbb], the collision cylinder,+0xc=height)- torso heading (
torso+0x1d8) need NAMED accessors (databinding trap — no raw offset reads). So 6c = back the classes with real storage + fix bugs 2-5 + implement the 3 accessors; 6d = wire the mech ctor (real name-load, replacing the mislabeledStandingAnimationstub @mech.cpp:1221) + the handler override.
- torso heading (
✅ STEP 6 COMPLETE (2026-07-08) [T2]
Built + runtime-verified. Changes:
dmgtable.cpp/.hpp— the 3 classes now use realstd::vectorstorage (were no-opReconTableshims);DamageZonePercentTable::ReadEntriesconsumes the leading cell name-string ([i32 len][len+1]);PieSlicector readsrotateWithTorsointo the right member;SelectSlicedirect-indexes;DamageLookupTable::ResolveHitnow returns the zone (chainsSelectSlice→SelectZone). Stream type is the realMemoryStream(ReadBytes).mech.cppctor — replaced the empty-name stub with the real load:FindResourceDescription(dzRes-> resourceName, DamageLookupTableStreamResourceType/*0x1d*/)→DynamicMemoryStream→new DamageLookupTablecached in the named memberMech::damageLookupTable(binarythis[0x111]/byte 0x444; was mislabeledammoExpendedin the offset map);~Mechdeletes it. ⚠ It was ORIGINALLY "cached" atWword(0x111)— the absorber bank — which stores NOTHING, so the whole unaimed path was inert until 2026-07-08 (the "can't kill the enemy" bug). See reconstruction-gotchas §2. [T2]Mech::TakeDamageMessageHandleroverride registered (MESSAGE_ENTRY(Mech, TakeDamage), overlays Entity's by ID): oninvalidDamageZone,msg->damageZone = table->ResolveHit(msg->damageData.impactPoint), clear the flag, then base-route. Aimed hits pass straight through.- Named accessors (no databinding-trap raw reads):
Mech::WorldToLocal(localToWorld.MultiplyByInverse),CylinderReferenceHeight(standingTemplateMaxY=collisionTemplate->maxY= the binarymech+0x2ec[+0xc]),TorsoHeading(via theBTGetTorsoTwistbridge intorso.cpp→Torso::CurrentTwist= torso+0x1d8; torso.hpp can't be#included into mech.cpp — subsystem-stub collision). - Runtime verify (2026-07-08, POST-Wword-fix — the earlier "verified" claim was PARTIAL: the handler
wiring was live but the table cache was inert): with
BT_AUTOFIRE=1 BT_AUTODRIVE=0.5 BT_SPAWN_ENEMY=1, the full chain runs end-to-end:[cyl] unaimed hit → zone Nresolves across 19 distinct zones (front-facing zones 0/6/17/15/4 dominate for head-on fire, as the geometry predicts), structure climbs gradually per hit, the enemy dies after 14 hits (not 1) via the authentic cascade, 4+ zones reachDestroyedGraphicState, and the destroyed segment meshes visibly swap (RemakeEntity). Env gatesBT_CYL_LOG=1,BT_AUTOFIRE/BT_AUTODRIVE(dev force-inputs, mech4.cpp). [T2] - Fire path now UNAIMED (2026-07-08): both bring-up hit paths (the SHOT block + the projectile
impact, mech4.cpp) dispatch
zone=−1+ a world impact point (beam entry point = enemy origin shifted ~3u toward the shooter at muzzle height; the projectile uses its own impact position) → the cylinder resolves the struck EXTERIOR zone. The old code aimed the internal VITAL zone directly → invisible 1-shot kills (internal zones are soft; the binary never aims them — they die only via the destruction cascadeRecurseSegmentTable/SendSubsystemDamage). On kill the wreck STAYS TARGETED (gEnemyMechis NOT nulled) so beams keep terminating on it — nulling it made every later beam a "free" ray that visibly passed through the wreck; scoring latches off viagEnemyDestroyed. [T2]Mech__DamageZone::TakeDamage→ engine armor model (damageLevel += damageAmount*damageScale[type]) → 1.0 = zone destroyed → death. The Mech ctor populates the inheriteddamageZones[]from the DamageZoneStream (type 0x14) resource.class Damage{ damageType (Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }. Verified:structureclimbs, vital zone destroyed →*** TARGET DESTROYED ***. [T2] Two non-field fixes were needed: message-handler chaining (an unchained set drops TakeDamage silently) + entity validity (SetValidFlag()on a manually-spawned entity) — see reconstruction-gotchas §9. [T2]
Death — the wreck STAYS (P5 CLOSED)
A killed mech does NOT disappear — BT death is a STATE transition
(SetGraphicState(DestroyedGraphicState) + death anim + effect/splash), the mech becomes a persistent
WRECK (RP analog: VTV::DeathShutdown). NEVER issue DestroyEntityMessage on death — the
teardown crash was an artifact of forcing a removal the original never does. The actual bug was ONE
thing: an EXPLICIT JointedMover::~JointedMover() in the reconstructed ~Mech ran the whole base-dtor
chain TWICE (the dtor-epilogue rule, reconstruction-gotchas §7) → a double-free of collisionLists
- the segment table. Removing it fixed BOTH the death-row crash AND app-exit crashes. [T2]
Death SEQUENCE reconstruction — the map (2026-07-08)
Full decomp map of the death path. Two tiers: EXPORTED (reconstructable) [T1 read]:
Mech::AdvanceLegAnimation(FUN_004a5028) /AdvanceBodyAnimation(FUN_004a5678) — the collapse-anim latch:movementMode(mech+0x40) 5/6/7/8 = the four fall directions → clips 0x1c-0x1f (viaSetLegAnimation/SetBodyAnimation=FUN_004a7fc4/FUN_004a800c), one-shot viadeathAnimationLatched(mech+0x650). Already reconstructed (mech2.cpp:550-557/817-822). The active drive path DOES callAdvanceBodyAnimation(mech4.cpp:317) → reachable. ⚠ CONFLICT with the shipped clip inventory (2026-07-08) [T4 until re-read]: BTL4.RES holds exactly 27 named clip codes per mech (swr wwl wwr wsl wsr wrl wrr rrl rrr rwl rwr bmp bbl bbr bsl bsr sbl sbr ggl ggr gsl gsr wgl wgr sqd squ trn, +*iinside-skeleton variants) — NO explicit death/fall clip name, and indexes 0x1c-0x1f (28-31) exceed a 27-entry table unless the table interleaves the i-variants or carries non-resource entries. The "clips 0x1c-0x1f" claim needs the FUN_004a5028 latch region re-read against the real StandingAnimation table before the collapse is built (task #32). Candidate authentic visuals: thebmp(knockdown) family orsqd(squat/crumple).Mech::IsDestroyed()(FUN_0049fb54) =movementMode==2||9(reconstructed asIsDisabled, btstubs.cpp);FUN_004ab1c8freezes locomotion (zeros mech+0x298) when destroyed.MechDeathHandler(ctorFUN_0042a984+ PerformanceFUN_0042aa2c, cached mech+0x850 /mech[0x214]) — the per-subsystem destroyed-skin + explosion engine. Each tick walks the damage subsystems (mech+0x120[mech+0x11c]); as a subsystem's damage crosses a descriptor-table threshold (FUN_0042a5f4detects the crossing,FUN_0042a664looks up the state) it applies theGraphicState("Destroyed" = enum 1, parsed @part_003.c:9099) to the subsystem graphic (sub+0xd4) AND dispatches an effect-creation message (FUN_0043663c→FUN_004364e4to the effect mgrapp+0x38). Fully exported but a no-op STUB in the recon (mech.cpp:221). GamedataDeathSplashDamage/Radius/Effectload into a descriptor @ +0x74/78/7c (FUN_004a2da8). Destroyed-skin variant set (destroyed,destroyeds…destroyedd) @part_003.c:5720. NOT EXPORTED (master-perf gap 0x4a9770-0x4ab188 + the un-exported TakeDamage) [T4]:- The
movementModeWRITER (sets 5-8 then 2/9 on death) + the fall-direction (5/6/7/8) selection. - The
Mech::DeathShutdownroster loop (RPVTV::DeathShutdownanalog — loop subsystems calling theSubsystem::DeathShutdown(int)vtable slot; the base is an empty virtual, SUBSYSTM.h:166). - The whole-mech
DeathEffect/DeathSplashradius dispatch + thegraphicAlarm.SetLevel(9)call site (the recon already raises the alarm in mechdmg.cpp:426/586). The problem: the orchestration lives in the per-frame Simulate that the bring-up drive override bypasses (btstubs.cpp:116-119), so the death consumer must be reconstructed from its exported consumers + the RP analog and wired into the active path (mech4.cppPerformAndWatch).
✅ Death-STATE core DONE (2026-07-08) [T2]
Mech::UpdateDeathState() + IsMechDestroyed() (mech4.cpp) reconstruct the un-exported death branch from
its exported consumers + the RP analog, called for EVERY mech early in PerformAndWatch: on `graphicAlarm
= 9
it (1) setsmovementMode = 5→ the collapse clip's latch inAdvanceBodyAnimation(fall direction 5-8 un-exported → 5 [T3]); (2) loops the roster callingSubsystem::DeathShutdown(1)(RPVTV::DeathShutdownanalog; base is a no-op virtual, a SHUTDOWN not teardown → safe, wreck STAYS); (3) next frame settles tomovementMode = 9→IsDisabled→ locomotion frozen. The drive's lonemovementMode = 1write (mech4.cpp ~1309) is guarded on!IsMechDestroyedso the death state isn't clobbered. **Runtime-verified** (forced kill):[death] … collapse + subsystem shutdown→[death] … settled -> disabled (IsDisabled=1, frozen wreck), mech frozen in place (pos unchanged), subsystem tick + renderer keep running, **no crash, wreck stays**. Env gateBT_DEATH_LOG=1. **Deferred (honest):** (a) the visible **collapse ANIMATION** may not latch through the active path — the gait SM showedstate=0post-death (the clip trigger viaAdvanceBodyAnimationneeds the deathmovementModeto reach its latch; and spawned NON-player mechs don't advance their body anim at all). (b) **MechDeathHandler** (the exported per-subsystem destroyed-skin + explosion engine,FUN_0042a984/FUN_0042aa2c`) is still the no-op stub → no death explosions/skin-swaps yet. (c) the whole-mech DeathSplash radius damage (un-exported).
Key Relationships
- Weapons/roster: subsystems. Aim source: locomotion (drive/facing). Effects: rendering.
- P5 forensics:
docs/HARD_PROBLEMS.md. Data: decomp-reference §4-5.