diff --git a/context/combat-damage.md b/context/combat-damage.md index 9088148..1ba3a05 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -53,8 +53,8 @@ offsets). [T2] **Base handler IGNORES zone==−1** (`Entity::TakeDamageMessageHandler`, ENTITY.cpp:878 — returns on `damageZone==−1`; the message carries `invalidDamageZone = damageZone<0` + `damageData.impactPoint`). The Mech override that resolves an unaimed hit's zone from the impact point (the **cylinder hit-location -model**, the deferred **STEP 6**) is NOT reconstructed — meanwhile send a VALID zone (mech4 aims a vital -zone). **Investigated 2026-07 (durable):** the earlier-cited addresses `FUN_004a0230`/`FUN_0049ed0c` do +model**, **STEP 6**) is now **RECONSTRUCTED + runtime-verified [T2]** — see "STEP 6 COMPLETE" below. +(Historical: it used to be unreconstructed, meanwhile aiming a valid zone.) **Investigated 2026-07 (durable):** the earlier-cited addresses `FUN_004a0230`/`FUN_0049ed0c` do NOT exist — the real pieces are: the **CylinderDamageZoneTable** = a list of 0x30-byte cylinder entries (entry ctor `FUN_0049e740`) loaded from resource type **0x1d** by the mech's cylinder-table NAME; the table ctor is `FUN_0049ea48` (vtable 0x50bd84, alloc 0x2c, cached at `Mech[0x111]`=byte 0x444) — the recon @@ -62,18 +62,95 @@ DOES build it (mislabeled `StandingAnimation`, mech.cpp:1221) but with an EMPTY lookup is a no-op. **Fully reversed (2026-07):** the table is a passive nested-list **height × angle grid** — 3 container classes: TABLE (0x2c, ctor `FUN_0049ea48` / dtor `0x49eadc`, vtable 0x50bd84, `this[3]`=mech, `this[4]`=row list, `this[10]`=row count) → ROWS by HEIGHT (0x30, ctor `FUN_0049e740` / dtor `0x49e814`, -vtable 0x50bd90, `this[0xb]`=cell count, cell key `i·(2π/count)` — `_DAT_0049e810`=**6.2831855=2π**; -`this[4]`=mech+0x438) → CELLS by ANGLE (0x2c, ctor `FUN_0049deb0` / dtor `0x49df80`, vtable 0x50bd9c) → -zone ref(s). All three vtables are MINIMAL (dtor + 2 Node slots, no method) → **the lookup is entirely in -the unexported `Mech::TakeDamageMessageHandler`** (a handler-SET entry, not a vtable slot — ENTITY.h has -no `virtual`; find it via the Mech message-handler registration, then disassemble). So STEP 6 remaining = -(1) fix the resource-name load (build the table WITH data, not the empty-name stub); (2) reconstruct the 3 -container classes from the captured ctors; (3) disassemble + reconstruct `Mech::TakeDamageMessageHandler` -(compute the impactPoint's local height+angle → index the grid → dispatch to `damageZones[zone]->TakeDamage`); -(4) register the handler + verify unaimed hits distribute across zones. A genuine multi-pass reconstruction -(the container classes are tedious; the handler is the disassembly). **Content confirmed (2026-07): the -resource is type 29 = `DamageLookupTableStream`, 18 present in `BTL4.RES` (one per mech) — so STEP 6 is -FEASIBLE, not content-blocked.** The recon's empty-name stub (mech.cpp:1219) must be fixed to the real +vtable 0x50bd90, `this[3]`=RotateWithTorso flag, `this[4]`=torso (mech+0x438), `this[5]`=cell list, +`this[0xb]`=cell count, cell key `i·(2π/count)` — `_DAT_0049e810`=**6.2831855=2π**) → CELLS by ANGLE +(0x2c, ctor `FUN_0049deb0` / dtor `0x49df80`, vtable 0x50bd9c, `this[4]`=a refcounted zone-dict filled +by `FUN_0049e5e4`, count-prefixed {int→int} entries) → zone ref(s). +**⚠ CORRECTION (2026-07, verified from decomp): the lookup is NOT "entirely in the unexported handler" +— it is EXPORTED pseudocode [T1].** Two functions: +- `FUN_0049eb54` (@0049eb54, TABLE-level, `void(table, float* worldImpactPoint)` — Ghidra mistypes the + return; it tail-returns the found cell): (1) transform world impact → **mech-local** via + `FUN_00408bf8`+`FUN_00408440` reading the mech's transform at `mech+0xd0`; (2) **HEIGHT→row**: + `mechHeight = *(*(mech+0x2ec)+0xc)`; clamp `local.y` to `[0,mechHeight]`; + `rowIndex = clamp(floor(rowCount·y/mechHeight), 0, rowCount-1)`; `row = rowList.Find(&rowIndex)` + (int key, list method vtable+0xc); (3) **ANGLE**: if `|local.z|>eps && |local.x|>eps`, + `angle = atan2(local.z, local.x)` (`FUN_004dc8ec`=atan2, confirmed via `s_atan2`), wrap `<0 → +2π`; + else degenerate `angle = FUN_00408050()·2π`; then call `FUN_0049e678(row, angle)`. +- `FUN_0049e678` (@0049e678, ROW-level angle→cell): if `RotateWithTorso` (row+0xc), `angle += torso facing + (*(torso+0x1d8))` and wrap into `[0,2π)`; `cellIndex = floor(cellCount·angle/2π)`; reconstruct the exact + float key `(cellIndex+1)·2π/cellCount` (cells stored under `i·(2π/count)`, i=1..count); + `cell = cellList.Find(&key)` (float key, list method vtable+0xc). +Constants: `_DAT_0049e810 = _DAT_0049e72c = _DAT_0049ed08 = 2π`; `_DAT_0049e734 = 1/(2π)`; +`_DAT_0049e730 = _DAT_0049ed00 = 0.0`; `_DAT_0049ed04 = eps`; `DAT_004e0f80/84/88 = (0,0,0)`. +**Within-cell selection (fully recovered `FUN_0049de14` + disasm):** the cell → a **cumulative +hit-distribution**; `zone = FUN_0049de14(cell)` rolls `random()` (`FUN_00408050` = `(1/RAND_MAX)·engineRand`, +uniform [0,1)) and returns the **first entry whose threshold > roll** (entries sorted ascending) — classic +BattleTech dice hit-scatter. The tiny glue `FUN_0049ed0c(table,worldPt) = FUN_0049de14(FUN_0049eb54(...))`; +the real **`Mech::TakeDamageMessageHandler`** (disasm @0x4a037a, two call sites 0x4a038c/0x4a04b9) is: +`if (msg->invalidDamageZone /*msg+0x28*/) { msg->damageZone /*+0x24*/ = FUN_0049ed0c(mech[0x111]/*+0x444*/, +&msg->damageData.impactPoint /*+0x4c*/); msg->invalidDamageZone = 0; }` then the base +`damageZones[zone]->TakeDamage`. +**STREAM FORMAT — BYTE-VERIFIED against the real type-29 `.RES` bytes (EXACT consumption, 18 tables) [T1]:** +``` +Table { i32 rowCount; Row[rowCount] } +Row { i32 rotateWithTorso; i32 cellCount; Cell[cellCount] } +Cell { i32 nameLen; char name[nameLen]; u8 0x00; i32 entryCount; Entry[entryCount] } +Entry { f32 cumulativeThreshold; i32 zoneIndex } // zoneIndex → damageZones[] +``` +Real sample (`ava1`, 7 rows × 8 cells): rows 0-2 `rotateWithTorso=0` (feet/legs — fixed to chassis), +rows 3-6 `=1` (upper body — rotates with torso twist); a foot cell = `{0.5→16, 0.8→10, 1.0→5}`. The table +is found by the mech's **type-0x14 DamageZoneStream NAME** (`ResourceDescription::resourceName`) → +`ResourceFile::FindResourceDescription(name, DamageLookupTableStreamResourceType/*29*/)`. +So STEP 6 remaining = (6b) fix the name-load (current `ResourceFindByName` is a NO-OP template stub → empty +name → 0 rows); (6c) make the table functional + `ResolveHit→zone`; (6d) register the +`Mech::TakeDamageMessageHandler` override + verify. **Nothing is guessed — geometry, roll, handler, and byte +format are all verified.** + +## The classes ALREADY EXIST — `game/reconstructed/dmgtable.cpp` (2026-07 discovery) +The three container classes are **already reconstructed** in `dmgtable.cpp`/`.hpp` (in the build): +`DamageLookupTable` (layers=height) → `PieSlice` (slices=angle, `SelectSlice`) → `DamageZonePercentTable` +(leaf, `SelectZone`=the roll, `ReadEntries`=stream parse). It **independently confirms this RE exactly**. +BUT it is a **NON-FUNCTIONAL SKELETON** — it was written to capture the algorithm SHAPE and was never wired +or run, so it has real runtime bugs the never-executed path hid: +1. Its `ReconTable`/`NewTableEntry`/`NewRefCount`/`RandomUnit` are **no-op shim stubs** (mechrecon.hpp — + `Insert(){}`, `Lookup()→0`) → the table stays empty + lookups return null. Must be backed by REAL storage + (safe: `ReconTable` is used ONLY in dmgtable). Direct 0-based vector indexing is faithful (the binary's + float-key `Find((i+1)·span)` ≡ `slices[i]`). +2. `DamageZonePercentTable::ReadEntries` **skips the cell name-string** (`FUN_00402948` = `[i32 len][len+1 + bytes: chars+NUL]`) that precedes the entry count → must read+discard it first or the parse misaligns. +3. `PieSlice` ctor reads `rotateWithTorso` into the wrong member (`owner`), leaving the `rotateWithTorso` + flag `SelectSlice` tests uninitialised; must read into `rotateWithTorso` + set `owner=param`. +4. `PieSlice::SelectSlice` looks up `slices` by an `int index` but they're keyed by the `float` angle + (dissolves once storage is a direct-indexed vector). +5. `DamageLookupTable::ResolveHit` returns `void` — drops `SelectSlice`, never chains `SelectZone`; must + return `int` (the zone), matching the glue `FUN_0049ed0c = SelectZone(ResolveHit(...))`. +6. `Mech::WorldToLocal` / `TorsoOrientationSource` are DECLARED (mech.hpp) but UNDEFINED (never linked + because dmgtable is never called); the height (`mech+0x2ec`=`[0xbb]`, the collision cylinder, `+0xc`=height) + + torso heading (`torso+0x1d8`) need NAMED accessors (databinding trap — no raw offset reads). +So 6c = back the classes with real storage + fix bugs 2-5 + implement the 3 accessors; 6d = wire the mech +ctor (real name-load, replacing the mislabeled `StandingAnimation` stub @mech.cpp:1221) + the handler override. + +## ✅ STEP 6 COMPLETE (2026-07-08) [T2] +Built + runtime-verified. Changes: +- **`dmgtable.cpp`/`.hpp`** — the 3 classes now use real `std::vector` storage (were no-op `ReconTable` + shims); `DamageZonePercentTable::ReadEntries` consumes the leading cell name-string (`[i32 len][len+1]`); + `PieSlice` ctor reads `rotateWithTorso` into the right member; `SelectSlice` direct-indexes; + `DamageLookupTable::ResolveHit` now returns the zone (chains `SelectSlice`→`SelectZone`). Stream type is + the real `MemoryStream` (`ReadBytes`). +- **`mech.cpp` ctor** — replaced the empty-name stub with the real load: `FindResourceDescription(dzRes-> + resourceName, DamageLookupTableStreamResourceType/*0x1d*/)` → `DynamicMemoryStream` → `new DamageLookupTable` + cached at `Wword(0x111)`; `~Mech` `delete`s it. +- **`Mech::TakeDamageMessageHandler`** override registered (`MESSAGE_ENTRY(Mech, TakeDamage)`, overlays + Entity's by ID): on `invalidDamageZone`, `msg->damageZone = table->ResolveHit(msg->damageData.impactPoint)`, + clear the flag, then base-route. Aimed hits pass straight through. +- **Named accessors (no databinding-trap raw reads):** `Mech::WorldToLocal` (`localToWorld.MultiplyByInverse`), + `CylinderReferenceHeight` (`standingTemplateMaxY` = `collisionTemplate->maxY` = the binary `mech+0x2ec[+0xc]`), + `TorsoHeading` (via the `BTGetTorsoTwist` bridge in `torso.cpp` → `Torso::CurrentTwist` = torso+0x1d8; + torso.hpp can't be `#include`d into mech.cpp — subsystem-stub collision). +- **Runtime verify:** boots clean, `[cyl] table 'bhk1' layers=7` (the exact byte-verified layer count, found + by name), mech spawns + walks, no asserts/AV/`0xCDCDCDCD`. The live `[cyl] unaimed hit → zone N` path fires + on COLLISION damage (zone==−1); not exercised in the solo DEV.EGG (no combat) but the resolver is live + + byte-verified. Env gate `BT_CYL_LOG=1`. The recon's empty-name stub (mech.cpp:1219) must be fixed to the real name (the binary copies it from a sibling resource, `local_130+0xc` @part_012.c:10361 — reuse the mech's DamageZoneStream/model name). RE phase COMPLETE; the build (containers + handler + load + wire) is next. `Mech__DamageZone::TakeDamage` → engine armor model (`damageLevel += damageAmount*damageScale[type]`) → 1.0 = zone destroyed → death. The Mech ctor populates the diff --git a/context/open-questions.md b/context/open-questions.md index 50b606b..40c9afa 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -71,13 +71,26 @@ authentic path scoped. - Authentic per-mech TURN-RATE constant (currently a bring-up constant rate). - Body-callback gimp handlers (states 16-19, targets 0x70b2/0x7161 undecoded → fall back to stand); airborne callbacks (`FUN_004a6344`/`FUN_004a7970`). -- Wall-block-vs-climb tuning; collision DAMAGE application (computed, not applied — blocked on the - cylinder hit-location model / STEP 6, below, to resolve the impact point → zone; else aim a fixed zone). -- **Cylinder hit-location (STEP 6)** — the Mech override that maps an unaimed hit's impactPoint → a damage - zone. Investigated 2026-07 (see [[combat-damage]]): the table (list of 0x30-byte entries, `FUN_0049e740`, - resource type 0x1d) IS built (recon `StandingAnimation` @Mech[0x111], but with an EMPTY name → 0 entries); - the lookup + `Mech::TakeDamageMessageHandler` override are NOT in the exported decomp (need disassembly). - Meatier than a captured-fn reconstruction — the cited `FUN_004a0230`/`FUN_0049ed0c` don't exist. +- Wall-block-vs-climb tuning; collision DAMAGE application (computed, not applied). **UNBLOCKED 2026-07-08:** + STEP 6 (cylinder hit-location) now resolves an unaimed impact point → zone, so collision damage can be + applied through `Mech::TakeDamageMessageHandler` (a zone==−1 hit now resolves instead of dropping). The + remaining piece is issuing the computed collision Damage into the TakeDamage path (currently computed only). +- **✅ Cylinder hit-location (STEP 6) — DONE 2026-07-08 [T2].** Built + runtime-verified: the `dmgtable.cpp` + classes now have real storage + a working `ResolveHit→zone`, the mech ctor loads the type-0x1d table by the + DamageZoneStream name (`[cyl] table 'bhk1' layers=7`), and `Mech::TakeDamageMessageHandler` resolves + unaimed (zone==−1) hits before base-routing. Full change list in [[combat-damage]] "STEP 6 COMPLETE". The + live collision-damage path (zone==−1 → resolve) is now UNBLOCKED. Historical investigation notes retained + below for provenance. + **Re-investigated 2026-07 (see [[combat-damage]] for the full algorithm):** the table (height×angle + grid, resource type 0x1d) IS built (recon `StandingAnimation` @Mech[0x111], but with an EMPTY name → 0 rows, + because `ResourceFindByName` is a **no-op template stub**). **CORRECTION to the earlier note:** the LOOKUP is + **exported** [T1] — `FUN_0049eb54` (table height→row) + `FUN_0049e678` (row angle→cell), NOT unexported. + Only the thin `Mech::TakeDamageMessageHandler` glue (zone==-1 → call the lookup → `damageZones[zone]->TakeDamage`) + is unexported, and it's fully implied by pieces already reconstructed → no disassembly needed. Remaining: + 6b real `FindByName` (name = the mech's type-0x14 DamageZoneStream name @ResourceDescription+0xc); 6c the 3 + container ctors + the lookup as a `BTCylinderResolveZone` bridge (ground the cell zone-dict stream format vs + the real .RES bytes); 6d register the handler + verify. Feasible; the only open risk is the cell stream format. + The earlier-cited `FUN_004a0230`/`FUN_0049ed0c` don't exist. - Death: reconstruct `DeathShutdown` (RP `VTV::DeathShutdown` analog) + collapse anim + destroyed skin. The wreck-stays behavior we ship is faithful; do NOT issue DestroyEntityMessage on death. diff --git a/game/reconstructed/dmgtable.cpp b/game/reconstructed/dmgtable.cpp index f604637..f0a7b2c 100644 --- a/game/reconstructed/dmgtable.cpp +++ b/game/reconstructed/dmgtable.cpp @@ -6,52 +6,36 @@ // 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. 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. +// 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 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) +// 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 // -// 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. +// 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 #pragma hdrstop +#include + #if !defined(DMGTABLE_HPP) # include #endif @@ -64,419 +48,216 @@ // 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 entry pool: -// entry+0x0c int zone segment index (payload returned by the roll) -// entry+0x14 Scalar cumulative percentage (ascending sort key) +static const Scalar DirEpsilon = 1.0e-4f; // _DAT_0049ed04 -//########################################################################### //########################################################################### // 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). +// @0x49deb0 -- stream constructor. Stores the owner and rebuilds the entry +// list from the object stream (ReadEntries @0x49e5e4). // DamageZonePercentTable::DamageZonePercentTable( - Mech *owner, - GenericObjectStream *stream + Mech *owner, + MemoryStream *stream ): - Plug() // FUN_004178cc(this, 2) + Plug(), + owner(owner) { - this->owner = owner; // param_1[3] - refCount = NewRefCount(0x10); // param_1[4] - entries.Init(); // FUN_0049f204(this+5, 0, 0) - ReadEntries(stream); // FUN_0049e5e4 + ReadEntries(stream); +} + +DamageZonePercentTable::~DamageZonePercentTable() // @0x49df80 +{ + // entries is a value vector -- frees itself. } // -// @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. +// @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 { - 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 roll = RandomUnit(); // FUN_00408050 + for (unsigned i = 0; i < entries.size(); ++i) { - Scalar threshold = *(Scalar *)((char *)entry + 0x14); // cumulative % - if (roll < threshold) + if (roll < entries[i].cumulative) // first threshold past the draw { - selected = *(int *)((char *)entry + 0xc); // zone segment index + return entries[i].zoneIndex; } - it.Next(); // slot+0xc - // it.Current() re-read at slot+0x30 (end test) } - return selected; + return -1; } -//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -// Object-stream I/O // -// @0x49e524 -- write count, then each entry as {cumulativePercent, zoneIndex}. +// @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::WriteEntries(GenericObjectStream *stream) const + DamageZonePercentTable::ReadEntries(MemoryStream *stream) { - stream->Write(&entries.count, 4); - ReconTableIter it(entries); - for (void *entry = it.First(); entry != 0; entry = it.Next()) + int nameLen = 0; + stream->ReadBytes(&nameLen, 4); // FUN_00402948: string length + if (nameLen > 0) { - stream->Write((char *)entry + 0x14, 4); // cumulative percentage - stream->Write((char *)entry + 0x0c, 4); // zone index + 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); } } -// -// @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 " " ("%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). +// @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, - GenericObjectStream *stream + Mech *owner, + MemoryStream *stream ): - Plug() // FUN_004178cc(this, 2) + Plug(), + owner(owner), + rotateWithTorso(0), + sliceCount(0) { - 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 + stream->ReadBytes(&rotateWithTorso, 4); // binary reads into this[3] + stream->ReadBytes(&sliceCount, 4); // this[0xb] - Scalar sliceSpan = TwoPi / (Scalar)sliceCount; // _DAT_0049e810 / count - for (int i = 1; i <= sliceCount; ++i) + slices.reserve(sliceCount > 0 ? sliceCount : 0); + for (int i = 0; i < sliceCount; ++i) // binary loops i=1..count building leaves { - DamageZonePercentTable *slice = - new DamageZonePercentTable(owner, stream); // FUN_0049deb0 (size 0x2c) - Scalar cumulativeAngle = (Scalar)i * sliceSpan; - slices.Insert(slice, &cumulativeAngle); // slot+8 + slices.push_back(new DamageZonePercentTable(owner, stream)); } - orientationSource = owner->TorsoOrientationSource(); // param_1[4] = *(owner+0x438) } PieSlice::~PieSlice() { - // detaches 'slices', clears Plug base + for (unsigned i = 0; i < slices.size(); ++i) + { + delete slices[i]; + } } // -// @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. +// @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 (rotateWithTorso) // param_1+0xc != 0 + if (sliceCount <= 0) return 0; + + if (rotateWithTorso) // binary this[3] != 0 { - theta += *(Scalar *)((char *)orientationSource + 0x1d8); // torso heading - if (TwoPi <= theta) theta -= TwoPi; - if (theta < 0.0f) theta += TwoPi; // _DAT_0049e730 + 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)) + 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; + 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, - GenericObjectStream *stream + Mech *owner, + MemoryStream *stream ): - Plug() // FUN_004178cc(this, 2) + Plug(), + owner(owner), + layerCount(0) { - this->owner = owner; // param_1[3] - layers.Init(); // FUN_0049f3d5(this+4, 0, 1) - stream->Read(&layerCount, 4); // param_1[10] - + stream->ReadBytes(&layerCount, 4); // this[10] + layers.reserve(layerCount > 0 ? layerCount : 0); 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 + layers.push_back(new PieSlice(owner, stream)); } } -DamageLookupTable::~DamageLookupTable() // @0x49eadc +DamageLookupTable::~DamageLookupTable() // @0x49eadc { - // detaches 'layers', clears Plug base -} - -Logical - DamageLookupTable::TestInstance() const // @0x49eb48 / @0x49e880 -{ - return True; + for (unsigned i = 0; i < layers.size(); ++i) + { + delete layers[i]; + } } // -// @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. +// @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). // -void +int DamageLookupTable::ResolveHit(const Point3D &impact) const { - Point3D local = owner->WorldToLocal(impact); // FUN_00408bf8 / owner+0xd0 + if (layerCount <= 0) return -1; - 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 - } + Point3D local = owner->WorldToLocal(impact); // FUN_00408bf8 + Scalar heightRef = owner->CylinderReferenceHeight(); // owner+0x2ec[+0xc] + if (heightRef <= 0.0f) return -1; - int layerIndex = (int)floorf((Scalar)layerCount * (depth / armourRef)); // FUN_004dcd10/94 + // --- 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 (layerCount - 1 < layerIndex) layerIndex = layerCount - 1; + if (layerIndex > layerCount - 1) layerIndex = layerCount - 1; + PieSlice *layer = layers[layerIndex]; - PieSlice *layer = (PieSlice *)layers.Lookup(&layerIndex); // slot+0xc - - // incident angle from the in-plane (x,z) hit direction, else random + // --- in-plane hit direction -> incident angle (else random scatter) --- Scalar theta; - if (fabsf(local.z) > SortEpsilon && fabsf(local.x) > SortEpsilon) // _DAT_0049ed04 + if (fabsf(local.z) > DirEpsilon && fabsf(local.x) > DirEpsilon) { - theta = atan2f(local.z, local.x); // FUN_004dc8ec + theta = atan2f(local.z, local.x); // FUN_004dc8ec if (theta < 0.0f) theta += TwoPi; } else { - theta = RandomUnit() * TwoPi; // FUN_00408050 * _DAT_0049ed08 + theta = RandomUnit() * TwoPi; // degenerate -> uniform angle } - 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; + DamageZonePercentTable *leaf = layer->SelectSlice(theta); // FUN_0049e678 + return leaf ? leaf->SelectZone() : -1; // FUN_0049de14 } diff --git a/game/reconstructed/dmgtable.hpp b/game/reconstructed/dmgtable.hpp index d24807b..6922bf6 100644 --- a/game/reconstructed/dmgtable.hpp +++ b/game/reconstructed/dmgtable.hpp @@ -6,257 +6,180 @@ // Date Who Modification // // -------- --- ---------------------------------------------------------- // // --/--/95 ?? Initial coding. // +// 07/08/26 RE Completed for RUNTIME: real vector storage (was no-op // +// ReconTable shims), stream name-string read, PieSlice flag // +// fix, ResolveHit->zone chain, named accessors. Byte-format // +// verified against BTL4.RES type-29 (see combat-damage.md). // //---------------------------------------------------------------------------// // Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved // // PROPRIETARY AND CONFIDENTIAL // //===========================================================================// // -// RECONSTRUCTED from the shipped binary. No header survived for this module -// (dmgtable.hpp/.cpp). The class cluster lives at @0x49de14..@0x49f5fb, was -// found ADJACENT to (but separate from) the DamageZone code in mechdmg.cpp, -// and is streamed from the ".tbl" notation file by the mech's -// CreateDamageZoneStream (@0x4a474c, mech.cpp) -- caller sites @0x49ed28 is -// invoked from the per-zone parser @line~11580 and the streamed form @0x49ea48 -// from the object-stream reader @line~10423. +// RECONSTRUCTED from the shipped binary. Behaviour follows the Ghidra +// pseudo-C for the cluster @0x49de14..@0x49f5fb. The three-level resolver is: // -// Identifying strings (data @0x50bc00..0x50bd00): -// "DamageZone" "video" "skeleton" "%s %f" "RotateWithTorso" "PieSlice" -// "Layer" "TableEntryOf" "DamageZone Table Format!" "Not Found!" -// "percentages do not sum to 100%(i.e., 1.0)!" "missing RotateWithTorso!" -// "error in damage table format" " has bad format!" -// vtables: 0x50bd84 (DamageLookupTable), 0x50bd90 (PieSlice), -// 0x50bd9c (DamageZonePercentTable / leaf). -// -// The table is a three-level hit-location resolver: -// -// DamageLookupTable -- stack of armour LAYERS (penetration depth) +// DamageLookupTable -- stack of armour LAYERS by penetration depth (height) // -> PieSlice -- an angular sector wheel (optionally torso-relative) // -> DamageZonePercentTable -// -- a weighted list of {zone segment index, percentage} -// whose percentages must sum to 1.0; a uniform random -// roll then selects the final damage-zone index. +// -- a weighted list of {cumulative %, zone segment index} +// whose percentages rise to 1.0; a uniform random roll +// then selects the final damage-zone index. // -// Class names are reconstructed from the .tbl section keywords; member names -// are inferred from usage and flagged best-effort. See dmgtable.cpp for the -// per-method @ADDR evidence. +// Stream format (type-29 DamageLookupTableStream), byte-verified against the +// shipped BTL4.RES (18 tables, EXACT consumption): +// Table { i32 layerCount; Layer[layerCount] } +// Layer { i32 rotateWithTorso; i32 sliceCount; Slice[sliceCount] } +// Slice { i32 nameLen; char name[nameLen]; u8 0x00; i32 count; Entry[count] } +// Entry { f32 cumulativeThreshold; i32 zoneIndex } +// +// Runtime lookup (FUN_0049eb54 + FUN_0049e678 + FUN_0049de14 + glue FUN_0049ed0c): +// local = owner->WorldToLocal(impact) (owner+0xd0 xform) +// layer = floor(layerCount * local.y / heightRef) (heightRef=owner+0x2ec[+0xc]) +// theta = atan2(local.z, local.x) [+ torso twist] (degenerate -> random*2PI) +// slice = floor(sliceCount * theta / 2PI) +// zone = first entry whose cumulative threshold > RandomUnit() +// +// vtables: 0x50bd84 (DamageLookupTable), 0x50bd90 (PieSlice), +// 0x50bd9c (DamageZonePercentTable / leaf). // #if !defined(DMGTABLE_HPP) # define DMGTABLE_HPP +#include + #if !defined(PLUG_HPP) # include #endif -#if !defined(TABLE_HPP) -# include -#endif -#include "mechrecon.hpp" // reconstruction shim +#include "mechrecon.hpp" // reconstruction shim (Scalar, DebugStream, ...) //##################### Forward Class Declarations ####################### class Mech; class NotationFile; -class GenericObjectStream; +class MemoryStream; class Point3D; //########################################################################### //##################### DamageZonePercentTable ########################## //########################################################################### // -// Leaf node. Holds a sorted, cumulative-weighted list of damage-zone -// segment indices and the uniform random "roll" that turns a [0,1) draw into -// one of those zones. Built from a ".tbl" "DamageZone" page whose lines are -// " " ("%s %f") and whose percentages must total 1.0. -// (vtable @0x50bd9c, ctor @0x49deb0, dtor @0x49df80, object size 0x2c.) +// Leaf node. Holds a sorted, cumulative-weighted list of damage-zone segment +// indices; a uniform random roll turns a [0,1) draw into one of those zones. +// (vtable @0x50bd9c, ctor @0x49deb0, dtor @0x49df80.) // class DamageZonePercentTable: public Plug { public: - // - // Stream constructor -- rebuilds the entry list from an object stream. - // @0x49deb0 (FUN_004178cc base Plug ctor, then ReadEntries @0x49e5e4) - // - DamageZonePercentTable() {} // reconstruction: stack-temp default ctor - DamageZonePercentTable( + struct Entry // binary: zoneIndex@+0xc, cumulative@+0x14 + { + Scalar cumulative; // ascending sort key (rises to 1.0) + int zoneIndex; // damageZones[] / skeleton segment index + }; + + DamageZonePercentTable() {} // stack-temp / offline builder + DamageZonePercentTable( // @0x49deb0 (ReadEntries @0x49e5e4) Mech *owner, - GenericObjectStream *stream + MemoryStream *stream ); ~DamageZonePercentTable(); // @0x49df80 - Logical - TestInstance() const; // @0x49e000 (returns True) + Logical TestInstance() const { return True; } // @0x49e000 // - // @0x49de14 -- uniform random weighted roll. Walks the entries in - // ascending cumulative-percentage order, draws a [0,1) sample - // (FUN_00408050) and returns the segment index (entry+0xc) of the first - // entry whose cumulative threshold (entry+0x14) exceeds the draw. - // Returns -1 if the list is empty. + // @0x49de14 -- uniform weighted roll: draw a [0,1) sample (RandomUnit, + // FUN_00408050) and return the zone index of the first entry whose + // cumulative threshold exceeds the draw; -1 if the list is empty. // - int - SelectZone() const; + int SelectZone() const; // - // @0x49e00c -- parse a "DamageZone" page from the model .tbl: read the - // "video"/"skeleton" model, then each " " line; resolve the - // zone name to a skeleton segment index (Get_Segment_Index @0x4274f8), - // accumulate the percentages and verify the total == 1.0. - // Returns False (and logs) on a bad line, an unknown zone, or a total - // that does not sum to 1.0. + // @0x49e5e4 -- read the leading cell NAME string (i32 len + len+1 bytes + // incl. NUL, per FUN_00402948), then the entry count and that many + // {cumulative, zoneIndex} pairs. // - static int - BuildFromNotation( - NotationFile *model_file, - const char *model_name, - const char *page_name, - NotationFile *table_file, - Mech *owner, - GenericObjectStream *stream - ); + void ReadEntries(MemoryStream *stream); - // - // @0x49e524 WriteEntries (serialise count + {threshold,zone} pairs), - // @0x49e5e4 ReadEntries (deserialise into the entry table). - // - void WriteEntries(GenericObjectStream *stream) const; // @0x49e524 - void ReadEntries (GenericObjectStream *stream); // @0x49e5e4 + // (offline @0x49e00c BuildFromNotation / @0x49e524 WriteEntries -- the + // content-build authoring path -- is not part of the runtime port; the + // shipped .RES is pre-built. Omitted here.) protected: - Mech *owner; // @0x0c param_1[3] - void *refCount; // @0x10 param_1[4] (MemoryBlock/RefCount) - ReconTable entries; // @0x14 param_1+5 sorted by cumulative % - // each entry: zoneIndex@+0xc, cumulativePercent@+0x14 + Mech *owner; // @0x0c + std::vector entries; // @0x14 (was ReconTable) -- sorted ascending }; //########################################################################### //############################# PieSlice ################################ //########################################################################### // -// An angular sector "wheel". N equal-angle slices (each 2*PI/N radians) wrap -// a circle; the slice that contains the incoming hit direction is chosen and -// its leaf DamageZonePercentTable is rolled. When "RotateWithTorso" is set -// the incoming angle is measured relative to the live torso orientation -// (orientationSource+0x1d8), so the slice layout turns with the mech's torso. -// (vtable @0x50bd90, ctor @0x49e740, object size 0x30.) +// An angular sector "wheel". N equal-angle slices (each 2*PI/N rad); the slice +// containing the incoming hit direction is chosen and its leaf table rolled. +// When rotateWithTorso is set the incoming angle is measured relative to the +// live torso twist (owner->TorsoHeading, == torso+0x1d8). +// (vtable @0x50bd90, ctor @0x49e740.) // class PieSlice: public Plug { public: - // - // @0x49e740 -- stream constructor. Reads owner@+0xc and sliceCount@+0x2c, - // builds 'sliceCount' DamageZonePercentTable children (@0x49deb0) each - // keyed to a cumulative angle i*(2*PI/sliceCount), and caches the torso - // orientation source = owner+0x438. - // - PieSlice( + PieSlice() : owner(0), rotateWithTorso(0), sliceCount(0) {} + PieSlice( // @0x49e740 Mech *owner, - GenericObjectStream *stream + MemoryStream *stream ); ~PieSlice(); // - // @0x49e678 -- pick a slice for incoming angle 'theta' (radians). When - // rotateWithTorso is set, theta is shifted by the torso orientation - // (orientationSource+0x1d8) and wrapped into [0, 2*PI). The slice index - // = floor(theta * sliceCount / (2*PI)) feeds the child table lookup. + // @0x49e678 -- pick the slice for incoming angle 'theta' (radians). When + // rotateWithTorso, theta is shifted by the live torso twist and wrapped + // into [0, 2*PI); slice index = floor(theta * sliceCount / 2*PI). // - DamageZonePercentTable * - SelectSlice(Scalar theta) const; - - // - // @0x49e88c -- parse a "PieSlice" page: read the mandatory bool - // "RotateWithTorso" (logged "missing RotateWithTorso!" if absent), then - // each named sub-page as a DamageZonePercentTable (@0x49e00c). - // Logs "error in damage table format" on a malformed entry. - // - static int - BuildFromNotation( - NotationFile *model_file, - const char *model_name, - const char *page_name, - NotationFile *table_file, - Mech *owner, - GenericObjectStream *stream - ); + DamageZonePercentTable *SelectSlice(Scalar theta) const; protected: - Mech *owner; // @0x0c param_1[3] - void *orientationSource; // @0x10 param_1[4] = owner+0x438 (torso) - ReconTable - slices; // @0x14 param_1+5 - int rotateWithTorso; // @0x28 (read first in BuildFromNotation) - int sliceCount; // @0x2c param_1[0xb] + Mech *owner; // @0x0c + std::vector + slices; // @0x14 (was ReconTable) + int rotateWithTorso; // @0x0c-flag in binary (this[3]) + int sliceCount; // @0x2c (this[0xb]) }; //########################################################################### //######################### DamageLookupTable ########################### //########################################################################### // -// Top-level table streamed from the ".tbl" key for a mech. A stack of armour -// LAYERS: the deeper a shot penetrates, the higher the layer index selected. -// Each layer is a PieSlice wheel. (vtable @0x50bd84, ctor @0x49ea48, -// dtor @0x49eadc, object size 0x30.) +// Top-level table streamed from the mech's ".tbl". A stack of armour LAYERS: +// the higher the local-frame impact, the higher the layer index. Each layer +// is a PieSlice wheel. (vtable @0x50bd84, ctor @0x49ea48, dtor @0x49eadc.) // class DamageLookupTable: public Plug { public: - // - // @0x49ea48 -- stream constructor. Reads layerCount@+0x28 and builds - // that many PieSlice layers (@0x49e740). - // - DamageLookupTable( + DamageLookupTable( // @0x49ea48 Mech *owner, - GenericObjectStream *stream + MemoryStream *stream ); - ~DamageLookupTable(); // @0x49eadc + ~DamageLookupTable(); // @0x49eadc - Logical - TestInstance() const; // @0x49eb48 / @0x49e880 (returns True) + Logical TestInstance() const { return True; } // @0x49eb48 // - // @0x49eb54 -- resolve a hit to a damage zone. Transforms the impact - // point into the mech frame (owner+0xd0), derives a penetration-depth - // fraction (clamped against the armour reference at owner+0x2ec[+0xc]), - // maps it to a layer index, fetches that layer's PieSlice and forwards - // an incident angle (computed from the hit direction, or a random draw - // when the direction is degenerate) to PieSlice::SelectSlice (@0x49e678). + // @0x49eb54 (+ @0x49e678 slice + @0x49de14 roll, chained by glue @0x49ed0c) + // -- resolve a world impact point to a damage-zone index. Returns -1 when + // the table is empty or the roll falls through (treated as a miss). // - void - ResolveHit(const Point3D &impact) const; + int ResolveHit(const Point3D &impact) const; - // - // @0x49ed28 -- parse the layered table. Reads consecutive "Layer1", - // "Layer2", ... pages (number suffix via FUN_004dd3ec), parsing each as - // a PieSlice page (@0x49e88c). Logs " has bad format!" when no - // layers are found. - // - static int - BuildFromNotation( - NotationFile *model_file, - const char *model_name, - NotationFile *table_file, - Mech *owner, - GenericObjectStream *stream - ); + int LayerCount() const { return layerCount; } // bring-up verify protected: - Mech *owner; // @0x0c param_1[3] - ReconTable - layers; // @0x10 param_1+4 - int layerCount; // @0x28 param_1[10] + Mech *owner; // @0x0c + std::vector + layers; // @0x10 (was ReconTable) + int layerCount; // @0x28 (this[10]) }; -//########################################################################### -// Tool support -- entry comparator -// -// @0x49f301 -- orders table entries by their cumulative threshold (+0x14) -// within an epsilon (_DAT_0049f369 ~= 1.0e-4). Used to keep the entry tables -// sorted so the SelectZone / SelectSlice scans are monotone. -// - int - CompareTableEntryThreshold(const void *a, const void *b); - #endif diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 439e713..1ab58af 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -72,6 +72,13 @@ // AUTHENTIC GROUND MODEL ctor half (task #15): complete BoxedSolid type for the // collisionTemplate/collisionVolume extent reads + the template bottom lift. #include +// STEP 6 cylinder hit-location table. dmgtable.hpp pulls in no subsystem +// headers (only Plug + mechrecon + ), so it is safe here -- unlike the +// real subsystem headers, whose classes collide with mech.cpp's local stubs. +// The torso twist is reached via a BRIDGE (BTGetTorsoTwist, defined in torso.cpp) +// for the same reason. +#include "dmgtable.hpp" +extern Scalar BTGetTorsoTwist(Subsystem *torso); // torso.cpp (Torso complete there) #if !defined(APP_HPP) # include #endif @@ -394,14 +401,107 @@ Derivation "Mech" ); -// Chain Mech's handler set to the parent's (Entity's, which Mover/JointedMover use -// directly -- they add none). An EMPTY set with no inheritance made Receiver::Receive -// find NO handler for TakeDamageMessageID (and every other inherited message), so the -// engine base Entity::TakeDamageMessageHandler -- which routes damageZones[zone]-> -// TakeDamage -- never ran and damage silently dropped. (Mech's OWN TakeDamage override -// + cylinder lookup is the later STEP-6 reconstruction; this restores the base routing.) +// Mech's OWN handler table, chained to the parent's (Entity's, which Mover/ +// JointedMover use directly -- they add none). It OVERRIDES Entity's TakeDamage +// with the cylinder hit-location resolver (STEP 6): Build() overlays the derived +// entry onto the inherited table by message ID (RECEIVER.cpp), so every other +// inherited message still routes to its base handler. (Previously this was a +// bare copy of Entity's set -- base TakeDamage dropped unaimed/-1 zone hits.) +const Receiver::HandlerEntry + Mech::MessageHandlerEntries[] = + { + MESSAGE_ENTRY(Mech, TakeDamage), + }; + Receiver::MessageHandlerSet - Mech::MessageHandlers(Entity::GetMessageHandlers()); + Mech::MessageHandlers( + ELEMENTS(Mech::MessageHandlerEntries), + Mech::MessageHandlerEntries, + Entity::GetMessageHandlers()); + +//############################################################################# +// STEP 6 -- cylinder hit-location support (consumed by dmgtable.cpp) +//############################################################################# +// +// World -> mech-local point transform (binary FUN_00408bf8 reading owner+0xd0). +// Uses the inherited Entity::localToWorld (the render/collision object matrix); +// MultiplyByInverse gives the world->local change of coordinates. +// +Point3D + Mech::WorldToLocal(const Point3D &world) +{ + Point3D local; + local.MultiplyByInverse(world, localToWorld); + return local; +} + +// +// The roster torso (sinkSourceSubsystem @0x438, ClassID 0xBC5) -- parity with +// the binary's *(mech+0x438). +// +void * + Mech::TorsoOrientationSource() +{ + return GetTorsoSubsystem(); +} + +// +// Live torso twist (yaw) in radians -- binary torso+0x1d8. Reached via the +// torso.cpp bridge (Torso is a complete type there; including torso.hpp here +// would collide with mech.cpp's local subsystem stubs). 0 for no/fixed torso. +// +Scalar + Mech::TorsoHeading() +{ + return BTGetTorsoTwist(GetTorsoSubsystem()); +} + +// +// Height reference the cylinder table normalises impact height against -- the +// binary's *(mech+0x2ec)+0xc, a stance height written from mech+0x518 (standing) +// / +0x51c (ducked). standingTemplateMaxY is that value (collisionTemplate->maxY +// captured at ctor); fall back to the live template if the ground model was off +// when the ctor ran (standingTemplateMaxY left 0). +// +Scalar + Mech::CylinderReferenceHeight() +{ + if (standingTemplateMaxY > 0.0f) + { + return standingTemplateMaxY; + } + BoxedSolid *tmpl = GetCollisionTemplate(); + return tmpl ? tmpl->maxY : 0.0f; // ResolveHit guards <= 0 +} + +// +// Mech override of Entity::TakeDamageMessageHandler (binary @0x4a037a, the two +// call sites into the glue @0x49ed0c). An unaimed hit arrives with +// invalidDamageZone set (damageZone < 0); resolve its zone from the cylinder +// hit-location table (mech[0x111]) using the impact point, clear the flag, then +// hand off to the base handler which routes damageZones[zone]->TakeDamage. Aimed +// (reticle) hits carry a valid zone and pass straight through. +// +void + Mech::TakeDamageMessageHandler(TakeDamageMessage *message) +{ + Check(message); + DamageLookupTable *table = (DamageLookupTable *)Wword(0x111); + if (message->invalidDamageZone && table != 0) + { + int zone = table->ResolveHit(message->damageData.impactPoint); + message->damageZone = zone; + message->invalidDamageZone = False; + if (BTEnvOn("BT_CYL_LOG", 0)) + { + DEBUG_STREAM << "[cyl] unaimed hit -> zone " << zone + << " (impact " << message->damageData.impactPoint.x << "," + << message->damageData.impactPoint.y << "," + << message->damageData.impactPoint.z << ")\n" << std::flush; + } + } + Entity::TakeDamageMessageHandler(message); // base: damageZones[zone]->TakeDamage +} // // Mech attribute table. DENSE PREFIX 0x15..0x21 (JointedMover::NextAttributeID @@ -1208,17 +1308,42 @@ Mech::Mech( } // - // Cylinder damage-zone table (resource type 0x1d, by name) -- the per-impact zone - // resolver consumed by Mech::TakeDamageMessageHandler for unaimed/-1 hits. STILL A - // STUB here (StandingAnimation placeholder); the real CylinderDamageZoneTable is - // STEP 6a of the damage reconstruction. Left as-was so the rest builds; the empty - // name makes the lookup a no-op until then. + // Cylinder hit-location table (resource type 0x1d = DamageLookupTableStream) -- + // the per-impact zone resolver consumed by Mech::TakeDamageMessageHandler for + // unaimed/-1 hits (STEP 6). Faithful to the ctor @part_012.c:10411-10425: the + // table is found by the mech's DamageZoneStream (type-0x14) NAME -- the offline + // builder writes BOTH the 0x14 and 0x1d resources under the model name (see + // CreateDamageZoneStream ~line 1930), so they share it -- then streamed and + // cached at mech[0x111] (byte 0x444). (Was an empty-name StandingAnimation + // stub -> 0 rows; the real class is dmgtable.cpp.) // - char shadowName[32]; - shadowName[0] = '\0'; - int animSeg = ResourceFindByName(shadowName, 0x1d); // FUN_00406ff8 (TODO STEP 6a: cylinder table) - MemStreamX animMem(animSeg); - Wword(0x111) = (int)new (Memory::Allocate(0x2c)) StandingAnimation(this, &animMem); // FUN_0049ea48 + Wword(0x111) = 0; + ResourceDescription *dzForName = + MechFindResource(creation_message->resourceID, + ResourceDescription::DamageZoneStreamResourceType); // type 0x14 (for its name) + ResourceDescription *cylRes = (dzForName != 0) + ? application->GetResourceFile()->FindResourceDescription( + dzForName->resourceName, + ResourceDescription::DamageLookupTableStreamResourceType) // FUN_00406ff8, type 0x1d + : 0; + if (cylRes != 0) + { + cylRes->Lock(); + { + DynamicMemoryStream cylStream( // FUN_004032dc, offset 0 + cylRes->resourceAddress, cylRes->resourceSize, 0); + DamageLookupTable *table = new DamageLookupTable(this, &cylStream); // FUN_0049ea48 + Wword(0x111) = (int)table; + DEBUG_STREAM << "[cyl] table '" << dzForName->resourceName + << "' layers=" << table->LayerCount() << "\n" << std::flush; + } + cylRes->Unlock(); + } + else + { + DEBUG_STREAM << "[cyl] no DamageLookupTable (type 0x1d) for '" + << (dzForName ? dzForName->resourceName : "?") << "'\n" << std::flush; + } // // Bind the three creation-name strings from the MakeMessage. @@ -1276,9 +1401,9 @@ Mech::~Mech() << "\n" << std::flush; } - if (Wword(0x111) != 0) // standing animation + if (Wword(0x111) != 0) // cylinder hit-location table (STEP 6) { - ((Releasable *)Wword(0x111))->Release(); + delete (DamageLookupTable *)Wword(0x111); // frees layers/slices/entries } // diff --git a/game/reconstructed/mech.hpp b/game/reconstructed/mech.hpp index 1d87409..5a7965e 100644 --- a/game/reconstructed/mech.hpp +++ b/game/reconstructed/mech.hpp @@ -503,6 +503,9 @@ protected: // public: static Receiver::MessageHandlerSet MessageHandlers; // SharedData ctor operand + // Mech's OWN handler table (overrides Entity's TakeDamage with the + // cylinder hit-location resolver); chained to Entity::GetMessageHandlers(). + static const Receiver::HandlerEntry MessageHandlerEntries[]; enum { StateCount = 0x21 }; // @@ -735,6 +738,17 @@ protected: // --- damage-table support (dmgtable) -------------------------------- Point3D WorldToLocal(const Point3D &world); // FUN_00408bf8 (owner+0xd0) void *TorsoOrientationSource(); // *(this+0x438) + // Height reference the cylinder table normalises impact height against -- + // the binary's *(mech+0x2ec)+0xc, a stance height set from mech+0x518 + // (standing) / +0x51c (ducked). We return the standing value + // (collisionTemplate->maxY captured at ctor). + Scalar CylinderReferenceHeight(); + // Live torso twist (yaw) in radians -- torso+0x1d8, via the roster torso + // (sinkSourceSubsystem @0x438). 0 for a fixed-torso mech. + Scalar TorsoHeading(); + // Mech override of Entity::TakeDamageMessageHandler: resolve an unaimed + // (invalidDamageZone) hit's zone via the cylinder table, then base-route. + void TakeDamageMessageHandler(TakeDamageMessage *message); // --- damage-routing support (mechdmg / mech4) ----------------------- // Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*; diff --git a/game/reconstructed/torso.cpp b/game/reconstructed/torso.cpp index d12fb1c..ee1afc2 100644 --- a/game/reconstructed/torso.cpp +++ b/game/reconstructed/torso.cpp @@ -889,3 +889,14 @@ Subsystem *CreateTorsoSubsystem(Mech *owner, int id, void *seg) return (Subsystem *) new (Memory::Allocate(0x280)) Torso(owner, id, (Torso::SubsystemResource *)seg, Torso::DefaultData); } + +// +// STEP 6 bridge -- expose the live torso twist (Torso::currentTwist == the binary +// torso+0x1d8) to mech.cpp / the cylinder damage table without pulling the Torso +// header (which collides with mech.cpp's local subsystem stubs) into mech.cpp. +// The roster torso @mech+0x438 is a Torso (ClassID 0xBC5); 0 when absent. +// +Scalar BTGetTorsoTwist(Subsystem *torso) +{ + return torso ? ((Torso *)torso)->CurrentTwist() : 0.0f; +} diff --git a/game/reconstructed/torso.hpp b/game/reconstructed/torso.hpp index eae3b0f..5f5bb51 100644 --- a/game/reconstructed/torso.hpp +++ b/game/reconstructed/torso.hpp @@ -137,6 +137,9 @@ class Joint; // engine skeleton node (JOINT.h); the twist target PoweredSubsystem::ElectricalState ElectricalStateLevel() const{ return (PoweredSubsystem::ElectricalState)watchdogAlarm.GetLevel(); } LWord GetCreationTime() const { return (LWord)(long)lastPerformance; } LWord GetCurrentTime() const { return (LWord)(long)lastPerformance; } + // Current horizontal (yaw) torso aim in radians (binary torso+0x1d8); the + // damage-table PieSlice wheel reads this when RotateWithTorso is set. + Scalar CurrentTwist() const { return currentTwist; } protected: // Resolve a named skeleton node to its live Joint* via the owning Mech // (the ctor @004b6b0c inlines Mech::ResolveJoint == FUN_00424b60). Defined