Clean, self-contained extraction of the BattleTech-specific work from the
reverse-engineering workspace -- engine + game + content + build, with nothing
from Red Planet or the raw archive dumps. Builds green (Win32) and runs the
single-player drive->animate->target->fire->damage->destroy loop out of the box.
Layout:
engine/ MUNGA + MUNGA_L4 shared 2007 engine, carrying our BT render/loader
work (bgfload/L4D3D/L4VIDEO: BSL bit-slice decode, LOD/ground/shadow
models) + image codec; the minimal rp/ headers the audio HAL needs
game/ reconstructed BT logic + surviving-original BT source + fwd shims
+ WinMain launcher
content/ full runtime tree (BTL4.RES, VIDEO/, GAUGE/, AUDIO/, eggs, BTDPL.INI)
docs/ format specs + reconstruction ledgers
reference/ raw Ghidra pseudocode (recon source-of-truth) + decomp exporter
tools/ MP console emulator + map/resource scanners
One top-level CMake builds munga_engine lib + bt410_l4 game lib + btl4.exe.
All paths relativized (186 fwd shims + ~437 CMake abs paths -> repo-relative);
DXSDK is the one external, overridable via -DDXSDK. Verified: builds to a
byte-identical 2.27MB exe and runs combat (TARGET DESTROYED, 0 crashes) against
the bundled content.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
483 lines
17 KiB
C++
483 lines
17 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. //
|
|
//---------------------------------------------------------------------------//
|
|
// 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. No function in the cluster
|
|
// was linker-tagged (all file=?), so the module name "dmgtable.cpp" is
|
|
// inferred from CLASSMAP and the .tbl section keywords; the cluster was
|
|
// recovered via the DamageZone (mechdmg.cpp) adjacency, the vtables
|
|
// @0x50bd84/0x50bd90/0x50bd9c, and the assertion / format strings
|
|
// @0x50bc00..0x50bd00. Each non-trivial method cites the originating @ADDR.
|
|
//
|
|
// Float constants converted to decimal:
|
|
// _DAT_0049e520 = 0x3f800000 = 1.0f (percentage-sum target)
|
|
// _DAT_0049e72c = 0x40c90fdb = 6.2831853f = 2*PI (angular wrap)
|
|
// _DAT_0049e810 = 0x40c90fdb = 2*PI (slice-angle span)
|
|
// _DAT_0049e730 = 0.0f (angle floor)
|
|
// _DAT_0049f369 = 0x38d1b717 ~= 1.0e-4f (sort epsilon)
|
|
//
|
|
// Helper-function name mapping (engine internals referenced by the decomp):
|
|
// FUN_004178cc Plug ctor FUN_0041790c Plug::Clear
|
|
// FUN_00417858 Plug::Detach
|
|
// FUN_00402298 operator new FUN_004022d0 operator delete
|
|
// FUN_004023f4 MemoryBlock/RefCount ctor FUN_004024d8 RefCount release
|
|
// FUN_00402460 TextString ctor
|
|
// FUN_00408050 RandomUnit() -> Scalar in [0,1)
|
|
// FUN_004dcd10 (float)floor() setup FUN_004dcd94 lrint()/truncate
|
|
// FUN_004dcd00 fabsf()
|
|
// FUN_004dd3ec itoa(value, buf, radix)
|
|
// FUN_004274f8 Get_Segment_Index(skeletonModel, zoneName)
|
|
// FUN_00404088 NotationFile::Read(name,key,char**)
|
|
// FUN_00404118 NotationFile::Read(name,key,float*)
|
|
// FUN_00404190 NotationFile::Read(name,key,int*)
|
|
// FUN_00404720 NotationFile::GetPage(name,key)
|
|
// FUN_00403f84 NotationFile::PageExists(name)
|
|
// FUN_004d7f84 strcmp() / format match FUN_004d4b58 stricmp()
|
|
// FUN_004dbb24 DebugStream::operator<<(char*) FUN_004d9c38 endl
|
|
// The TableOf<>/PlugOf<> template slots (ctor/dtor/iterator) live at
|
|
// @0x49f204..@0x49f5b8 (vtables 0x50bd30/0x50bd50/0x50bd64) and are
|
|
// engine template instantiations, not damage-table logic.
|
|
//
|
|
|
|
#include <bt.hpp>
|
|
#pragma hdrstop
|
|
|
|
#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 SortEpsilon = 1.0e-4f; // _DAT_0049f369
|
|
|
|
// Per-entry byte layout inside the sorted TableOf<int> entry pool:
|
|
// entry+0x0c int zone segment index (payload returned by the roll)
|
|
// entry+0x14 Scalar cumulative percentage (ascending sort key)
|
|
|
|
|
|
//###########################################################################
|
|
//###########################################################################
|
|
// DamageZonePercentTable (leaf)
|
|
//###########################################################################
|
|
//###########################################################################
|
|
|
|
//
|
|
// @0x49deb0 -- stream constructor. Lays down the vtable @0x50bd9c, stores the
|
|
// owner (param_1[3]), allocates the entry-table memory block (param_1[4]),
|
|
// constructs the empty TableOf at +0x14 (FUN_0049f204), then rebuilds the
|
|
// entries from the object stream (ReadEntries @0x49e5e4).
|
|
//
|
|
DamageZonePercentTable::DamageZonePercentTable(
|
|
Mech *owner,
|
|
GenericObjectStream *stream
|
|
):
|
|
Plug() // FUN_004178cc(this, 2)
|
|
{
|
|
this->owner = owner; // param_1[3]
|
|
refCount = NewRefCount(0x10); // param_1[4]
|
|
entries.Init(); // FUN_0049f204(this+5, 0, 0)
|
|
ReadEntries(stream); // FUN_0049e5e4
|
|
}
|
|
|
|
//
|
|
// @0x49df80 -- detaches the entry table, releases the memory block, clears the
|
|
// Plug base.
|
|
//
|
|
DamageZonePercentTable::~DamageZonePercentTable()
|
|
{
|
|
// *this = &PTR_FUN_0050bd9c; Detach(entries); release(refCount); Plug::Clear
|
|
}
|
|
|
|
Logical
|
|
DamageZonePercentTable::TestInstance() const // @0x49e000
|
|
{
|
|
return True;
|
|
}
|
|
|
|
//
|
|
// @0x49de14 -- the weighted random roll (the "selector"). Iterates the entry
|
|
// table in ascending cumulative order, draws a single uniform sample in [0,1)
|
|
// and returns the zone index of the first entry whose cumulative percentage
|
|
// exceeds the draw. -1 when the table is empty.
|
|
//
|
|
int
|
|
DamageZonePercentTable::SelectZone() const
|
|
{
|
|
int selected = -1;
|
|
Scalar roll = RandomUnit(); // FUN_00408050
|
|
ReconTableIter it(entries); // FUN_0040139b(.,this+0x14)
|
|
|
|
it.First(); // slot+4
|
|
for (void *entry = it.Current(); entry != 0 && selected == -1; entry = it.Current())
|
|
{
|
|
Scalar threshold = *(Scalar *)((char *)entry + 0x14); // cumulative %
|
|
if (roll < threshold)
|
|
{
|
|
selected = *(int *)((char *)entry + 0xc); // zone segment index
|
|
}
|
|
it.Next(); // slot+0xc
|
|
// it.Current() re-read at slot+0x30 (end test)
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Object-stream I/O
|
|
//
|
|
// @0x49e524 -- write count, then each entry as {cumulativePercent, zoneIndex}.
|
|
//
|
|
void
|
|
DamageZonePercentTable::WriteEntries(GenericObjectStream *stream) const
|
|
{
|
|
stream->Write(&entries.count, 4);
|
|
ReconTableIter it(entries);
|
|
for (void *entry = it.First(); entry != 0; entry = it.Next())
|
|
{
|
|
stream->Write((char *)entry + 0x14, 4); // cumulative percentage
|
|
stream->Write((char *)entry + 0x0c, 4); // zone index
|
|
}
|
|
}
|
|
|
|
//
|
|
// @0x49e5e4 -- read count, then that many {cumulativePercent, zoneIndex} pairs,
|
|
// allocating a TableEntry (FUN_00424716) per pair and inserting into the table.
|
|
//
|
|
void
|
|
DamageZonePercentTable::ReadEntries(GenericObjectStream *stream)
|
|
{
|
|
int n;
|
|
stream->Read(&n, 4);
|
|
for (int i = 0; i < n; ++i)
|
|
{
|
|
int zoneIndex;
|
|
Scalar cumulative;
|
|
stream->Read(&cumulative, 4);
|
|
stream->Read(&zoneIndex, 4);
|
|
entries.Insert(NewTableEntry(zoneIndex), &cumulative); // FUN_00424716 + slot+8
|
|
}
|
|
}
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// BuildFromNotation -- DamageZonePercentTable (@0x49e00c)
|
|
//
|
|
// Parses a "DamageZone" page and validates that the percentages total 1.0.
|
|
//
|
|
int
|
|
DamageZonePercentTable::BuildFromNotation(
|
|
NotationFile *model_file,
|
|
const char *model_name,
|
|
const char *page_name,
|
|
NotationFile *table_file,
|
|
Mech *owner,
|
|
GenericObjectStream *stream
|
|
)
|
|
{
|
|
if (!model_file->PageExists(page_name)) // FUN_00403f84
|
|
{
|
|
DebugStream << page_name << " does not exist in " << model_file->GetFileName();
|
|
return False;
|
|
}
|
|
|
|
DamageZonePercentTable table;
|
|
NotationLine *line = ReconGetPage(model_file, page_name, "DamageZone"); // FUN_00404720 + 0050bc00
|
|
|
|
// "video"/"skeleton" model -> resolve to a skeleton so zone names map to
|
|
// segment indices.
|
|
const char *skeletonName = 0;
|
|
model_file->GetEntry("video", "skeleton", &skeletonName); // FUN_00404088 (0050bc0b/0050bc11)
|
|
Skeleton *skeleton = LoadSkeleton(ReconDir(table_file), skeletonName);
|
|
if (skeleton->SegmentCount() == 0)
|
|
{
|
|
DebugStream << skeletonName << " is empty or missing!"; // 0050bc1a
|
|
return False;
|
|
}
|
|
|
|
Scalar runningTotal = 0.0f;
|
|
for (; line != 0; line = line->Next())
|
|
{
|
|
// every line must be "<zoneName> <percentage>" ("%s %f")
|
|
if (strcmp(line->Format(), "%s %f") != 0) // FUN_004d7f84 / 0050bc30
|
|
{
|
|
DebugStream << model_name << " " << model_file->GetFileName()
|
|
<< ": " << page_name << " error in DamageZone Table Format!"; // 0050bc3b
|
|
return False; // -> cleanup @LAB_0049e1a9
|
|
}
|
|
|
|
char zoneName[64];
|
|
Scalar percentage;
|
|
line->Parse(zoneName, &percentage);
|
|
|
|
int segmentIndex = ReconSegIndex(skeleton, zoneName); // FUN_004274f8
|
|
if (segmentIndex == -1)
|
|
{
|
|
DebugStream << model_file->GetFileName() << " " << page_name
|
|
<< " " << zoneName << " Not Found!"; // 0050bc63
|
|
return False;
|
|
}
|
|
|
|
runningTotal += percentage;
|
|
table.entries.Insert(NewTableEntry(segmentIndex), &runningTotal); // FUN_00424716 + slot+8
|
|
}
|
|
|
|
if (runningTotal != PercentSumTarget) // _DAT_0049e520 == local_18
|
|
{
|
|
DebugStream << model_file->GetFileName() << " " << page_name
|
|
<< ": percentages do not sum to 100%(i.e., 1.0)!"; // 0050bc72
|
|
return False;
|
|
}
|
|
|
|
table.WriteEntries(stream); // FUN_0049e524(param_6, &table)
|
|
return True;
|
|
}
|
|
|
|
|
|
//###########################################################################
|
|
//###########################################################################
|
|
// PieSlice
|
|
//###########################################################################
|
|
//###########################################################################
|
|
|
|
//
|
|
// @0x49e740 -- stream constructor (vtable 0x50bd90). Reads owner@+0xc and the
|
|
// slice count@+0x2c from the stream, then builds 'sliceCount' leaf tables, each
|
|
// stamped with the cumulative slice angle i*(2*PI/sliceCount). Caches the
|
|
// torso orientation source pointer = owner+0x438 (used by SelectSlice).
|
|
//
|
|
PieSlice::PieSlice(
|
|
Mech *owner,
|
|
GenericObjectStream *stream
|
|
):
|
|
Plug() // FUN_004178cc(this, 2)
|
|
{
|
|
slices.Init(); // FUN_0049f2b2(this+5, 0, 1)
|
|
stream->Read(&this->owner, 4); // param_1+3 (param_3 slot+0x1c)
|
|
stream->Read(&sliceCount, 4); // param_1+0xb
|
|
|
|
Scalar sliceSpan = TwoPi / (Scalar)sliceCount; // _DAT_0049e810 / count
|
|
for (int i = 1; i <= sliceCount; ++i)
|
|
{
|
|
DamageZonePercentTable *slice =
|
|
new DamageZonePercentTable(owner, stream); // FUN_0049deb0 (size 0x2c)
|
|
Scalar cumulativeAngle = (Scalar)i * sliceSpan;
|
|
slices.Insert(slice, &cumulativeAngle); // slot+8
|
|
}
|
|
orientationSource = owner->TorsoOrientationSource(); // param_1[4] = *(owner+0x438)
|
|
}
|
|
|
|
PieSlice::~PieSlice()
|
|
{
|
|
// detaches 'slices', clears Plug base
|
|
}
|
|
|
|
//
|
|
// @0x49e678 -- choose the slice for incoming angle 'theta'. If the slice wheel
|
|
// rotates with the torso, add the live torso orientation
|
|
// (orientationSource+0x1d8) and wrap into [0, 2*PI). Then index the slice
|
|
// table by floor(theta * sliceCount / (2*PI)) and return its leaf table.
|
|
//
|
|
DamageZonePercentTable *
|
|
PieSlice::SelectSlice(Scalar theta) const
|
|
{
|
|
if (rotateWithTorso) // param_1+0xc != 0
|
|
{
|
|
theta += *(Scalar *)((char *)orientationSource + 0x1d8); // torso heading
|
|
if (TwoPi <= theta) theta -= TwoPi;
|
|
if (theta < 0.0f) theta += TwoPi; // _DAT_0049e730
|
|
}
|
|
|
|
int index = (int)floorf(theta * (Scalar)sliceCount * (1.0f / TwoPi)) + 1; // FUN_004dcd10/94
|
|
return (DamageZonePercentTable *)slices.Lookup(&index); // slot+0xc on this+0x14
|
|
}
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// BuildFromNotation -- PieSlice (@0x49e88c)
|
|
//
|
|
int
|
|
PieSlice::BuildFromNotation(
|
|
NotationFile *model_file,
|
|
const char *model_name,
|
|
const char *page_name,
|
|
NotationFile *table_file,
|
|
Mech *owner,
|
|
GenericObjectStream *stream
|
|
)
|
|
{
|
|
int rotateWithTorso;
|
|
if (!model_file->GetEntry(page_name, "RotateWithTorso", &rotateWithTorso)) // FUN_00404190 / 0050bc9f
|
|
{
|
|
DebugStream << page_name << " missing RotateWithTorso!"; // 0050bcaf
|
|
return False;
|
|
}
|
|
stream->Write(&rotateWithTorso, 4); // param_6 slot+0x20
|
|
|
|
NotationLine *line = ReconGetPage(model_file, page_name, "PieSlice"); // FUN_00404720 / 0050bcca
|
|
int sliceCount = line->Count(); // FUN_00403b60
|
|
stream->Write(&sliceCount, 4);
|
|
|
|
for (; line != 0; line = line->Next())
|
|
{
|
|
const char *sliceName = line->Value();
|
|
if (sliceName[0] == '\0') // puVar6[2] == 0
|
|
{
|
|
DebugStream << model_name << " error in damage table format " << page_name; // 0050bcd3
|
|
return -1;
|
|
}
|
|
// each named sub-page is a leaf percentage table
|
|
DamageZonePercentTable::BuildFromNotation(
|
|
model_file, model_name, sliceName, table_file, owner, stream); // FUN_0049e00c
|
|
}
|
|
return True;
|
|
}
|
|
|
|
|
|
//###########################################################################
|
|
//###########################################################################
|
|
// 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,
|
|
GenericObjectStream *stream
|
|
):
|
|
Plug() // FUN_004178cc(this, 2)
|
|
{
|
|
this->owner = owner; // param_1[3]
|
|
layers.Init(); // FUN_0049f3d5(this+4, 0, 1)
|
|
stream->Read(&layerCount, 4); // param_1[10]
|
|
|
|
for (int i = 0; i < layerCount; ++i)
|
|
{
|
|
PieSlice *layer = new PieSlice(owner, stream); // FUN_0049e740 (size 0x30)
|
|
layers.Insert(layer, &i); // slot+8 on this+4
|
|
}
|
|
}
|
|
|
|
DamageLookupTable::~DamageLookupTable() // @0x49eadc
|
|
{
|
|
// detaches 'layers', clears Plug base
|
|
}
|
|
|
|
Logical
|
|
DamageLookupTable::TestInstance() const // @0x49eb48 / @0x49e880
|
|
{
|
|
return True;
|
|
}
|
|
|
|
//
|
|
// @0x49eb54 -- resolve an impact point to a damage zone. Transforms 'impact'
|
|
// into the mech's local frame (owner+0xd0), then derives a normalised
|
|
// penetration-depth fraction (clamped against the armour reference held at
|
|
// owner+0x2ec[+0xc]). That fraction selects a layer; the chosen PieSlice is
|
|
// then handed an incident angle -- either computed from the in-plane hit
|
|
// direction (atan2-style, FUN_004dc8ec) or, when the direction is degenerate,
|
|
// a uniform random angle in [0, 2*PI) -- and PieSlice::SelectSlice resolves it.
|
|
//
|
|
void
|
|
DamageLookupTable::ResolveHit(const Point3D &impact) const
|
|
{
|
|
Point3D local = owner->WorldToLocal(impact); // FUN_00408bf8 / owner+0xd0
|
|
|
|
Scalar armourRef = *(Scalar *)(*(int *)((char *)owner + 0x2ec) + 0xc);
|
|
Scalar depth = local.y; // local_2c
|
|
if (0.0f <= depth) // _DAT_0049ed00
|
|
{
|
|
if (armourRef < depth) depth = armourRef; // clamp to deepest layer
|
|
}
|
|
|
|
int layerIndex = (int)floorf((Scalar)layerCount * (depth / armourRef)); // FUN_004dcd10/94
|
|
if (layerIndex < 0) layerIndex = 0;
|
|
if (layerCount - 1 < layerIndex) layerIndex = layerCount - 1;
|
|
|
|
PieSlice *layer = (PieSlice *)layers.Lookup(&layerIndex); // slot+0xc
|
|
|
|
// incident angle from the in-plane (x,z) hit direction, else random
|
|
Scalar theta;
|
|
if (fabsf(local.z) > SortEpsilon && fabsf(local.x) > SortEpsilon) // _DAT_0049ed04
|
|
{
|
|
theta = atan2f(local.z, local.x); // FUN_004dc8ec
|
|
if (theta < 0.0f) theta += TwoPi;
|
|
}
|
|
else
|
|
{
|
|
theta = RandomUnit() * TwoPi; // FUN_00408050 * _DAT_0049ed08
|
|
}
|
|
|
|
layer->SelectSlice(theta); // FUN_0049e678
|
|
// (caller then rolls the returned leaf table via SelectZone @0x49de14)
|
|
}
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// BuildFromNotation -- DamageLookupTable (@0x49ed28)
|
|
//
|
|
// Reads consecutive "Layer1", "Layer2", ... pages (number appended via itoa,
|
|
// FUN_004dd3ec) and parses each as a PieSlice page. When no layers are found
|
|
// the page is malformed.
|
|
//
|
|
int
|
|
DamageLookupTable::BuildFromNotation(
|
|
NotationFile *model_file,
|
|
const char *model_name,
|
|
NotationFile *table_file,
|
|
Mech *owner,
|
|
GenericObjectStream *stream
|
|
)
|
|
{
|
|
char numbered[20];
|
|
int layer = 1;
|
|
// "Layer" + itoa(layer) -> "Layer1"
|
|
TextString key = TextString("Layer") + itoa(layer, numbered, 10); // 0050bcf2 / FUN_004dd3ec
|
|
|
|
while (model_file->PageExists(key)) // FUN_00403f84
|
|
{
|
|
++layer;
|
|
key = TextString("Layer") + itoa(layer, numbered, 10);
|
|
// each "LayerN" page is a PieSlice page
|
|
PieSlice::BuildFromNotation(
|
|
model_file, model_name, key, table_file, owner, stream); // FUN_0049e88c (@line 8809)
|
|
}
|
|
|
|
if (layer == 1) // no layers parsed
|
|
{
|
|
DebugStream << model_file->GetFileName() << " has bad format!"; // 0050bcf8
|
|
return False;
|
|
}
|
|
return True;
|
|
}
|
|
|
|
|
|
//###########################################################################
|
|
// Tool support
|
|
//
|
|
// @0x49f301 -- table-entry comparator: orders by cumulative threshold (+0x14)
|
|
// within SortEpsilon. Returns -1 / 0 / +1.
|
|
//
|
|
int
|
|
CompareTableEntryThreshold(const void *a, const void *b)
|
|
{
|
|
Scalar diff = *(Scalar *)((char *)a + 0x14) - *(Scalar *)((char *)b + 0x14);
|
|
if (fabsf(diff) < SortEpsilon) // _DAT_0049f369
|
|
return 0;
|
|
return (*(Scalar *)((char *)a + 0x14) <= *(Scalar *)((char *)b + 0x14)) ? -1 : 1;
|
|
}
|