The frame-adapter's inferred twist-sign flip (SelectSlice theta -= twist, vs the binary's += pre-reflection) was the last unverified half of #124 -- every earlier probe ran at twist 0. Bench: stationary madcat target with the torso PINNED at 0 / +140 / -140 deg, LRM salvos from a fixed shooter (missiles = the authentic cylinder path; the binary DROPPED zone -1 beam damage). Result: slice picks track the physically-facing flank in BOTH directions (twist-left -> right-family zones for left-flank impacts, twist-right -> left-family), deterministic, wrong-sign outcome (slice 7 vs observed slice 1) clearly excluded. No game-code change needed. Instrumentation added (all env-gated): - torso.cpp BT_FORCE_TWIST=<-1..1>: HOLD the sim's analogTwistAxis (the input-level pin was dead -- live input rides the CONTROLS.MAP device push, and Basic mode auto-centers; sim-level is plumbing-independent). - dmgtable.cpp [slice] line: rot flag, live twist, thetaIn/thetaAdj, chosen slice -- the weighted leaf roll made zone-only logs ambiguous. - [dmgresolve] now names the zone (BTMechZoneSegAndName). - mech4.cpp BT_FORCE_TWIST input pin + one-shot mode cycle (kept as doc of the dead path), mechmppr [mppr] mode probe. - scratchpad/night11/twistsign.sh: the 3-config bench. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
363 lines
13 KiB
C++
363 lines
13 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;
|
|
|
|
Scalar thetaIn = theta;
|
|
if (rotateWithTorso) // binary this[3] != 0
|
|
{
|
|
// #124 frame adapter, angular half: theta arrives in the 1995 frame
|
|
// (ResolveHit's z-reflection), so the twist -- measured in OUR frame
|
|
// -- enters with the OPPOSITE sign (a reflection reverses angular
|
|
// direction). Binary form: theta += torso+0x1d8.
|
|
theta -= owner->TorsoHeading(); // torso+0x1d8 (sign: see adapter)
|
|
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;
|
|
|
|
// #124 twist-sign bench: expose the twist term itself -- the leaf is a
|
|
// weighted roll, so the SLICE choice (not the rolled zone) is the signal.
|
|
if (getenv("BT_DMGTABLE_LOG"))
|
|
{
|
|
static int s_sl = 0;
|
|
if (s_sl++ < 400)
|
|
DEBUG_STREAM << "[slice] rot=" << (int)rotateWithTorso
|
|
<< " twist=" << (float)owner->TorsoHeading()
|
|
<< " thetaIn=" << (float)thetaIn
|
|
<< " thetaAdj=" << (float)theta
|
|
<< " -> slice " << index << "/" << sliceCount << std::endl;
|
|
}
|
|
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, corrected 2026-08-03 by the MUZZLE-ANCHOR
|
|
// probe). The 1995 resolver frame: FORWARD = +Z (the authored ring puts
|
|
// Front* cells in the +Z arc) and RIGHT = +X (W0/W7 = "Right*") [T1
|
|
// slice-name data]. Our engine frame, measured with the mech's own
|
|
// asymmetric geometry as the anchor (BT_ASPECT_TEST muzzle probes -- the
|
|
// LEFT missile pod LRM15_1 sits at raw local x=-2.32, the RIGHT pod
|
|
// LRM15_2 at x=+2.32): RIGHT = +X (SAME as 1995) but FORWARD = -Z
|
|
// (drive-code convention, "forward = -Z at heading 0"). The frames
|
|
// therefore differ by a Z-NEGATION ONLY -- a REFLECTION, not the pi
|
|
// rotation first committed (that crossed the pods: left muzzle resolved
|
|
// rtorso). y (the band axis) untouched.
|
|
//
|
|
// A reflection reverses the angular walk direction, so the OTHER angular
|
|
// input -- the torso-twist term SelectSlice adds -- must flip sign with
|
|
// it (applied there, same adapter). Twist was 0 in every probe; the
|
|
// twisted-torso verify is the follow-up on #124.
|
|
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)
|
|
{
|
|
extern int BTMechZoneSegAndName(void *mech_v, int zone_idx,
|
|
int *seg_out, const char **name_out);
|
|
int seg = -1; const char *zname = 0;
|
|
if (resolved >= 0)
|
|
BTMechZoneSegAndName((void *)owner, resolved, &seg, &zname);
|
|
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
|
|
<< " '" << (zname ? zname : "?") << "'" << std::endl;
|
|
}
|
|
}
|
|
return resolved;
|
|
}
|