STEP 6 COMPLETE: cylinder hit-location LIVE — unaimed hits resolve to zones

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>
This commit is contained in:
arcattack
2026-07-08 08:32:17 -05:00
co-authored by Claude Opus 4.8
parent 33fee712e9
commit d07ac7dd49
8 changed files with 504 additions and 557 deletions
+144 -19
View File
@@ -72,6 +72,13 @@
// AUTHENTIC GROUND MODEL ctor half (task #15): complete BoxedSolid type for the
// collisionTemplate/collisionVolume extent reads + the template bottom lift.
#include <BOXSOLID.hpp>
// STEP 6 cylinder hit-location table. dmgtable.hpp pulls in no subsystem
// headers (only Plug + mechrecon + <vector>), so it is safe here -- unlike the
// real subsystem headers, whose classes collide with mech.cpp's local stubs.
// The torso twist is reached via a BRIDGE (BTGetTorsoTwist, defined in torso.cpp)
// for the same reason.
#include "dmgtable.hpp"
extern Scalar BTGetTorsoTwist(Subsystem *torso); // torso.cpp (Torso complete there)
#if !defined(APP_HPP)
# include <app.hpp>
#endif
@@ -394,14 +401,107 @@ Derivation
"Mech"
);
// Chain Mech's handler set to the parent's (Entity's, which Mover/JointedMover use
// directly -- they add none). An EMPTY set with no inheritance made Receiver::Receive
// find NO handler for TakeDamageMessageID (and every other inherited message), so the
// engine base Entity::TakeDamageMessageHandler -- which routes damageZones[zone]->
// TakeDamage -- never ran and damage silently dropped. (Mech's OWN TakeDamage override
// + cylinder lookup is the later STEP-6 reconstruction; this restores the base routing.)
// Mech's OWN handler table, chained to the parent's (Entity's, which Mover/
// JointedMover use directly -- they add none). It OVERRIDES Entity's TakeDamage
// with the cylinder hit-location resolver (STEP 6): Build() overlays the derived
// entry onto the inherited table by message ID (RECEIVER.cpp), so every other
// inherited message still routes to its base handler. (Previously this was a
// bare copy of Entity's set -- base TakeDamage dropped unaimed/-1 zone hits.)
const Receiver::HandlerEntry
Mech::MessageHandlerEntries[] =
{
MESSAGE_ENTRY(Mech, TakeDamage),
};
Receiver::MessageHandlerSet
Mech::MessageHandlers(Entity::GetMessageHandlers());
Mech::MessageHandlers(
ELEMENTS(Mech::MessageHandlerEntries),
Mech::MessageHandlerEntries,
Entity::GetMessageHandlers());
//#############################################################################
// STEP 6 -- cylinder hit-location support (consumed by dmgtable.cpp)
//#############################################################################
//
// World -> mech-local point transform (binary FUN_00408bf8 reading owner+0xd0).
// Uses the inherited Entity::localToWorld (the render/collision object matrix);
// MultiplyByInverse gives the world->local change of coordinates.
//
Point3D
Mech::WorldToLocal(const Point3D &world)
{
Point3D local;
local.MultiplyByInverse(world, localToWorld);
return local;
}
//
// The roster torso (sinkSourceSubsystem @0x438, ClassID 0xBC5) -- parity with
// the binary's *(mech+0x438).
//
void *
Mech::TorsoOrientationSource()
{
return GetTorsoSubsystem();
}
//
// Live torso twist (yaw) in radians -- binary torso+0x1d8. Reached via the
// torso.cpp bridge (Torso is a complete type there; including torso.hpp here
// would collide with mech.cpp's local subsystem stubs). 0 for no/fixed torso.
//
Scalar
Mech::TorsoHeading()
{
return BTGetTorsoTwist(GetTorsoSubsystem());
}
//
// Height reference the cylinder table normalises impact height against -- the
// binary's *(mech+0x2ec)+0xc, a stance height written from mech+0x518 (standing)
// / +0x51c (ducked). standingTemplateMaxY is that value (collisionTemplate->maxY
// captured at ctor); fall back to the live template if the ground model was off
// when the ctor ran (standingTemplateMaxY left 0).
//
Scalar
Mech::CylinderReferenceHeight()
{
if (standingTemplateMaxY > 0.0f)
{
return standingTemplateMaxY;
}
BoxedSolid *tmpl = GetCollisionTemplate();
return tmpl ? tmpl->maxY : 0.0f; // ResolveHit guards <= 0
}
//
// Mech override of Entity::TakeDamageMessageHandler (binary @0x4a037a, the two
// call sites into the glue @0x49ed0c). An unaimed hit arrives with
// invalidDamageZone set (damageZone < 0); resolve its zone from the cylinder
// hit-location table (mech[0x111]) using the impact point, clear the flag, then
// hand off to the base handler which routes damageZones[zone]->TakeDamage. Aimed
// (reticle) hits carry a valid zone and pass straight through.
//
void
Mech::TakeDamageMessageHandler(TakeDamageMessage *message)
{
Check(message);
DamageLookupTable *table = (DamageLookupTable *)Wword(0x111);
if (message->invalidDamageZone && table != 0)
{
int zone = table->ResolveHit(message->damageData.impactPoint);
message->damageZone = zone;
message->invalidDamageZone = False;
if (BTEnvOn("BT_CYL_LOG", 0))
{
DEBUG_STREAM << "[cyl] unaimed hit -> zone " << zone
<< " (impact " << message->damageData.impactPoint.x << ","
<< message->damageData.impactPoint.y << ","
<< message->damageData.impactPoint.z << ")\n" << std::flush;
}
}
Entity::TakeDamageMessageHandler(message); // base: damageZones[zone]->TakeDamage
}
//
// Mech attribute table. DENSE PREFIX 0x15..0x21 (JointedMover::NextAttributeID
@@ -1208,17 +1308,42 @@ Mech::Mech(
}
//
// Cylinder damage-zone table (resource type 0x1d, by name) -- the per-impact zone
// resolver consumed by Mech::TakeDamageMessageHandler for unaimed/-1 hits. STILL A
// STUB here (StandingAnimation placeholder); the real CylinderDamageZoneTable is
// STEP 6a of the damage reconstruction. Left as-was so the rest builds; the empty
// name makes the lookup a no-op until then.
// Cylinder hit-location table (resource type 0x1d = DamageLookupTableStream) --
// the per-impact zone resolver consumed by Mech::TakeDamageMessageHandler for
// unaimed/-1 hits (STEP 6). Faithful to the ctor @part_012.c:10411-10425: the
// table is found by the mech's DamageZoneStream (type-0x14) NAME -- the offline
// builder writes BOTH the 0x14 and 0x1d resources under the model name (see
// CreateDamageZoneStream ~line 1930), so they share it -- then streamed and
// cached at mech[0x111] (byte 0x444). (Was an empty-name StandingAnimation
// stub -> 0 rows; the real class is dmgtable.cpp.)
//
char shadowName[32];
shadowName[0] = '\0';
int animSeg = ResourceFindByName(shadowName, 0x1d); // FUN_00406ff8 (TODO STEP 6a: cylinder table)
MemStreamX animMem(animSeg);
Wword(0x111) = (int)new (Memory::Allocate(0x2c)) StandingAnimation(this, &animMem); // FUN_0049ea48
Wword(0x111) = 0;
ResourceDescription *dzForName =
MechFindResource(creation_message->resourceID,
ResourceDescription::DamageZoneStreamResourceType); // type 0x14 (for its name)
ResourceDescription *cylRes = (dzForName != 0)
? application->GetResourceFile()->FindResourceDescription(
dzForName->resourceName,
ResourceDescription::DamageLookupTableStreamResourceType) // FUN_00406ff8, type 0x1d
: 0;
if (cylRes != 0)
{
cylRes->Lock();
{
DynamicMemoryStream cylStream( // FUN_004032dc, offset 0
cylRes->resourceAddress, cylRes->resourceSize, 0);
DamageLookupTable *table = new DamageLookupTable(this, &cylStream); // FUN_0049ea48
Wword(0x111) = (int)table;
DEBUG_STREAM << "[cyl] table '" << dzForName->resourceName
<< "' layers=" << table->LayerCount() << "\n" << std::flush;
}
cylRes->Unlock();
}
else
{
DEBUG_STREAM << "[cyl] no DamageLookupTable (type 0x1d) for '"
<< (dzForName ? dzForName->resourceName : "?") << "'\n" << std::flush;
}
//
// Bind the three creation-name strings from the MakeMessage.
@@ -1276,9 +1401,9 @@ Mech::~Mech()
<< "\n" << std::flush;
}
if (Wword(0x111) != 0) // standing animation
if (Wword(0x111) != 0) // cylinder hit-location table (STEP 6)
{
((Releasable *)Wword(0x111))->Release();
delete (DamageLookupTable *)Wword(0x111); // frees layers/slices/entries
}
//