Files
BT411/game/reconstructed/dmgtable.cpp
T
Joe DiPrimaandClaude Fable 5 c5dfb002f3 #124: hit-location ASPECT was rotated 180 degrees -- the resolver now speaks the 1995 frame
Night-10's systematic targeting audit (rear panels unhittable from
direct fire, Owens left torso hitting the RIGHT weapon pod and vice
versa, missiles favoring the far side) measured down to one geometric
fact: the authored wedge ring puts the Front* cells in the +Z arc
(slice-name audit, bit-verified tables), i.e. the 1995 resolver frame
has mech-forward = +Z -- while OUR engine's entity frame carries
forward along -Z. Bench proof: a victim facing its attacker took
frontal beams at local z = -4..-10, theta ~270 deg, resolving the
REAR-named cells (zones 12/14/15) every time.

The two frames differ by a pi ROTATION about Y -- rotation, not
reflection, because the field flank observations CROSS (left->right)
rather than persist. ResolveHit now rotates the local impact into the
cylinder's frame (negate x and z; y untouched; handedness preserved so
the SelectSlice torso-twist add keeps its sign).

A/B (172 resolves, live missiles+beams on a facing victim): before
theta~4.7 -> rear zones only; after theta~1.42-1.59 -> utorso/dtorso/
ltorso/rtorso/arm -- the front-authored cells, with the per-burst
re-roll scatter intact.

NOT this bug: the "one band LOW" Y-axis half of the night-10 table --
that is #16 (pick-ray boresight parallax, code-confirmed, still open).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 01:12:32 -05:00

335 lines
12 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;
// PORT FRAME ADAPTER (#124, measured 2026-08-03). The 1995 resolver frame
// carries the mech's FORWARD along +Z: the authored wedge ring puts the
// FrontChest-family cells in the +Z arc (slice-name audit [T1]) -- frontal
// hits MUST read z > 0 for the shipped tables to mean what they say. Our
// engine's entity frame carries forward along -Z (bench 2026-08-03: the
// dummy facing its attacker takes frontal beams at local z = -4..-10,
// theta ~270 deg, resolving the REAR-named cells; the night-10 field table
// -- rear panels unhittable, Owens left torso -> RIGHT pod -- is this same
// pi ROTATION at every aspect: rotation, not reflection, because the
// left/right flanks CROSS rather than persist). Express the impact in the
// cylinder's frame: rotate pi about Y. y (the band axis) is untouched,
// and the frames share handedness, so the torso-twist add in SelectSlice
// keeps its sign.
local.x = -local.x;
local.z = -local.z;
// --- 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] impact=(" << impact.x << ","
<< impact.y << "," << impact.z << ")"
<< " local=(" << local.x << "," << local.y << "," << local.z << ")"
<< " heightRef=" << heightRef
<< " frac=" << (depth / heightRef)
<< " layer=" << layerIndex << "/" << layerCount
<< " theta=" << theta
<< " -> zone " << resolved << std::endl;
}
return resolved;
}