Files
BT411/game/reconstructed/dmgtable.cpp
T
Joe DiPrimaandClaude Opus 5 5a425320b7 #92: map the direct-fire hit->zone path; the FOOT is pickable over only 3% of a mech's height
Oracle, night 7: "Unable to damage the foot panels on Loki via direct fire from
front or side.  Was able to damage on Thor."

FIRST, THE MECHANISM -- it is not what the issue assumed (missing collision
geometry).  A direct-fire hit gets its zone one of two ways, decided at
Mech::TakeDamageMessageHandler by `invalidDamageZone`:

  AIMED   (invalidDamageZone=0): the zone rides in on the message from the
          per-part aim pick, MechSegmentPick (#73, btl4vid.cpp).
  UNAIMED (invalidDamageZone=1): DamageLookupTable::ResolveHit runs the authored
          cylinder lottery -- impact HEIGHT picks a layer, ANGLE picks a pie
          slice, and a weighted random roll picks the zone from that slice.

Measured live, both paths are in use: the Thor logged 16 unaimed hits
(invalidZone=1 zone=-1 -> ResolveHit) alongside aimed ones carrying real zones.

THE FINDING.  MechSegmentPick is a BOUNDING-SPHERE test whose primary key is
SMALLEST RADIUS WINS -- a larger sphere can never beat a smaller one the ray also
grazes.  The leg spheres, dumped in world space and IDENTICAL on both chassis:

    knee  centre y=1.868  r=1.505     spans y 0.364 .. 3.373
    toe   centre y=0.302  r=1.689     spans y -1.387 .. 1.991

The toe sphere is BIGGER than the knee's and they overlap heavily, so the toe can
only win where the ray misses the knee sphere outright -- i.e. below y=0.364.
Against a reference height of 11.16 that is a 0.36-unit window, **3.3% of the
mech's height**, and it sits right on the ground.  Everywhere else a shot at the
foot is credited to the LEG.  That is the reported symptom.

This is a PORT ARTIFACT, not authentic: btl4vid.cpp's own comment concedes the
sphere test approximates "the per-part semantic the 1995 mesh intersection
produced".  Real mesh intersection has no such interference -- aiming at the foot
mesh hits the foot.

NOT EXPLAINED, and stated plainly: the per-CHASSIS asymmetry.  Loki and Thor have
identical leg spheres, identical foot geometry (LOK_LFOT.BGF and THR_LFOT.BGF are
both 3082 bytes with the same token layout), and identical foot layers in their
damage tables (only the upper/cockpit layers differ).  So nothing found here says
the Loki should behave differently from the Thor.  The bench could not settle it
because BT_AIM moves the drawn RETICLE, not the pick ray -- there is currently no
harness to aim the pick at a chosen height.  That harness is the next step.

Diagnostics added:
  [pickgeom]   one-shot dump of every pick sphere in WORLD space (zone, r, centre)
  [pickcand]   which spheres a ray actually threaded, their perpendicular d, and
               which won -- the probe that makes "smallest wins" visible
  [dmgtable]   the whole authored DamageLookupTable: layers, slices, zone weights
  [dmgresolve] per hit: localY, heightRef, layer, theta, resolved zone
  [cylgate]    invalidDamageZone / table pointer / incoming zone at the gate
(the last three under BT_DMGTABLE_LOG, the first two under BT_PICK_LOG)
plus scratchpad/night8/footpick.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:47:30 -05:00

317 lines
11 KiB
C++

//===========================================================================//
// File: dmgtable.cpp //
// Project: BattleTech Brick: Entity Manager //
// Contents: Damage lookup table -- layered / pie-slice hit-location selector //
//---------------------------------------------------------------------------//
// Date Who Modification //
// -------- --- ---------------------------------------------------------- //
// --/--/95 ?? Initial coding. //
// 07/08/26 RE Completed for RUNTIME (was a non-functional skeleton on //
// no-op ReconTable/stream shims). See dmgtable.hpp header. //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
//
// RECONSTRUCTED from the shipped binary. Behaviour follows the Ghidra
// pseudo-C for the cluster @0x49de14..@0x49f5fb, cross-checked BYTE-FOR-BYTE
// against the shipped BTL4.RES type-29 resources (18 tables, exact stream
// consumption -- see context/combat-damage.md). Each method cites its @ADDR.
//
// Float constants (read-only globals in the decomp):
// _DAT_0049e520 = 1.0f (percentage-sum target, offline validation)
// _DAT_0049e72c / _DAT_0049e810 / _DAT_0049ed08 = 6.2831853f = 2*PI
// _DAT_0049ed04 = ~1e-4f (degenerate-direction epsilon)
// _DAT_0049ed00 / _DAT_0049e730 = 0.0f
//
// Engine-helper name mapping:
// FUN_00408050 RandomUnit() -> Scalar in [0,1) (engine RandomGenerator)
// FUN_00402948 read shared string [i32 len][len+1 bytes: chars + NUL]
// FUN_004dc8ec atan2f() FUN_004dcd00 fabsf() FUN_004dcd10/94 floorf
// FUN_00408bf8 world->local point xform (owner+0xd0) == Mech::WorldToLocal
//
#include <bt.hpp>
#pragma hdrstop
#include <math.h>
#if !defined(DMGTABLE_HPP)
# include <dmgtable.hpp>
#endif
#if !defined(MECH_HPP)
# include <mech.hpp>
#endif
//
// Tuning constants observed as read-only globals in the decomp.
//
static const Scalar PercentSumTarget = 1.0f; // _DAT_0049e520
static const Scalar TwoPi = 6.2831853f; // _DAT_0049e72c / _DAT_0049e810
static const Scalar DirEpsilon = 1.0e-4f; // _DAT_0049ed04
//###########################################################################
// DamageZonePercentTable (leaf)
//###########################################################################
//
// @0x49deb0 -- stream constructor. Stores the owner and rebuilds the entry
// list from the object stream (ReadEntries @0x49e5e4).
//
DamageZonePercentTable::DamageZonePercentTable(
Mech *owner,
MemoryStream *stream
):
Plug(),
owner(owner)
{
ReadEntries(stream);
}
DamageZonePercentTable::~DamageZonePercentTable() // @0x49df80
{
// entries is a value vector -- frees itself.
}
//
// @0x49de14 -- the weighted random roll. Walks the entries in ascending
// cumulative order, draws ONE uniform sample in [0,1) and returns the zone
// index of the first entry whose cumulative threshold exceeds the draw.
// -1 when the table is empty (treated as a miss by the caller).
//
int
DamageZonePercentTable::SelectZone() const
{
Scalar roll = RandomUnit(); // FUN_00408050
for (unsigned i = 0; i < entries.size(); ++i)
{
if (roll < entries[i].cumulative) // first threshold past the draw
{
return entries[i].zoneIndex;
}
}
return -1;
}
//
// @0x49e5e4 -- read the leading cell NAME string (FUN_00402948: [i32 len] then
// len+1 bytes = chars + trailing NUL), then the entry count and that many
// {cumulativePercent, zoneIndex} pairs. The name is descriptive only (the
// binary caches it but the roll never uses it), so it is consumed and dropped.
//
void
DamageZonePercentTable::ReadEntries(MemoryStream *stream)
{
int nameLen = 0;
stream->ReadBytes(&nameLen, 4); // FUN_00402948: string length
if (nameLen > 0)
{
char scratch[128];
int total = nameLen + 1; // chars + NUL (FUN_00402948 reads len+1)
if (total > (int)sizeof(scratch)) total = (int)sizeof(scratch);
stream->ReadBytes(scratch, total); // consume name + NUL
}
int count = 0;
stream->ReadBytes(&count, 4);
entries.reserve(count > 0 ? count : 0);
for (int i = 0; i < count; ++i)
{
Entry e;
stream->ReadBytes(&e.cumulative, 4); // f32 cumulative threshold
stream->ReadBytes(&e.zoneIndex, 4); // i32 zone segment index
entries.push_back(e);
}
}
//###########################################################################
// PieSlice
//###########################################################################
//
// @0x49e740 -- stream constructor (vtable 0x50bd90). Reads the RotateWithTorso
// flag (binary this[3]) and the slice count (this[0xb]) from the stream, then
// builds 'sliceCount' leaf tables. (The torso orientation is read live via
// owner->TorsoHeading() in SelectSlice -- no cached raw pointer.)
//
PieSlice::PieSlice(
Mech *owner,
MemoryStream *stream
):
Plug(),
owner(owner),
rotateWithTorso(0),
sliceCount(0)
{
stream->ReadBytes(&rotateWithTorso, 4); // binary reads into this[3]
stream->ReadBytes(&sliceCount, 4); // this[0xb]
slices.reserve(sliceCount > 0 ? sliceCount : 0);
for (int i = 0; i < sliceCount; ++i) // binary loops i=1..count building leaves
{
slices.push_back(new DamageZonePercentTable(owner, stream));
}
}
PieSlice::~PieSlice()
{
for (unsigned i = 0; i < slices.size(); ++i)
{
delete slices[i];
}
}
//
// @0x49e678 -- choose the slice for incoming angle 'theta'. When the wheel
// rotates with the torso, add the live torso twist (torso+0x1d8, via
// owner->TorsoHeading) and wrap into [0, 2*PI). Slice index =
// floor(theta * sliceCount / 2*PI) -- direct 0-based index (equivalent to the
// binary's float-key Find((index+1)*2*PI/sliceCount)).
//
DamageZonePercentTable *
PieSlice::SelectSlice(Scalar theta) const
{
if (sliceCount <= 0) return 0;
if (rotateWithTorso) // binary this[3] != 0
{
theta += owner->TorsoHeading(); // torso+0x1d8
if (theta >= TwoPi) theta -= TwoPi;
if (theta < 0.0f) theta += TwoPi;
}
int index = (int)floorf(theta * (Scalar)sliceCount * (1.0f / TwoPi));
if (index < 0) index = 0;
if (index > sliceCount - 1) index = sliceCount - 1;
return slices[index];
}
//###########################################################################
// DamageLookupTable (top -- layered)
//###########################################################################
//
// @0x49ea48 -- stream constructor (vtable 0x50bd84). Reads the layer count and
// builds that many PieSlice layers from the stream.
//
DamageLookupTable::DamageLookupTable(
Mech *owner,
MemoryStream *stream
):
Plug(),
owner(owner),
layerCount(0)
{
stream->ReadBytes(&layerCount, 4); // this[10]
layers.reserve(layerCount > 0 ? layerCount : 0);
for (int i = 0; i < layerCount; ++i)
{
layers.push_back(new PieSlice(owner, stream));
}
// #92 -- "unable to damage the foot panels on Loki ... could on Thor".
// Direct-fire damage does NOT use the mesh or the aim pick: the impact's
// HEIGHT picks a layer, its ANGLE picks a slice, and a weighted random roll
// picks the zone from that slice. So a zone that appears in NO slice of the
// lowest layer is simply unreachable by direct fire. Dump the whole table.
if (getenv("BT_DMGTABLE_LOG"))
DumpTable();
}
//
// #92 diagnostic: print every layer / slice / weighted zone entry.
//
void DamageLookupTable::DumpTable() const
{
DEBUG_STREAM << "[dmgtable] layers=" << layerCount << std::endl;
for (int L = 0; L < (int)layers.size(); ++L)
{
const PieSlice *layer = layers[L];
DEBUG_STREAM << "[dmgtable] layer " << L
<< " rotateWithTorso=" << layer->DiagRotateWithTorso()
<< " slices=" << layer->DiagSliceCount() << std::endl;
for (int S = 0; S < layer->DiagSliceCount(); ++S)
{
const DamageZonePercentTable *leaf = layer->DiagSlice(S);
if (leaf == 0) continue;
DEBUG_STREAM << "[dmgtable] slice " << S << ":";
Scalar prev = 0.0f;
for (int E = 0; E < leaf->DiagEntryCount(); ++E)
{
Scalar cum = leaf->DiagCumulative(E);
DEBUG_STREAM << " z" << leaf->DiagZone(E)
<< "=" << (int)((cum - prev) * 100.0f + 0.5f) << "%";
prev = cum;
}
DEBUG_STREAM << std::endl;
}
}
}
DamageLookupTable::~DamageLookupTable() // @0x49eadc
{
for (unsigned i = 0; i < layers.size(); ++i)
{
delete layers[i];
}
}
//
// @0x49eb54 (+ slice @0x49e678 + roll @0x49de14, chained by glue @0x49ed0c) --
// resolve a world impact point to a damage-zone index.
// local = owner->WorldToLocal(impact) (owner+0xd0 xform)
// layer = clamp(floor(layerCount * local.y / heightRef), 0, layerCount-1)
// theta = atan2(local.z, local.x) (degenerate direction -> random*2*PI)
// zone = layer.SelectSlice(theta).SelectZone()
// Returns -1 when the table is empty or the roll falls through (a miss).
//
int
DamageLookupTable::ResolveHit(const Point3D &impact) const
{
if (layerCount <= 0) return -1;
Point3D local = owner->WorldToLocal(impact); // FUN_00408bf8
Scalar heightRef = owner->CylinderReferenceHeight(); // owner+0x2ec[+0xc]
if (heightRef <= 0.0f) return -1;
// --- height -> layer (penetration depth) ---
Scalar depth = local.y; // binary local_2c
int layerIndex = (int)floorf((Scalar)layerCount * (depth / heightRef));
if (layerIndex < 0) layerIndex = 0;
if (layerIndex > layerCount - 1) layerIndex = layerCount - 1;
PieSlice *layer = layers[layerIndex];
// --- in-plane hit direction -> incident angle (else random scatter) ---
Scalar theta;
if (fabsf(local.z) > DirEpsilon && fabsf(local.x) > DirEpsilon)
{
theta = atan2f(local.z, local.x); // FUN_004dc8ec
if (theta < 0.0f) theta += TwoPi;
}
else
{
theta = RandomUnit() * TwoPi; // degenerate -> uniform angle
}
DamageZonePercentTable *leaf = layer->SelectSlice(theta); // FUN_0049e678
int resolved = leaf ? leaf->SelectZone() : -1; // FUN_0049de14
// #92: which LAYER does a given impact height land in? A foot shot that
// lands in a layer above the foot layer can never roll a foot zone.
if (getenv("BT_DMGTABLE_LOG"))
{
static int s_rh = 0;
if (s_rh++ < 400)
DEBUG_STREAM << "[dmgresolve] localY=" << local.y
<< " heightRef=" << heightRef
<< " frac=" << (depth / heightRef)
<< " layer=" << layerIndex << "/" << layerCount
<< " theta=" << theta
<< " -> zone " << resolved << std::endl;
}
return resolved;
}