Files
BT411/game/reconstructed/mechdmg.cpp
T
Joe DiPrimaandClaude Opus 5 6a96fb6420 #52 the peer STANDING-LOCK: case 0's fallthrough was intercepted
A replicant could not start walking between gait-change records.  The port's
body case 4 (the task-#64 lockstep twin) is an INSERTION sitting between case 0
and the advance group; in the binary case 4 is a MEMBER of that group
(FUN_004a5678 @004a5678: case 2,3,4,5,8,... -- no turn block, no speed exit),
so case 0's fallthrough is meant to land on Advance().  The insertion caught it.

On a replicant that is not a race but an identity: case 0 arms walk iff
standSpeed < bodyTargetSpeed, and the inserted block resets iff standSpeed <
bspd -- where bspd IS bodyTargetSpeed for a replicant.  Same expression, so arm
and reset fire on the same frame, forever, and a peer parked at Standing with a
live replicated demand never cycles.  bodyCycleSpeed stays 0 while position
advances from dead reckoning: the skate.

This is the sequel to e91d447 (#82).  Before it the replicant branch read the
dead local mapper cell (0 forever), the exit never fired, and the fallthrough
worked BY ACCIDENT.  Fixing the dead cell closed the escape hatch.

Fix: case 0 -> goto advance_body_normally, the leg twin's own idiom, restoring
the binary's structure without touching the #64/#82 turn logic.
BT_NO_BODY_FALLTHRU=1 reverts.

Measured (2-node, scratchpad/night13/skatelock.sh):
  legacy  336 consecutive locked seconds, bspd=39.2324 bts=39.2324 every line
  fixed   0 locks, every pass
  master body-Standing samples 52 -> 21 (it locked too, invisibly at mj=0)
  turn-in-place intact: pivoter body state 4 x9 / leg state 4 x8, in lockstep

Diagnostics (both keepers): [skate] now carries bstate= -- the field lines
proved "both channels idle" but never named the state, which was the whole
answer; [bodySM]/[peergait] under BT_BODY_SM_LOG instrument the arm->reset pair
and a moving replicant's body channel.

NOT claimed: that this accounts for the night-13 field episodes.  That link is
inference -- locked + translating IS the skate signature by construction, but no
bench caught the two together.  bstate= settles it next playtest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 00:50:54 -05:00

1613 lines
66 KiB
C++

//==========================================================================//
// File: mechdmg.cpp //
// Project: BattleTech //
// Contents: Mech::DamageZone -- maps incoming damage to mech locations / //
// subsystems (the dz_* zones streamed from the .BGF SV_SPECIAL //
// tokens), critical-hit selection, LOD redirection, and the //
// descend-on-destruction segment walk. //
//---------------------------------------------------------------------------//
// Date Who Modification //
// -------- --- ---------------------------------------------------------- //
// 06/05/95 JM Initial coding. //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. //
// All Rights reserved worldwide //
// This unpublished sourcecode is PROPRIETARY and CONFIDENTIAL //
//===========================================================================//
//
// RECONSTRUCTED from the shipped binary (Ghidra pseudo-C, recovered/all/
// part_012.c). Class shape is the SURVIVING MECHDMG.HPP; bodies follow the
// decompiled object at the cited @ADDRs. The companion Mech-side loop that
// streams every zone, Mech::CreateDamageZoneStream @004a474c, lives in
// mech.cpp; this file is the per-zone class.
//
// Float constants recovered from the code section (section_dump.txt):
// _DAT_0049c99c = 1.0f _DAT_0049c9a0 = 0.5f _DAT_0049c9a4 = 0.0f
// _DAT_0049cacc = 0.0f _DAT_0049cad0 = 1.0f
// _DAT_0049ce48 = 0.5f _DAT_0049ce4c = 1.0f
// _DAT_0049c50c = 0.25f _DAT_0049c510 = 10.0f (LOD reuse-window seconds)
// _DAT_0049c5f8 = 0.0f _DAT_0049c5fc = 1.0f (structure clamp)
// _DAT_0049d1c4 = 1.0f _DAT_0049d1c8 = 1.0e-4f _DAT_0049d1cc = 0.5f (leg halving)
//
// Helper-function name mapping (engine internals referenced by the decomp):
// FUN_0041df5c DamageZone::DamageZone (base ctor, vtable @004e3cf0)
// FUN_0041e394 DamageZone::~DamageZone (base dtor)
// FUN_0041e4e0 DamageZone::TakeDamage (base armor/structure model)
// FUN_0041e590 DamageZone::CreateStreamedDamageZone (base streamer)
// FUN_00424767 DamageZoneIndexTable::TableOf(0,1)
// FUN_0042478a DamageZoneIndexTable::~TableOf
// FUN_00424830 DamageZoneIndexTableIterator::TableIteratorOf(&table)
// FUN_0042484f DamageZoneIndexTableIterator::~TableIteratorOf
// FUN_00424716 IntegerPlug::PlugOf(&int)
// FUN_00424811 ...IteratorOf(&list) (segment child iterators)
// FUN_00417ab4 Slot::GetCurrentPlug (READ; was mislabeled Resolve)
// FUN_00417a80 Slot::AddImplementation (BIND -- Ghidra dropped its args)
// FUN_00417858 SlotOf<>::Release
// FUN_0041a1a4 IsDerivedFrom(classDerivations)
// FUN_0041b9ec AlarmIndicator(levels) FUN_0041bbd8 AlarmIndicator::SetLevel(n)
// FUN_00420ea4 EntityID::operator=(copy) FUN_00420ef4 EntityID::operator=
// FUN_0042104c EntityID::operator==
// FUN_00414b60 TheTime.CurrentTick() (/ DAT_0052140c == TicksPerSecond)
// FUN_00408050 (Scalar)Random -> [0,1) FUN_0040807c(rng,n) Random(n)
// FUN_004ac07c Subsystem::ApplyDamage(damage) -> Scalar
// FUN_004b11bc Generator::ForceShortRecovery (class derivations @0050f4bc)
// FUN_0049fb54 Mech::IsDisabled() (movementMode 2||9; NOT a torso/leg query)
// FUN_0040485c/00404720/00404088/00404190/004040d8 NotationFile readers
// FUN_00402298/004022b0/004022d0 operator new / array new / delete
// FUN_004dbb24/004db92c/004d9c38/004dbd4c DebugStream << / endl
//
#include <bt.hpp>
#include <matchlog.hpp> // MP match forensics (CRIT event line)
#pragma hdrstop
#if !defined(MECHDMG_HPP)
# include <mechdmg.hpp>
#endif
#if !defined(MECH_HPP)
# include <mech.hpp>
#endif
#if !defined(POWERSUB_HPP)
# include <powersub.hpp>
#endif
#include <stdio.h> // sscanf (offline streamer)
// #78/#82 diagnostic scope: the mech whose GIMP cell the [simstomp] trap in
// SIMULATE.h should watch (set at each leg-threshold crossing). Definition
// lives here so the engine header can extern it without a game dependency.
void *g_btGimpWatchMech = 0;
// The trap body (SIMULATE.h calls it): report WHO reset the gimp cell, with a
// module-relative return address for tools/symcrash.py.
void BTGimpStompTrap(void *sim, unsigned cur, unsigned nw, void *ra)
{
static int s_ds = 0;
if (s_ds++ >= 40)
return;
unsigned long base = (unsigned long)GetModuleHandleA(0);
char buf[160];
sprintf(buf, "[simstomp] %u->%u mech=%p ra=btl4+0x%lx",
cur, nw, sim, (unsigned long)ra - base);
DEBUG_STREAM << buf << std::endl << std::flush;
}
#if !defined(RANDOM_HPP)
# include <random.hpp> // the engine's global RandomGenerator Random
#endif
//===========================================================================//
// Reconstruction stand-ins LOCAL to this translation unit.
// The per-zone damage code reaches engine clock / RNG / segment-tree / NULL
// subsystem facets through an invented name set; these behaviour-neutral
// stand-ins (and proxy casts onto the real Subsystem* / NotationFile* /
// ResourceFile*) carry that surface so the recovered logic compiles.
//===========================================================================//
static struct ReconClock { Scalar CurrentTick() { return 0; } } TheTime; // FUN_00414b60
static const Scalar TicksPerSecond = 1.0f; // DAT_0052140c
// (#110: the DZRef/SegmentRecord/SegmentIterator/SegTableX stubs that lived here
// are GONE. They silently no-op'ed the whole destruction cascade walk --
// RecurseSegmentTable "descended" into nothing, so blowing an arm off never
// touched the gun pod hanging from it. The walk now uses the REAL engine
// segment tree; see RecurseSegmentTable below.)
// Subsystem facets the recovered code reaches that the engine Subsystem does
// not expose under these names (cast of Subsystem*).
struct SubProxy2
{
Scalar ApplyDamage(Damage &) { return 0; } // FUN_004ac07c
void OnDestroyed() {} // vtable slot 0x34
int isVital; // +0x39
AlarmIndicator failureAlarm; // +0x2c
int hasDestructor; // +0x41
void *damageZone; // +0xE0
};
// Offline .dmg/.skl notation readers (cast of NotationFile* / ResourceFile*).
struct NotationEntry { const char *name; const char *text; NotationEntry *Next() { return 0; } };
struct NotationList { int Count() { return 0; } NotationEntry *First() { return 0; } };
struct NoteX2 // cast of NotationFile*
{
template<class...A> int ReadLogical(A&&...) { return 0; }
template<class...A> int ReadString(A&&...) { return 0; }
template<class...A> NotationList FindList(A&&...) { return NotationList(); }
void Close() {}
};
struct RFileX2 { template<class A> NotationFile *Open(A) { return 0; } }; // cast of ResourceFile*
inline int Str_Equal(const char *a, const char *b) { return a && b && strcmp(a, b) == 0; }
template<class...A> inline int FindSegment(A&&...) { return -1; } // FUN_004274f8
//
// Sentinel for "no zone". Returned by GetSegmentIndex/RandomRedirect on a miss.
//
const int Mech__DamageZone::NullDamageZone = -1;
// LOD reuse window: within [0.25s, 10.0s] of the previous hit, the artifact
// zone tends to keep routing to the same child (damage clustering).
static const Scalar LODReuseWindowMin = 0.25f; // _DAT_0049c50c
static const Scalar LODReuseWindowMax = 10.0f; // _DAT_0049c510
// Structure level is always clamped to [0,1].
static const Scalar StructureMin = 0.0f; // _DAT_0049c5f8 / _DAT_0049c9a4 / _DAT_0049cacc
static const Scalar StructureMax = 1.0f; // _DAT_0049c5fc / _DAT_0049c99c / _DAT_0049cad0
// A leg whose zone is half-gone already trips the partial leg-failure graphic
// when the mech is not in a normal standing/walking stance (the oracle reads
// _DAT_0049c9a0 == 0.5 in that branch, vs StructureMax (1.0) for the full
// "leg destroyed -> fall" case).
static const Scalar LegHalfStructure = 0.5f; // _DAT_0049c9a0
// CriticalHit passes only half of the inflicted damage on to the armour model.
static const Scalar CriticalDamageFraction = 0.5f; // _DAT_0049ce48
static const Scalar CriticalDamageMax = 1.0f; // _DAT_0049ce4c
// Probability of re-using the previous child zone inside the reuse window.
// (Ghidra read the operand at @0049c514; exact value not cleanly recoverable
// from the pseudo-C -- it behaves as a high "stay on the same spot" bias.)
static const Scalar LODReuseHysteresis = 0.33f; // _DAT_0049c514 -- a DOUBLE in the
// binary (bytes 1f85eb51b81ed53f =
// 0.33); the old 0.82 was a misread
// (task #8 audit)
//###########################################################################
//###########################################################################
// MechCriticalSubsystem
//###########################################################################
//###########################################################################
//
// @0049dd18 -- owner is the DamageZone that will destroy this subsystem when
// the zone's structure runs out. damagePercentage / damagePercentageUsed and
// criticalWeight are filled in afterwards by the streaming ctor of the zone.
//
MechCriticalSubsystem::MechCriticalSubsystem(DamageZone *owner)
: subsystemPlug(owner) // FUN_0049dd7e (SlotOf<Subsystem*>)
{
criticalWeight = 0; // Wword(6) @0x18
damagePercentageUsed = 0; // Wword(5) @0x14
}
//
// @0049dd44 -- releases the subsystem slot (FUN_0049dd9d).
//
MechCriticalSubsystem::~MechCriticalSubsystem()
{
}
//
// @0049dd74
//
Logical
MechCriticalSubsystem::TestInstance() const
{
return True;
}
//###########################################################################
//###########################################################################
// Mech::DamageZone
//###########################################################################
//###########################################################################
//#############################################################################
// Construction -- @0049ce50
//
// Chains to the base DamageZone (which parses armour/structure from the .dmg
// record and allocates the redirect table), then reads this class's own
// stream fields, normalises the per-facing armour scalars, builds the
// critical-subsystem array and finally the LOD redirect table.
//
Mech__DamageZone::Mech__DamageZone(
Mech *mech,
int damage_zone_index,
MemoryStream *stream
)
: DamageZone(mech, damage_zone_index, stream), // FUN_0041df5c
parentArtifactZone(0) // FUN_0049ddc9(,0) -- Socket(NULL) legal
{
// vtable @0050bb90
redirectTable.Construct(0, 1); // Wword(0x58) = FUN_00424767(,0,1)
// last-redirect bookkeeping
lastDamageTime = (Scalar)TheTime.CurrentTick() / TicksPerSecond; // Wword(0x5f)
lastInflicting = mech->entityID; // Wword(0x60) = FUN_00420ea4(,mech+0x184)
criticalWeightSum = 0; // Wword(0x6c)
//
// Read the streamed scalar/flag block. (Stream order set by
// CreateStreamedDamageZone below.)
//
stream->ReadBytes(&descendOnDestruction, 4); // Wword(0x67)
stream->ReadBytes(&destroySiblingsOnDestruction, 4); // Wword(0x68)
stream->ReadBytes(&segmentIndex, 4); // Wword(0x65)
stream->ReadBytes(&leftLeg, 4); // Wword(0x69)
stream->ReadBytes(&rightLeg, 4); // Wword(0x6a)
stream->ReadBytes(&vitalDamageZone, 4); // Wword(0x66)
stream->ReadBytes(&criticalSubsystemCount, 4); // Wword(0x6b)
//
// Normalise the damage-scale vector damageScale[Damage::DamageTypeCount]
// (this[0x51..0x55], == base DamageZone::damageScale[5]). These are NOT
// per-facing: the base TakeDamage indexes them by Damage::damageType
// (it reads this[0x51 + damage.damageType]; see DamageZone::TakeDamage
// @0041e4e0), so the 5 cells are the {Collision,Ballistic,Explosive,Laser,
// Energy} per-type armour scale for this zone. Each cell is rewritten so a
// stored "armour points" figure becomes a damage-per-point fraction
// (1/(points*count)); a leg zone then halves every type's scale.
//
// damageScale[5] and defaultArmorPoints are the INHERITED engine DamageZone base
// members (read raw from the stream by the base ctor, DAMAGE.cpp:301-307); we
// normalise them in place here -- not a raw 0x144/0x140 offset (which would be the
// binary layout, wrong when compiled against the engine base).
Scalar rawScale[Damage::DamageTypeCount];
for (int type = 0; type < Damage::DamageTypeCount; ++type)
rawScale[type] = damageScale[type];
for (int type = 0; type < Damage::DamageTypeCount; ++type) // 5 damage types
{
Scalar even = 1.0f / defaultArmorPoints; // _DAT_0049d1c4 / count
if (fabsf(damageScale[type] - even) > 1.0e-4f) // _DAT_0049d1c8
damageScale[type] = 1.0f / (damageScale[type] * defaultArmorPoints);
if (rightLeg || leftLeg)
damageScale[type] *= 0.5f; // _DAT_0049d1cc
}
// ARMOR ECONOMY DUMP (the ram-kill forensics): the authored per-type
// absorption -- damageLevel += amount * scale[type]; level 1.0 = zone
// destroyed. scale[0] (Collision) x the StaticBounce kinetic number is
// the authored ram economy.
if (getenv("BT_DMG_LOG"))
DEBUG_STREAM << "[zone-armor] zone=" << damageZoneIndex
<< " '" << (const char *)damageZoneName << "'"
<< " armorPts=" << defaultArmorPoints
<< " raw={" << rawScale[0] << "," << rawScale[1] << "," << rawScale[2]
<< "," << rawScale[3] << "," << rawScale[4] << "}"
<< " scale={" << damageScale[0] << "," << damageScale[1] << ","
<< damageScale[2] << "," << damageScale[3] << "," << damageScale[4] << "}"
<< " leg=" << (int)(rightLeg || leftLeg)
<< " vital=" << (int)vitalDamageZone
// #110: the destruction-cascade authoring -- whether this zone's
// death descends the segment tree / takes its siblings, and how many
// critical subsystems it can fail. The arm chain lives or dies by
// these values.
<< " descend=" << (int)descendOnDestruction
<< " destroySibs=" << (int)destroySiblingsOnDestruction
<< " segIdx=" << segmentIndex
<< " crits=" << criticalSubsystemCount << std::endl;
//
// Critical-subsystem table.
//
if (criticalSubsystemCount == 0)
{
criticalSubsystems = 0; // Wword(0x6d)
}
else
{
criticalSubsystems =
(MechCriticalSubsystem **)Memory::AllocateArray(criticalSubsystemCount * 4);
for (int i = 0; i < criticalSubsystemCount; ++i)
{
criticalSubsystems[i] =
new (Memory::Allocate(0x1c)) MechCriticalSubsystem(this); // FUN_0049dd18
}
int subsystemIndex = -1;
for (int i = 0; i < criticalSubsystemCount; ++i)
{
stream->ReadBytes(&criticalSubsystems[i]->criticalWeight, 4); // +0x18
stream->ReadBytes(&criticalSubsystems[i]->damagePercentage, 4); // +0x10
criticalWeightSum += criticalSubsystems[i]->criticalWeight;
stream->ReadBytes(&subsystemIndex, 4); // roster index (mech+0x128 order)
// THE BINDING (task #2, disasm @0049d0e1-0049d10d -- Ghidra DROPPED
// the two arg pushes, which is why this read as a bare Resolve():
// the real call is Slot::AddImplementation(roster[index])). Gate =
// MasterInstance + DynamicFlag on the flags LWord @+0x28
// (simulationFlags). Replicant plugs stay UNBOUND on purpose:
// subsystem criticals are master-authoritative. [T1]
if ((mech->simulationFlags & Entity::InstanceMask) == Entity::MasterInstance
&& (mech->simulationFlags & Entity::DynamicFlag) != 0)
{
Subsystem *target =
(subsystemIndex >= 0 && subsystemIndex < mech->GetSubsystemCount())
? mech->GetSubsystem(subsystemIndex) : 0;
if (target != 0)
{
criticalSubsystems[i]->subsystemPlug.Add(target); // FUN_00417a80
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[crit] zone " << damage_zone_index
<< " entry " << i << " -> roster[" << subsystemIndex
<< "] = " << (void *)target << "\n" << std::flush;
}
else if (getenv("BT_DEATH_LOG"))
{
// roster slot NULL: idx 0 (mapper, installed post-ctor) or
// idx 1 (voltage bus, ReconSlot stub). The engine
// Link::AddToPlug derefs the plug, so a null bind crashes.
DEBUG_STREAM << "[crit] zone " << damage_zone_index
<< " entry " << i << " roster[" << subsystemIndex
<< "] is NULL -- left unbound\n" << std::flush;
}
}
}
}
//
// LOD redirect table: <count> followed by <count> child zone indices.
//
int redirectCount = 0;
stream->ReadBytes(&redirectCount, 4);
for (int i = 0; i < redirectCount; ++i)
{
int childZone = 0;
stream->ReadBytes(&childZone, 4);
redirectTable.Add(new (Memory::Allocate(0x10)) IntegerPlug(&childZone)); // FUN_00424716
}
lastHitZone = (redirectCount == 0) ? 0 : RandomRedirect(); // Wword(0x5e)
}
//#############################################################################
// SetLODParentPointers -- @0049d1d0
//
// Run after every zone of the mech exists. For each child index in this
// (artifact) zone's redirect table, point that real child zone's
// parentArtifactZone slot back at us.
//
void
Mech__DamageZone::SetLODParentPointers()
{
Mech *mech = (Mech *)GetOwningSimulation();
DamageZoneIndexTableIterator it(&redirectTable); // FUN_00424830
for (IntegerPlug *plug; (plug = it.Next()) != 0; ) // slot 0x28
{
Mech__DamageZone *child = mech->Zone(plug->value); // inherited Entity::damageZones[idx], typed
child->parentArtifactZone.Add(this); // slot @0x188: FUN_00417a80 (NOT flag-gated)
}
}
//#############################################################################
// Destruction -- @0049d23c (vtable slot 0)
//
Mech__DamageZone::~Mech__DamageZone()
{
*(void **)this = &vtable_0050bb90;
if (criticalSubsystems != 0)
{
for (int i = 0; i < criticalSubsystemCount; ++i)
delete criticalSubsystems[i];
Memory::Free(criticalSubsystems);
}
{
DamageZoneIndexTableIterator it(&redirectTable); // FUN_00424830
it.DeleteContents(); // FUN_00417858(,1)
}
// (binary tail: FUN_0049dde8 parentArtifactZone.~DZSlot, FUN_0042478a
// redirectTable.~DamageZoneIndexTable, then base ~DamageZone FUN_0041e394 --
// all COMPILER-EMITTED dtor epilogue glue; C++ re-emits member + base
// destruction implicitly at the closing brace, so writing them as source runs
// them twice. See the reconstruction rule in mech.cpp ~Mech.)
}
//#############################################################################
// TakeDamage -- @0049c690 (vtable slot 6)
//
// If this zone is an artifact (has a redirect table) the hit is handed to the
// LOD router so it lands on a real child zone. Otherwise the base DamageZone
// applies it to armour/structure, the parent artifact (if any) recomputes its
// displayed level, special damage may short an attached generator, and the
// zone updates its graphic / leg-failure state.
//
// The base armour model (DamageZone::TakeDamage @0041e4e0, == the WinTesla
// engine DAMAGE.cpp body we link against) is:
//
// damageLevel += damage.damageAmount * damageScale[damage.damageType];
// Clamp(damageLevel, 0.0f, 1.0f); // _DAT_0041e57c / _DAT_0041e580
// SetDamageLevelChangedFlag(); // flags |= 4
// if (damageLevel >= 1.0f) { // zone destroyed
// SetDamageZoneState(BurningState); // state indicator -> 1
// SetGraphicState(DestroyedGraphicState);// vtable slot 3 -> 1
// SetGraphicStateChangedFlag(); // flags |= 8
// }
//
// i.e. there is no separate "armour then structure" pool -- a single 0..1
// damageLevel accumulates, scaled per damage TYPE by damageScale[5] (the
// vector normalised in the ctor above); 1.0 == destroyed. (damageLevel in
// this reconstruction names that same base damageLevel cell @0x158.)
//
void
Mech__DamageZone::TakeDamage(Damage& damage)
{
Mech *mech = (Mech *)GetOwningSimulation();
DamageZoneIndexTableIterator it(&redirectTable); // FUN_00424830
if (it.Count() != 0) // slot 0x14 -> artifact zone
{
EntityID inflicting = mech->lastInflictingID; // mech[0x43c]
Damage local = damage; // copy 10 words
LODDamageRouter(inflicting, local); // FUN_0049c40c
return;
}
// ---- real zone: apply to armour/structure ----
Scalar preLvl = damageLevel;
DamageZone::TakeDamage(damage); // FUN_0041e4e0
if (getenv("BT_DMG_LOG"))
DEBUG_STREAM << "[dmghit] mech=" << mech->GetEntityID()
<< " zone=" << damageZoneIndex << " vital=" << (int)vitalDamageZone
<< " type=" << (int)damage.damageType << " amt=" << damage.damageAmount
<< " burst=" << damage.burstCount
<< " lvl " << preLvl << "->" << damageLevel << "\n" << std::flush;
Mech__DamageZone *parent = parentArtifactZone.GetCurrent(); // FUN_00417ab4(this+0x62)
if (parent != 0)
parent->UpdateLODDamageLevel(); // FUN_0049c51c
// ---- ShortAttachedGenerators (inlined): special damage type 4 ----
if (damage.damageType == 4 && criticalSubsystemCount > 0)
{
int n = Random(criticalSubsystemCount); // FUN_0040807c
Subsystem *s = criticalSubsystems[n]->subsystemPlug.GetCurrent();
if (s != 0 // unbound-plug guard (idx-0/1 roster residue)
&& s->IsDerivedFrom(*Generator::GetClassDerivations())) // FUN_0041a1a4(,0x50f4bc)
((Generator *)s)->ForceShortRecovery(); // FUN_004b11bc
}
// ---- graphic / failure state from current structure level ----
if (vitalDamageZone == 0 || damageLevel < StructureMax)
{
if (leftLeg == 0 && rightLeg == 0)
{
if (damageLevel >= StructureMax)
RecurseSegmentTable(mech); // FUN_0049cad4
}
else if ((unsigned)(mech->MovementMode() - 3) < 2) // live sim state 3||4 (mech+0x40)
{
// task #60: the gate reads the LIVE MovementMode (mech+0x40),
// @part_012.c:6910 `*(int*)(iVar1+0x40) - 3U < 2` -- NOT the phantom
// `stance` member (no binary offset, zero writers -> perma-0, so
// this whole leg-shot-out -> fall/death branch was DEAD).
if (rightLeg != 0 && damageLevel >= StructureMax)
mech->graphicAlarm.SetLevel(9); // fall / leg gone
else if (leftLeg != 0 && damageLevel >= StructureMax)
mech->graphicAlarm.SetLevel(9);
}
else if (mech->IsDisabled() == 0) // FUN_0049fb54 (NOT disabled)
{
if (getenv("BT_DMG_LOG") && (leftLeg || rightLeg))
DEBUG_STREAM << "[gimp-eval] zone=" << damageZoneIndex
<< " L=" << (int)leftLeg << " R=" << (int)rightLeg
<< " lvl=" << damageLevel
<< " half=" << (int)(damageLevel >= LegHalfStructure)
<< " alarmBefore=" << (int)mech->graphicAlarm.GetLevel()
<< "\n" << std::flush;
// A live (non-disabled) mech: a half-destroyed leg zone trips the
// partial-failure graphic (right -> 4, left -> 3). The oracle
// compares against _DAT_0049c9a0 (0.5), NOT StructureMax. task #60:
// the guard is IsDisabled (@0049fb54 = movementMode 2||9), NOT the
// always-0 IsAirborne stub -- on a WRECK the binary SUPPRESSES this
// write; the stub let it corrupt graphicAlarm 9->4/3 (the task-#52
// wreck-graphic bug, now fixed at source not just masked).
if (rightLeg != 0 && damageLevel >= LegHalfStructure)
mech->graphicAlarm.SetLevel(4);
else if (leftLeg != 0 && damageLevel >= LegHalfStructure)
mech->graphicAlarm.SetLevel(3);
//
// #78 the "REVERSE DISABLED" voice: the binary's mech+0x40 is ONE
// cell -- the same 3/4 write was ALSO Entity.SimulationState, and
// the authored audio has state watchers on SimulationState==3/4
// that start the warning sequence (notes 29,16,40 -> two klaxon
// hits + the Warnings01 zone-8 "reverse disabled" voice line).
// The port split the cell (graphicAlarm vs engine simulationState,
// gotcha #23), so the voice never triggered. Mirror the gimp
// level into the engine indicator -- guarded so a fall/disabled/
// dead state (2, 5-8, 9) is never stomped; SetState dedupes, so
// the voice fires once per onset (again only on a side switch),
// which is the binary's own edge semantics.
//
if (damageLevel >= LegHalfStructure && (rightLeg != 0 || leftLeg != 0))
{
g_btGimpWatchMech = (void *)mech; // scope the #78 simstomp trap
unsigned ss = mech->GetSimulationState();
if (getenv("BT_AUDIO_SPATIAL")) { static int s_gm=0; if (s_gm++<30)
DEBUG_STREAM << "[gimp-sim] ss=" << ss << " -> "
<< ((rightLeg != 0) ? 4 : 3) << "\n" << std::flush; }
if (ss <= 1 || ss == 3 || ss == 4)
mech->SetSimulationState((rightLeg != 0) ? 4 : 3);
}
}
}
else
{
// vital zone destroyed -> mech death
SetDamageZoneState(1); // this+4
mech->graphicAlarm.SetLevel(9);
}
damageLevel = Clamp(damageLevel, StructureMin, StructureMax);
}
//#############################################################################
// SetGraphicState -- @0049c66c (vtable slot 3)
//
// Drives the secondary "graphic state" alarm (this+0x64) and flags the zone
// as needing a visual refresh (Wword(0xb8) |= 8).
//
// Mech__DamageZone::SetGraphicState removed -- byte-identical to the engine base
// DamageZone::SetGraphicState (damageZoneGraphicState.SetState(new_state) +
// SetGraphicStateChangedFlag(), the latter == the old `flags |= 8`). Calls below
// resolve to the inherited base override.
//#############################################################################
// LODDamageRouter -- @0049c40c
//
// An artifact zone decides which real child takes the hit. If the same
// attacker hit recently (within [0.25s, 10.0s]) and a random roll succeeds
// the previous child is reused; otherwise a fresh RandomRedirect() is chosen.
// A brand-new attacker always re-rolls.
//
void
Mech__DamageZone::LODDamageRouter(EntityID inflicting, Damage damage)
{
int zone;
if (inflicting != lastInflicting) // FUN_0042104c
{
zone = RandomRedirect(); // new attacker
}
else
{
Scalar dt = (Scalar)TheTime.CurrentTick() / TicksPerSecond - lastDamageTime;
if (dt < LODReuseWindowMin)
zone = lastHitZone; // too soon: same spot
else if (dt > LODReuseWindowMax)
zone = RandomRedirect(); // stale: re-roll
else if ((Scalar)Random <= LODReuseHysteresis) // _DAT_0049c514
zone = lastHitZone;
else
zone = RandomRedirect();
}
lastHitZone = zone; // Wword(0x5e)
lastDamageTime = (Scalar)TheTime.CurrentTick() / TicksPerSecond;
lastInflicting = inflicting; // FUN_00420ef4
Mech *mech = (Mech *)GetOwningSimulation();
mech->Zone(zone)->TakeDamage(damage); // slot 6 on the real child
UpdateLODDamageLevel(); // FUN_0049c51c
}
//#############################################################################
// RandomRedirect -- @0049c600
//
// Uniformly pick one of the redirect table's child zone indices.
//
int
Mech__DamageZone::RandomRedirect()
{
DamageZoneIndexTableIterator it(&redirectTable); // FUN_00424830
int n = Random(it.Count()); // FUN_0040807c(rng, slot 0x14)
IntegerPlug *plug = it[n]; // slot 0x34
return plug->value; // +0xc
}
//#############################################################################
// UpdateLODDamageLevel -- @0049c51c
//
// An artifact zone's displayed structure is the mean of its real children's
// structure levels, clamped to [0,1].
//
void
Mech__DamageZone::UpdateLODDamageLevel()
{
Mech *mech = (Mech *)GetOwningSimulation();
unsigned count = 0;
Scalar sum = 0.0f;
DamageZoneIndexTableIterator it(&redirectTable); // FUN_00424830
for (IntegerPlug *plug; (plug = it.Next()) != 0; ) // slot 0x28
{
sum += mech->Zone(plug->value)->damageLevel; // +0x158
++count;
}
damageLevel = (count != 0) ? sum / (Scalar)count : StructureMin;
damageLevel = Clamp(damageLevel, StructureMin, StructureMax);
}
//
// #80 bridge: MechSubsystem::TakeDamage's vital-subsystem kill (@0x4ac126-
// 0x4ac137: owner alarm+0x2C -> level 9). MechSubsystem's TU holds only an
// incomplete Mech; this TU already writes graphicAlarm level 9 for the
// leg-destruction path, so the vital path reuses the same complete-type site.
//
//
// #78 bridge: the gimp level, read in THIS TU. The mapper's direct
// `mech->graphicAlarm.GetLevel()` read 0 while this TU's read of the same
// object returned 4 -- AlarmIndicator is typedef'd to DIFFERENT TYPES per
// header family (mech.hpp: ReconAlarm, 4 bytes; heat.hpp: GaugeAlarm, 0x54),
// so Mech's layout past graphicAlarm diverges between TU families. Until
// that typedef split is audited and unified, every cross-TU read of the
// alarm goes through here.
//
// #82 REPLICANTS (field: "peers see a limping mech SKATING", 2026-07-29): the
// binary's mech+0x40 is ONE cell -- and because that cell IS
// Simulation::simulationState, it rides EVERY update record header
// (Simulation::Write/ReadUpdateRecord), so a peer's replicant learned the gimp
// level for free and its gait machine limped with no extra plumbing.
//
// The port split the cell (graphicAlarm = where mechdmg writes; engine
// simulationState = what replicates). A replicant NEVER re-derives the level:
// zone damage reaches it as a DamageZone update record
// (Entity::ReadDamageUpdateRecord -> DamageZone::ReadUpdateRecord writes
// damageLevel straight from the wire), which never runs
// Mech__DamageZone::TakeDamage -- so the threshold evaluation, and with it
// graphicAlarm 3/4, only ever happens on the damage-resolving node. Its
// self-simulated gait therefore stayed in the WALK states while the replicated
// speed dragged it along at limp pace: the skate.
//
// So read the cell the way the binary had it: the master's authoritative
// graphicAlarm, else the replicated engine cell (which the master now mirrors
// for the warning voice, #78 -- the same value the binary put on the wire).
// Mech::StateCount is 0x21 and the engine base only defines 0/1 there, so 3/4
// are unambiguously the mech's gimp levels (the authored audio watchers on
// SimulationState 3/4 are the binary's own confirmation).
int BTMechGimpAlarmLevel(void *mech_v)
{
if (mech_v == 0)
return 0;
return (int)((Mech *)mech_v)->graphicAlarm.GetLevel();
}
int BTMechGimpLevel(void *mech_v)
{
if (mech_v == 0)
return 0;
Mech *m = (Mech *)mech_v;
const int alarm = (int)m->graphicAlarm.GetLevel();
const int sim = (int)m->GetSimulationState();
// ONE-CELL PRECEDENCE: in the binary a fall / death / limbo state OVERWROTE
// the gimp level in the same cell, so those gaits win and the NORMAL
// drivers (which carry the death latch) must run. Report the alarm
// unchanged there -- never gimp.
if (sim == 2 || (sim >= 5 && sim <= 9))
return alarm;
if (alarm == 3 || alarm == 4)
return alarm; // master authority
if (sim == 3 || sim == 4)
return sim; // the replicated cell
return alarm; // 0/9/... unchanged
}
void BTMechVitalSubsystemKill(void *owner_mech)
{
if (owner_mech != 0)
((Mech *)owner_mech)->graphicAlarm.SetLevel(9); // owner+0x2C, level 9
}
//#############################################################################
// CriticalHit -- @0049ccc4
//
// Apply (half of) a critical hit. Half the damage amount flows through the
// normal armour model (TakeDamage); the remainder rolls one critical
// subsystem, weighted by criticalWeight, and damages it. Returns the chosen
// subsystem (or 0 if none was selected).
//
Subsystem*
Mech__DamageZone::CriticalHit(Damage &damage_data)
{
Scalar bite = damage_data.damageAmount * CriticalDamageFraction; // damage+4 * 0.5
if (bite > CriticalDamageMax)
bite = CriticalDamageMax;
damage_data.damageAmount -= bite;
TakeDamage(damage_data); // slot 6 (this+0x18)
Scalar roll = (Scalar)Random; // FUN_00408050
if (criticalSubsystems != 0)
{
for (int i = 0; i < criticalSubsystemCount; ++i)
{
Scalar cum = criticalSubsystems[i]->criticalWeight;
for (int j = 0; j < i; ++j)
cum += criticalSubsystems[j]->criticalWeight;
if (roll <= cum / criticalWeightSum)
{
Subsystem *s = criticalSubsystems[i]->subsystemPlug.GetCurrent();
MechCriticalSubsystem *cs = criticalSubsystems[i];
if (s != 0 // unbound-plug guard
&& cs->damagePercentageUsed < cs->damagePercentage)
cs->damagePercentageUsed +=
((MechSubsystem *)s)->ApplyDamageAndMeasure(damage_data); // FUN_004ac07c
return s;
}
}
}
return 0;
}
//#############################################################################
// SendSubsystemDamage -- @0049c9a8
//
// When this zone is destroyed it forces full structure (1.0) on itself and
// pushes each critical subsystem's outstanding damage fraction onto the zone
// that physically carries that subsystem; any zone driven to destruction
// there raises its alarms and fails the subsystem.
//
void
Mech__DamageZone::SendSubsystemDamage()
{
SetDamageZoneState(1); // this+0x10
damageLevel = StructureMax; // this+0x158 = 1.0
for (int i = 0; i < criticalSubsystemCount; ++i)
{
MechSubsystem *s =
(MechSubsystem *)criticalSubsystems[i]->subsystemPlug.GetCurrent(); // +4
MechCriticalSubsystem *cs = criticalSubsystems[i];
// Unbound-plug guard: with the authentic binding live (the ctor loop)
// this is only the idx-0/1 roster residue (mapper installed post-ctor
// / voltage-bus stub), not the norm. The 1995 binary derefs with no
// check because every plug there is bound.
if (s == 0)
{
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] crit-subsys " << i << "/" << criticalSubsystemCount
<< " UNBOUND (roster slot residue) -- skipped\n" << std::flush;
continue;
}
// Push the UNUSED crit allotment into the subsystem's OWN private
// DamageZone (the 0x160-byte zone its ctor built at +0xE0) -- NOT a
// mech dz_* zone (the old SubProxy2 cast misread this). [T1 @0049c9a8]
Scalar level = s->GetSubsystemDamageLevel()
+ (cs->damagePercentage - cs->damagePercentageUsed);
level = Clamp(level, StructureMin, StructureMax);
s->SetSubsystemDamageLevel(level);
if (level >= StructureMax) // subsystem destroyed
{
if (s->IsVitalSubsystem()) // +0xE4
((Mech *)GetOwningSimulation())->graphicAlarm.SetLevel(9); // mech kill
s->ForceCriticalFailure(); // status 1 + print + zone valve
// MP MATCH FORENSICS (matchlog.hpp): a critical subsystem died
// to the zone cascade (vital=1 == this kills the mech).
if (BTMatchLogActive())
{
Mech *owner = (Mech *)GetOwningSimulation();
BTMatchLog("CRIT", "mech=%d:%d zone=%d sub=%d vital=%d",
BTMatchHostOf(owner->GetEntityID()),
(int)owner->GetEntityID(),
damageZoneIndex, i, (int)s->IsVitalSubsystem());
}
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] crit-subsys " << i
<< " '" << (s->GetName() ? s->GetName() : "?") << "'"
<< " DESTROYED (vital=" << s->IsVitalSubsystem() << ")\n" << std::flush;
}
else if (getenv("BT_DEATH_LOG"))
{
DEBUG_STREAM << "[deathfx] crit-subsys " << i
<< " '" << (s->GetName() ? s->GetName() : "?") << "'"
<< " damaged -> " << level
<< " (pct=" << cs->damagePercentage
<< " used=" << cs->damagePercentageUsed << ")" << std::endl;
}
}
}
//#############################################################################
// RecurseSegmentTable -- @0049cad4
//
// Walks the model's segment tree from this zone. First pushes this zone's
// subsystem damage out (SendSubsystemDamage), then -- per the two streamed
// flags -- destroys sibling zones (destroySiblingsOnDestruction) and/or
// descends into child segments (descendOnDestruction), recursing on each.
//
void
Mech__DamageZone::RecurseSegmentTable(Mech *my_mech)
{
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[cascade] zone " << damageZoneIndex
<< " DESTROYED -> descend=" << (int)descendOnDestruction
<< " destroySibs=" << (int)destroySiblingsOnDestruction
<< " crits=" << criticalSubsystemCount << std::endl;
SendSubsystemDamage(); // FUN_0049c9a8
SetGraphicState(1); // slot 0xc
//
// The segment-tree walk (@0049cad4, re-read 2026-08-02 for #110). The
// binary iterates the mech's segment table (mech+0x300, vtbl+0x34 GetNth)
// to this zone's segment, then walks two lists ON the EntitySegment:
// seg+0xD0 the damage-zone indices ATTACHED to this segment
// seg+0xE8 the CHILD segment indices
// Those are the engine's own EntitySegment::damageZoneTable /
// childIndexTable -- byte-identical offsets (TableOf is 0x18: 0xD0+0x18 =
// 0xE8), populated at stream time by JMOVER.cpp:373 (AddDamageZone) [T0].
// The previous "reconstruction" here walked stub iterators that always
// returned NULL, so the cascade never left this zone: an arm died and the
// gun pod hanging off it kept firing (#110, Lynx + Conn Man's three-chassis
// audit). Element payload = PlugOf<int> (operator int).
//
EntitySegment::SegmentTableIterator segs(my_mech->segmentTable); // mech+0x300
EntitySegment *seg = segs.GetNth(segmentIndex); // vtbl+0x34
if (seg == 0)
return; // (binary derefs blind)
if (destroySiblingsOnDestruction) // Wword(0x68)
{
// Other zones on the SAME segment.
EntitySegment::IntegerTableIterator sibs(seg->GetDamageZoneTable()); // seg+0xD0
for (EntitySegment::IntegerPlug *r; (r = sibs.ReadAndNext()) != 0; )
{
Mech__DamageZone *sib = my_mech->Zone((int)*r);
if (sib != 0 && sib != this && sib->GetGraphicState() != 1)
{
sib->RecurseSegmentTable(my_mech);
sib->SetGraphicState(2); // 2 = Gone
}
}
}
if (descendOnDestruction) // Wword(0x67)
{
// Every zone on every CHILD segment, recursively -- the arm takes the
// gun with it. The binary's child-zone loop has NO graphic-state
// guard (only the sibling loop has one); reproduced as-is.
EntitySegment::IntegerTableIterator kids(seg->GetChildIndexTable()); // seg+0xE8
for (EntitySegment::IntegerPlug *r; (r = kids.ReadAndNext()) != 0; )
{
EntitySegment::SegmentTableIterator segs2(my_mech->segmentTable);
EntitySegment *child = segs2.GetNth((int)*r);
if (child == 0)
continue;
EntitySegment::IntegerTableIterator czones(child->GetDamageZoneTable()); // child+0xD0
for (EntitySegment::IntegerPlug *cr; (cr = czones.ReadAndNext()) != 0; )
{
Mech__DamageZone *cz = my_mech->Zone((int)*cr);
if (cz == 0)
continue;
cz->RecurseSegmentTable(my_mech);
cz->SetGraphicState(2);
}
}
}
}
//#############################################################################
// CreateStreamedDamageZone -- @0049d304
//
// Parse ONE zone out of the .dmg notation file into the memory stream that
// the ctor above will later replay. Called once per entry in the .dmg
// "DamageZones" list by Mech::CreateDamageZoneStream (@004a474c).
//
// Token stream written, in order:
// (base fields via DamageZone::CreateStreamedDamageZone @0041e590)
// DescendOnDestruction (Logical)
// DestroySiblingsOnDestruction (Logical)
// GetSegmentIndex(name, skl) (int)
// leftLeg, rightLeg (from "LegDamageZone" == LeftLeg/RightLeg)
// vitalDamageZone (from "VitalDamageZone" == True/False, required)
// criticalSubsystemCount, then per "CriticalSubsystem <name> <%f> <%f>":
// subsystem index ("ControlsMapper"->0, "Power"->1, else Subsystems list),
// the two percentages
// redirectCount, then per "LODRedirect" entry: GetSegmentIndex of the name.
//
Logical
Mech__DamageZone::CreateStreamedDamageZone(
ResourceFile *resource_file,
NotationFile *model_file,
const char *model_name,
NotationFile *skl_file,
const char *damage_zone_name,
NotationFile *dmg_file,
const ResourceDirectories *directories,
MemoryStream *damage_zone_stream
)
{
CString name(damage_zone_name);
// base armour/structure record
if (!DamageZone::CreateStreamedDamageZone( // FUN_0041e590
model_file, model_name, skl_file, name, damage_zone_stream, dmg_file, directories))
return (Logical)-1;
Logical descend = 0;
((NoteX2 *)dmg_file)->ReadLogical(damage_zone_name, "DescendOnDestruction", &descend);
damage_zone_stream->WriteBytes(&descend, 4);
Logical destroySibs = 0;
((NoteX2 *)dmg_file)->ReadLogical(damage_zone_name, "DestroySiblingsOnDestruction", &destroySibs);
damage_zone_stream->WriteBytes(&destroySibs, 4);
int segIndex = GetSegmentIndex(name, skl_file); // FUN_0049db20
damage_zone_stream->WriteBytes(&segIndex, 4);
Logical isLeftLeg = 0, isRightLeg = 0;
const char *leg;
if (((NoteX2 *)dmg_file)->ReadString(damage_zone_name, "LegDamageZone", &leg))
{
if (Str_Equal(leg, "RightLeg")) isRightLeg = 1;
if (Str_Equal(leg, "LeftLeg")) isLeftLeg = 1;
}
damage_zone_stream->WriteBytes(&isLeftLeg, 4);
damage_zone_stream->WriteBytes(&isRightLeg, 4);
const char *vital;
if (!((NoteX2 *)dmg_file)->ReadString(damage_zone_name, "VitalDamageZone", &vital))
{
DebugStream << dmg_file << damage_zone_name << " missing VitalDamageZone!";
return False;
}
Logical isVital;
if (Str_Equal(vital, "True")) isVital = 1;
else if (Str_Equal(vital, "False")) isVital = 0;
else
{
DebugStream << model_name << " " << damage_zone_name
<< " Improper format for LegDamageZone";
return False;
}
damage_zone_stream->WriteBytes(&isVital, 4);
//
// CriticalSubsystem list ("<name> <%f> <%f>").
//
NotationList crits = ((NoteX2 *)dmg_file)->FindList(damage_zone_name, "CriticalSubsystem");
int critCount = crits.Count();
damage_zone_stream->WriteBytes(&critCount, 4);
for (NotationEntry *e = crits.First(); e != 0; e = e->Next())
{
char subName[64];
Scalar pctA, pctB;
if (sscanf(e->text, "%s %f %f", subName, &pctA, &pctB) == 0) // s__s__f__f
{
DebugStream << model_name << " error in CriticalSubsystem Format!";
return (Logical)-1;
}
damage_zone_stream->WriteBytes(&pctA, 4);
damage_zone_stream->WriteBytes(&pctB, 4);
int subIndex;
if (Str_Equal(subName, "ControlsMapper"))
subIndex = 0;
else if (Str_Equal(subName, "Power"))
subIndex = 1;
else
{
// search the model's "Subsystems" list, indices start at 2
const char *subFile;
((NoteX2 *)model_file)->ReadString("gamedata", "Subsystems", &subFile);
NotationFile *sf = ((RFileX2 *)resource_file)->Open(subFile);
NotationList list = ((NoteX2 *)sf)->FindList(0);
subIndex = 2;
NotationEntry *se;
for (se = list.First(); se != 0; se = se->Next(), ++subIndex)
if (Str_Equal(se->name, subName))
break;
((NoteX2 *)sf)->Close();
if (se == 0)
{
DebugStream << model_name << ':' << subName << " does not exist!";
return (Logical)-1;
}
}
damage_zone_stream->WriteBytes(&subIndex, 4);
}
//
// LODRedirect list: each redirect names a child zone resolved via the
// segment table (TableOf::Find @004274f8); -1 == not found is an error.
//
NotationList redirects = ((NoteX2 *)dmg_file)->FindList(damage_zone_name, "LODRedirect");
int redirectCount = redirects.Count();
damage_zone_stream->WriteBytes(&redirectCount, 4);
for (NotationEntry *e = redirects.First(); e != 0; e = e->Next())
{
CString childName(e->text);
if (childName.Length() == 0)
{
DebugStream << model_name << " error in LODRedirect Format!";
return (Logical)-1;
}
int childIndex = FindSegment(childName, skl_file); // FUN_004274f8
if (childIndex == -1)
{
DebugStream << resource_file << ' ' << childName << " Not Found!";
return (Logical)-1;
}
damage_zone_stream->WriteBytes(&childIndex, 4);
}
return True;
}
//#############################################################################
// GetSegmentIndex -- @0049db20
//
// Walk the skeleton (.skl) notation file's segment groups in order. Skipping
// the "LAB_ONLY" and "DamageZones" pseudo-groups, count real segments; for
// each segment read its "dzone" sub-list and, if any dzone name matches
// damage_zone_name, return the running segment index. Returns -1 on miss.
//
int
Mech__DamageZone::GetSegmentIndex(CString damage_zone_name, NotationFile *skl_file)
{
int index = -1;
NotationList segments = ((NoteX2 *)skl_file)->FindList(0); // FUN_0040485c
for (NotationEntry *seg = segments.First(); seg != 0; seg = seg->Next())
{
if (Str_Equal(seg->name, "LAB_ONLY")) continue;
if (Str_Equal(seg->name, "DamageZones")) continue;
++index;
NotationList dzones = ((NoteX2 *)skl_file)->FindList(seg->name, "dzone"); // FUN_00404720
for (NotationEntry *dz = dzones.First(); dz != 0; dz = dz->Next())
{
CString dzName(dz->text);
if (dzName == damage_zone_name) // FUN_004027d8
return index;
}
}
return -1;
}
//#############################################################################
// Damage-state descriptor table -- @0041e4a8 / @0042a748 / @0042a2c8
//
// Streams this zone's damage-state descriptor table from the type-0x1e resource
// (binary this+0xd4). Each entry (0x1C bytes) is [f32 DamageLevel][i32
// EffectResource][i32 GraphicState][f32 TimeDelay], written ascending by
// DamageLevel. (Was a no-op stub -> the table was never built, so
// MechDeathHandler had nothing to walk and death was invisible.)
//#############################################################################
void
Mech__DamageZone::LoadCriticalSubsystems(MemoryStream *stream) // @0041e4a8
{
int count = 0;
stream->ReadBytes(&count, 4); // FUN_0042a748: entry count
damageDescriptors.reserve(count > 0 ? count : 0);
for (int i = 0; i < count; ++i) // FUN_0042a2c8 per entry
{
DamageDescriptor d;
stream->ReadBytes(&d.damageLevel, 4); // +0xc
stream->ReadBytes(&d.effectResource, 4); // +0x14
stream->ReadBytes(&d.graphicState, 4); // +0x10
stream->ReadBytes(&d.timeDelay, 4); // +0x18
damageDescriptors.push_back(d);
}
if (count != 0 && getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] zone descriptor table loaded: " << count
<< " entries (first threshold=" << (count > 0 ? damageDescriptors[0].damageLevel : -1.0f)
<< " effect=" << (count > 0 ? damageDescriptors[0].effectResource : -1) << ")\n" << std::flush;
}
//
// @0042a664 -- first entry whose threshold is strictly greater than 'level'
// (0 if none / the zone is past the last threshold, i.e. fully destroyed).
//
const Mech__DamageZone::DamageDescriptor *
Mech__DamageZone::DescriptorForLevel(Scalar level) const
{
for (unsigned i = 0; i < damageDescriptors.size(); ++i)
if (damageDescriptors[i].damageLevel > level)
return &damageDescriptors[i];
return 0;
}
//
// @0042a5f4 -- true iff some entry's threshold lies in (oldLevel, newLevel]
// (a damage-state threshold was crossed this tick).
//
Logical
Mech__DamageZone::DescriptorCrossed(Scalar oldLevel, Scalar newLevel) const
{
for (unsigned i = 0; i < damageDescriptors.size(); ++i)
{
Scalar t = damageDescriptors[i].damageLevel;
if (t > oldLevel && t <= newLevel)
return True;
}
return False;
}
//
// @0042a6c4 -- first entry whose GraphicState enum == gstate (0 if none).
//
const Mech__DamageZone::DamageDescriptor *
Mech__DamageZone::DescriptorForGraphicState(int gstate) const
{
for (unsigned i = 0; i < damageDescriptors.size(); ++i)
if (damageDescriptors[i].graphicState == gstate)
return &damageDescriptors[i];
return 0;
}
// Inherited engine DamageZone::damageLevel (binary this+0x158) -- the [0,1]
// structural damage MechDeathHandler compares against the descriptor thresholds.
Scalar
Mech__DamageZone::GetStructureDamageLevel() const
{
return damageLevel;
}
void
Mech__DamageZone::ApplyDamageGraphicState(int gstate)
{
SetGraphicState(gstate); // engine DamageZone::SetGraphicState -> destroyed skin
}
void
Mech__DamageZone::Heal()
{
// Respawn heal: restore this zone to the undamaged/intact state the loader
// builds -- full structure, intact skin, no burning/destroyed flag. The
// authentic Mech::Reset (@0049fb74) heals via each subsystem's
// ResetToInitialState; the zone's structural state is restored here.
damageLevel = StructureMin; // 0.0 -- full structure (this+0x158)
ApplyDamageGraphicState(ExistsGraphicState); // 0 -- intact skin (undo Destroyed/Gone)
SetDamageZoneState(0); // clear burning / vital-destroyed state
}
//#############################################################################
// MechDeathHandler -- @0042a984 / @0042aa2c / @0042a9f4
//#############################################################################
// Effect-spawn bridge (mech4.cpp -- where Explosion::Make + the mech transform
// live): spawns the descriptor's explosion resource at the mech. The authentic
// path dispatches a class-5 message to the effect manager (app+0x38 -> the 0xBD3
// SubsystemMessageManager, unreconstructed); we use the established Explosion port.
extern void BTSpawnDamageEffect(Mech *mech, int effect_resource, int segment_index);
// Render bridge (btl4vid.cpp): swap a mech's wrecked segment meshes onto its
// already-built render tree when a damage zone's graphic state changes (the
// unported "RemakeEntity" render state). Entity* param -> Mech* binds fine.
extern void BTRemakeMechModel(Entity *entity);
//
// #108 bridge: Mech::ReadUpdateRecord (mech.cpp, incomplete-type TU for the
// handler) stamps the ghost detector through this.
//
void
BTMechNoteUpdateApplied(void *mech_v)
{
Mech *m = (Mech *)mech_v;
if (m != 0 && m->DeathHandlerPtr() != 0)
((MechDeathHandler *)m->DeathHandlerPtr())->NoteUpdateApplied();
}
MechDeathHandler::MechDeathHandler(Mech *mech) // @0042a984
: owner(mech), prevMode(0), ghostFrames(0), ghostLogged(0),
skatePrevX(0.0f), skatePrevZ(0.0f), skateFrames(0), skateLogged(0)
{
// per-zone last-damage cache, zeroed (binary this[0x10], size mech+0x11c).
int count = (mech != 0 && mech->damageZoneCount > 0) ? mech->damageZoneCount : 0;
lastLevel.assign(count, 0.0f);
}
MechDeathHandler::~MechDeathHandler() // @0042a9f4
{
}
//
// @0042aa2c -- each tick, walk the zones; where a zone's damageLevel rose since
// last tick, fire the current damage-band descriptor's explosion (binary +0xb8 &
// 4) and, on destruction, the Destroyed-graphic descriptor's explosion (+0xb8 &
// 8), applying that descriptor's GraphicState (the destroyed skin) to the zone.
//
void
MechDeathHandler::Tick() // @0042aa2c
{
if (owner == 0)
return;
// #52 SKATE DETECTOR (PORT, ungated -- the night-12 forensic): a REPLICANT
// whose position advances while its LEG CHANNEL is not cycling is the
// field "skating" signature. One line per episode, carrying the three
// inputs that split the failure: bodyTargetSpeed (the replicated commanded
// speed the leg SM feeds from -- ~0 means the RECORD side starved the
// gait), legCycleSpeed (~0 with a live demand means the SM is STUCK), and
// IsMechDestroyed (1 means the gait feed block was SKIPPED -- the stale
// post-respawn flag suspect). ~90 frames of sustained skate to trip; the
// threshold 0.08 u/frame ~ 2.4 u/s at 30 fps, well above reckoner jitter.
if (owner->GetInstance() == Entity::ReplicantInstance)
{
const float px = (float)owner->localOrigin.linearPosition.x;
const float pz = (float)owner->localOrigin.linearPosition.z;
const float dx = px - skatePrevX, dz = pz - skatePrevZ;
const float step = sqrtf(dx * dx + dz * dz);
skatePrevX = px; skatePrevZ = pz;
// CHANNEL-AGNOSTIC (corrected same-day): the peer's joints are posed by
// the BODY channel in the current authentic architecture (mech4
// s_peerLegCh=0 -> AdvanceBodyAnimation mj=1), so legCycleSpeed==0 is
// NORMAL there -- the first detector build false-fired on every healthy
// movement phase. Skating = moving with BOTH channels idle.
const float cyc = fabsf((float)owner->legCycleSpeed)
+ fabsf((float)owner->bodyCycleSpeed);
const int movingNoLegs = (step > 0.08f && step < 5.0f // 5+: teleport/warp
&& cyc < 0.05f) ? 1 : 0;
// #52 A/B probe (BT_BODY_SM_LOG): the POSITIVE half of the Standing-lock
// evidence. The [skate] line only speaks once a lock has ALSO produced
// 90 sustained moving frames; this says every second what a moving peer's
// body channel is actually doing. Locked: bstate=0, bodyCyc=0. Healthy:
// bstate in the walk/run family with a live cycle.
if (step > 0.08f && step < 5.0f && getenv("BT_BODY_SM_LOG"))
{
static float s_pg = 0.0f; s_pg += 1.0f / 30.0f;
if (s_pg >= 1.0f) { s_pg = 0.0f;
DEBUG_STREAM << "[peergait] replicant " << owner->GetEntityID()
<< " step=" << step
<< " bstate=" << (int)owner->bodyAnimationState
<< " bodyCyc=" << (float)owner->bodyCycleSpeed
<< " bts=" << (float)owner->bodyTargetSpeed
<< (movingNoLegs ? " <-- IDLE CHANNELS" : "")
<< "\n" << std::flush; }
}
if (movingNoLegs)
{
if (++skateFrames > 90 && !skateLogged)
{
skateLogged = 1;
// bstate = the peer's BODY animation state (@0x728), the channel
// that poses a replicant (s_peerLegCh=0). Added 2026-08-07 after
// night 13: the field lines proved "moving with both channels
// idle" but not WHICH state it was idling in, and the answer
// (0 = Standing, pinned) is the whole diagnosis -- see #52.
DEBUG_STREAM << "[skate] replicant " << owner->GetEntityID()
<< " SKATING: " << skateFrames << " frames moving ("
<< step << " u/frame) with legCyc="
<< (float)owner->legCycleSpeed
<< " bodyCyc=" << (float)owner->bodyCycleSpeed
<< " bodyTargetSpeed=" << (float)owner->bodyTargetSpeed
<< " bstate=" << (int)owner->bodyAnimationState
<< " destroyed=" << (int)owner->IsMechDestroyed()
<< " mode=" << (int)owner->MovementMode()
<< " at (" << px << "," << pz << ")\n" << std::flush;
if (BTMatchLogActive())
BTMatchLog("SKATE", "mech=%d:%d frames=%d step=%.3f cyc=%.3f "
"cmdSpd=%.2f bstate=%d destroyed=%d mode=%d x=%.1f z=%.1f",
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
skateFrames, step, cyc,
(float)owner->bodyTargetSpeed,
(int)owner->bodyAnimationState,
(int)owner->IsMechDestroyed(), (int)owner->MovementMode(),
px, pz);
}
}
else if (skateFrames > 0)
{
if (skateLogged)
DEBUG_STREAM << "[skate] replicant " << owner->GetEntityID()
<< " recovered after " << skateFrames << " frames\n" << std::flush;
skateFrames = 0; skateLogged = 0;
}
}
// #108 GHOST DETECTOR (PORT, ungated -- the night-9 forensic): a visible
// REPLICANT that stops receiving update records is exactly what "everyone
// shoots thin air" looks like from the other side. ReadUpdateRecord
// stamps arrivals (NoteUpdateApplied); ~20s of silence on an unburied
// replicant logs ONE [ghost] line per episode with the identity + last
// position, then re-arms when records resume. Frame-counted (Tick has no
// dt); 600 frames ~ 10-20s at field rates.
if (owner->GetInstance() == Entity::ReplicantInstance)
{
++ghostFrames;
if (ghostFrames > 600 && !ghostLogged
&& !owner->WreckBuried()) // buried wrecks are expected-silent
{
ghostLogged = 1;
DEBUG_STREAM << "[ghost] replicant " << owner->GetEntityID()
<< " has received NO update records for " << ghostFrames
<< " frames (mode " << (int)owner->MovementMode()
<< ") last pos (" << owner->localOrigin.linearPosition.x
<< "," << owner->localOrigin.linearPosition.z
<< ") -- stale-replicant / ghost signature (#108)\n" << std::flush;
if (BTMatchLogActive())
BTMatchLog("GHOST", "mech=%d:%d frames=%d mode=%d x=%.1f z=%.1f",
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
ghostFrames, (int)owner->MovementMode(),
(float)owner->localOrigin.linearPosition.x,
(float)owner->localOrigin.linearPosition.z);
}
}
// REPLICANT UN-WRECK (#94): fire on the DEATH-STATE EXIT edge of the
// replicated SimulationState dial (MovementMode 9/2 are the death modes;
// they never revert except through Mech::Reset, whose SetMovementMode(0)
// + ForceUpdate(0x1f) reaches the peer in every record header). This is
// the cause-agnostic respawn signal: the previous trigger inferred the
// respawn from a damage-zone falling edge, which a COLLISION death never
// produces (type-0 damage prices as internal rattle, never armor -- the
// zones of a fresh mech killed by ramming sit at 0 for its whole life),
// so the peer's wreck could never un-wreck: the live respawned mech drove
// around wearing the hulk (night-7 field report, SAURON's Loki). Gated
// to ReplicantInstance: the master un-wrecks locally in Mech::Reset.
{
const int mode = (int)owner->MovementMode();
const int oldMode = prevMode;
const bool nowDead = (mode == 2 || mode == 9);
const bool prevDead = (oldMode == 2 || oldMode == 9);
prevMode = mode;
if (prevDead && !nowDead
&& owner->GetInstance() == Entity::ReplicantInstance)
{
extern void BTRebuildMechModel(Entity *entity);
extern void BTStartWarpEffect(float x, float y, float z);
// #129 (fresh spawns smoking, peers' screens only): the wreck plume
// is re-armed LOCALLY on every node (entity-attached, 10s windows),
// and only the master's Mech::Reset ran the @004d0c14 effect
// cleanup -- the observer's last window rode the teleport and kept
// puffing on the fresh mech for up to ~16s. Kill the entity's
// effects on the un-wreck edge, same as the master path.
extern void BTStopEntityPfx(void *entity_v);
BTStopEntityPfx((void *)owner);
BTRebuildMechModel((Entity *)owner);
BTStartWarpEffect((float)owner->localOrigin.linearPosition.x,
(float)owner->localOrigin.linearPosition.y,
(float)owner->localOrigin.linearPosition.z);
// #108 forensics: UNGATED -- one line per peer respawn; a field
// night's ghost triage needs these without asking players for envs.
DEBUG_STREAM << "[respawn] replicant " << owner->GetEntityID()
<< " un-wrecked + warp (mode "
<< oldMode << "->" << mode << ") at ("
<< owner->localOrigin.linearPosition.x << ","
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
}
}
int n = owner->damageZoneCount;
for (int i = 0; i < n && i < (int)lastLevel.size(); ++i)
{
Mech__DamageZone *zone = owner->Zone(i);
if (zone == 0)
continue;
Scalar level = zone->GetStructureDamageLevel();
Scalar prev = lastLevel[i];
// FALLING edge = the master's Mech::Reset healed + re-broadcast this zone
// (ForceUpdate(DamageZoneUpdateModelFlag)). Just refresh the cache: the
// respawn un-wreck no longer rides this edge -- it fires from the
// death-state exit watcher above, which also covers deaths that never
// moved a zone (#94).
if (level < prev)
{
lastLevel[i] = level;
continue;
}
if (level <= prev) // unchanged -- no new damage
continue;
lastLevel[i] = level;
// AUTHENTIC (FUN_0042aa2c @0042aa2c, the binary's effect-watcher level
// branch): when the rise CROSSES a band-descriptor threshold
// (FUN_0042a5f4) and this node owns the master, raise the entity's
// damage-zone update flag (*(entity+0x18) |= 2, master-gated on
// (entity+0x28 & 0xc) == 0) -- the zone update records ship this
// zone's damageLevel to every observer, so replicant dolls/hull tint
// track the fight at band granularity. This send was MISSING from
// the port (only the graphic-state branch below raised it): masters
// consumed damage correctly while every observer's copy sat at 0.0
// until destruction (the "no discoloration in MP" report, #87).
if (zone->DescriptorCrossed(prev, level)
&& owner->GetInstance() == Entity::MasterInstance)
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
// AUTHENTIC GATE (decomp re-verified 2026-07-12, workflow): the binary
// fires the CURRENT band descriptor's effect whenever the zone's
// damage-CHANGED flag is set (+0xb8 & 4, set by DamageZone::TakeDamage,
// coalesced per tick) -- i.e. EVERY hit on a damaged mech re-puffs its
// band effect. Bands 3/4 are authored ember/bright-FIRE plumes, so a
// nearly-destroyed mech under fire burns continuously (the demo look).
// A level RISE this tick IS the flag analog -- no crossing test. (The
// earlier metronome was SHKWAVE mispacing, not this path; an observer
// node sees a few replication-step puffs per volley -- smoke, fine.)
const Mech__DamageZone::DamageDescriptor *d = zone->DescriptorForLevel(level);
if (level >= 1.0f && prev < 1.0f) // just destroyed -> the Destroyed descriptor
{
const Mech__DamageZone::DamageDescriptor *dd =
zone->DescriptorForGraphicState(DamageZone::DestroyedGraphicState); // enum 1
if (dd != 0)
d = dd;
}
if (d != 0)
{
// THE AUTHENTIC CHAIN (@004d097c dispatcher, landed 2026-07-13):
// RendererManager::StartEntityEffect -> the Renderer message
// handler [T0: GameModel resolve + ExplosionResourceTable
// graphic-state remap] -> BTL4VideoRenderer::
// StartEntityEffectImplementation -> the effect ATTACHED to the
// zone's segment (smoke follows the walking mech) -- and the
// AudioRenderer's implementation hears the same message (zone
// sounds ride free). Explosion::Make fallback only when the
// manager isn't up (early bring-up).
extern int BTStartZoneEffect(Mech *mech, void *zone, int resource);
if (!BTStartZoneEffect(owner, zone, d->effectResource))
{
BTSpawnDamageEffect(owner, d->effectResource, // explosion AT the zone's
zone->segmentIndex); // segment (world position)
}
zone->ApplyDamageGraphicState(d->graphicState); // destroyed skin (graphic state)
// A graphic-state change means the segment's MODEL changed (intact ->
// destroyed variant, keyed by GetVideoObjectName(skl, gstate)). Fire
// the engine's model-rebuild flag so the renderer re-runs
// MakeMechRenderables and loads the destroyed segment geometry -- the
// same mechanism CulturalIcons use to visibly deform on damage
// (CULTURAL.cpp:91; EXPTBL.cpp:549 does this per zone in the engine's
// ExplosionTable). Without this the tree stays as built at spawn (intact).
// The render's "RemakeEntity" state (which consumes this flag) was
// NOT ported -- the mech tree is built once at spawn -- so we drive the
// equivalent directly: set the flag (authentic signal; also flags the
// zone for net damage replication) AND call the render bridge, which
// swaps the wrecked segment mesh onto the already-built tree in place.
// The single-threaded frame loop makes the immediate swap safe.
if (d->graphicState != DamageZone::ExistsGraphicState)
{
// Master-gated like the binary's gstate branch (@0042aa2c:
// (entity+0x28 & 0xc) == 0 guards the |= 2); the mesh swap
// runs on EVERY instance -- an observer node executes this
// same path when the replicated level/gstate lands.
if (owner->GetInstance() == Entity::MasterInstance)
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
BTRemakeMechModel(owner); // RemakeEntity: swap in the destroyed mesh
}
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] zone " << i << " level " << level
<< " -> effect " << d->effectResource << " gstate " << d->graphicState
<< "\n" << std::flush;
}
}
}
//#############################################################################
// Mech::AmmoExplosionFanOut -- the mech side of @0x4ac274 (Gitea #46)
//
// The binary iterates the plugs connected to the EXPLODING SUBSYSTEM
// (FUN_004acfa9 walks its Node link chain), collecting every object whose
// classID@+4 == 0x4E = DamageZoneClassID -- i.e. the mech damage zones whose
// critical-subsystem entries plug this subsystem -- then splits the total
// damage evenly and sends the OWNER one full Entity::TakeDamageMessage
// (id 0x12, 100 bytes) per zone:
//
// +0x1C inflictingEntity = owner+0x184 (the mech ITSELF)
// +0x24 damageZone = zone+0x13C (damageZoneIndex)
// +0x2C damageData = the split Damage record
// +0x5C inflictingSubsystemID = subsystem+0xD8 (so the message manager
// can bundle the bin's explosion resource for the view --
// ENTITY3.h's own NOTE)
//
// printing "ammo explosion damaging <zoneName>" (@0050df61, name from
// zone+0x15C = DamageZone::damageZoneName) per zone. [T1 raw decomp]
//
// The port walks its own zone table with the SAME membership test (the
// criticalSubsystems[] plug back-reference IS the plug link the binary
// iterated from the subsystem's side). ONE guarded deviation: the binary
// divides by the collected count UNGUARDED (every authored bin lives in at
// least one zone); the port warns-and-returns on zero so a malformed model
// cannot div-by-zero.
//
void
Mech::AmmoExplosionFanOut(
::Subsystem *inflicting_subsystem,
int inflicting_subsystem_id,
Damage &total_damage
)
{
enum { kMaxZones = 32 };
int zones[kMaxZones];
int zoneCount = 0;
for (int z = 0; z < damageZoneCount && zoneCount < kMaxZones; ++z)
{
Mech__DamageZone *dz = Zone(z);
if (dz == 0)
continue;
for (int c = 0; c < dz->criticalSubsystemCount; ++c)
{
MechCriticalSubsystem *cs = dz->criticalSubsystems[c];
if (cs != 0 && (::Subsystem *)cs->subsystemPlug.GetCurrent() == inflicting_subsystem)
{
zones[zoneCount++] = z;
break;
}
}
}
if (zoneCount < 1)
{
// Guarded deviation (see the header comment).
DEBUG_STREAM << "[ammo] explosion fan-out: subsystem "
<< inflicting_subsystem_id << " is in NO damage zone -- dropped\n" << std::flush;
return;
}
total_damage.damageAmount /= (Scalar)zoneCount; // even split (@004ac2xx)
for (int i = 0; i < zoneCount; ++i)
{
Mech__DamageZone *dz = Zone(zones[i]);
// The binary's exact print (@0050df61) -- warning channel.
DEBUG_STREAM << "ammo explosion damaging "
<< (const char *)dz->damageZoneName << "\n" << std::flush;
Entity::TakeDamageMessage td(
Entity::TakeDamageMessageID, // 0x12
sizeof(Entity::TakeDamageMessage),
GetEntityID(), // inflictingEntity = SELF (owner+0x184)
dz->damageZoneIndex, // zone+0x13C
total_damage,
inflicting_subsystem_id); // the bin (explosion bundling)
Dispatch(&td); // (**owner_vtbl)(owner,&msg) -- the
// proven master-side damage path
}
// MP MATCH FORENSICS: the bay-fire detonation landed (field verify for #46).
if (BTMatchLogActive())
BTMatchLog("BAYBOOM", "mech=%d:%d sub=%d zones=%d dmg=%.3f",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
inflicting_subsystem_id, zoneCount, total_damage.damageAmount);
}
//
// The cross-family bridge (called from MechSubsystem::DistributeCriticalHit in
// mechsub.cpp, where Mech is incomplete -- the databinding rule).
//
void BTAmmoExplosionFanOut(
void *owner_mech, void *inflicting_subsystem,
int inflicting_subsystem_id, Damage &total_damage)
{
((Mech *)owner_mech)->AmmoExplosionFanOut(
(::Subsystem *)inflicting_subsystem, inflicting_subsystem_id, total_damage);
}
//
// #124 ZONE WALKER bridge: a zone's carrier SEGMENT index + name, complete-
// type TU (Mech__DamageZone). Replicant-safe -- the zone table streams on
// every instance, so the SHOOTER can enumerate its target's zones locally.
// Returns 0 past the end (the walker wraps on that).
//
int BTMechZoneSegAndName(void *mech_v, int zone_idx, int *seg_out, const char **name_out)
{
Mech *m = (Mech *)mech_v;
if (m == 0 || zone_idx < 0 || zone_idx >= m->damageZoneCount)
return 0;
Mech__DamageZone *dz = m->Zone(zone_idx);
if (dz == 0)
return 0;
*seg_out = dz->EffectSegmentIndex(); // public accessor (@0x194)
*name_out = (const char *)dz->damageZoneName; // engine base, public
return 1;
}