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>
335 lines
12 KiB
C++
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;
|
|
}
|