The Mech per-impact hit-location resolver (the cylinder damage table) is now functional, wired, and runtime-verified [T2]. Unaimed (zone==-1) hits — the collision-damage path — now resolve an impact point to a damage zone via the authentic height x angle grid + weighted dice roll, instead of dropping. dmgtable.cpp/.hpp was a non-functional skeleton on no-op ReconTable/stream shims; backed it with real std::vector storage and fixed 5 latent runtime bugs: - ReadEntries now consumes the leading cell name-string ([i32 len][len+1]) - PieSlice ctor reads rotateWithTorso into the correct member - SelectSlice direct-indexes (was int lookup on a float-keyed table) - ResolveHit returns the zone (chains SelectSlice -> SelectZone) - real MemoryStream::ReadBytes (was a variadic no-op) mech.cpp ctor: replaced the empty-name StandingAnimation stub with the real load — FindResourceDescription(dzRes->resourceName, type 0x1d) -> stream -> new DamageLookupTable, cached at mech[0x111]; ~Mech deletes it. Mech::TakeDamageMessageHandler override registered (MESSAGE_ENTRY overlays Entity's by ID): on invalidDamageZone, resolve via the table then base-route; aimed reticle hits pass through unchanged. Three named accessors (no databinding-trap raw reads): WorldToLocal (localToWorld.MultiplyByInverse), CylinderReferenceHeight (standingTemplateMaxY == collisionTemplate->maxY == binary mech+0x2ec[+0xc]), TorsoHeading via a BTGetTorsoTwist bridge in torso.cpp (Torso::CurrentTwist == torso+0x1d8; torso.hpp cannot be included into mech.cpp — subsystem-stub collision). Stream format + geometry + roll + handler were all byte-verified against the shipped BTL4.RES type-29 resources (18 tables, exact consumption) and the disassembly (FUN_0049eb54/e678/de14, glue 0x49ed0c, handler @0x4a037a). Runtime: boots clean, "[cyl] table 'bhk1' layers=7" (exact byte-verified layer count, found by name), mech spawns + walks, no asserts/AV/0xCDCDCDCD. Env gate BT_CYL_LOG=1. Unblocks collision-damage application. KB updated (combat-damage.md STEP 6 COMPLETE, open-questions.md marked done). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 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 atWword(0x111);~Mechdeletes it.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: boots clean,
[cyl] table 'bhk1' layers=7(the exact byte-verified layer count, found by name), mech spawns + walks, no asserts/AV/0xCDCDCDCD. The live[cyl] unaimed hit → zone Npath fires on COLLISION damage (zone==−1); not exercised in the solo DEV.EGG (no combat) but the resolver is live + byte-verified. Env gateBT_CYL_LOG=1. The recon's empty-name stub (mech.cpp:1219) must be fixed to the real name (the binary copies it from a sibling resource,local_130+0xc@part_012.c:10361 — reuse the mech's DamageZoneStream/model name). RE phase COMPLETE; the build (containers + handler + load + wire) is next.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]
Key Relationships
- Weapons/roster: subsystems. Aim source: locomotion (drive/facing). Effects: rendering.
- P5 forensics:
docs/HARD_PROBLEMS.md. Data: decomp-reference §4-5.