WAVE 7 Phase B: the Mad Cat's LRMs LAUNCH flying missiles that fly + damage (autocannon too)

The byte-exact WORLD-ENTITY reconstruction (Projectile @4be1bc 0x340 / Missile @4bf5b4 0x368)
is infeasible on the 2007 engine: measured sizeof(engine Entity)=0x1BC vs the 1995 binary's
0x300, so the reconstruction's raw base-offset reads (velocity@0x1dc, roster@0x124, motion@0x250)
read GARBAGE on the engine (the Mech 0x638-vs-0x854 gap, but the entity integrator DEPENDS on
those offsets).  So -- like the mech drive and the beam renderer -- flying projectiles are a PORT
reconstruction (BTPushProjectile/BTUpdateProjectiles in mech4.cpp, a static array + stack
messages, ZERO heap ops): seeded from the launcher's fire with the decomp's real muzzle
(GetMuzzlePoint) / launch speed (|launchVelocity|) / per-shot damage (damageData, split across
missileCount), they fly to the target (tracer via BTPushBeam) and deliver the weapon's damage on
impact through the SAME Entity::TakeDamage path as the beam (aim Mech::FirstVitalZone()).

THREE bring-up fixes were needed to make a projectile weapon fire at all (found by tracing):
  1. TRIGGER: fireImpulse was only driven for the Emitter; ProjectileWeaponSimulation now sets
     fireImpulse = gBTWeaponTrigger too (else CheckFireEdge never sees an edge).
  2. AMMO BIN: OwnerSubsystemCount/OwnerSubsystem were stubbed ->0, so ammoBinLink never resolved
     and ConsumeRound always failed; redirected to the real roster (owner->GetSubsystemCount()/
     GetSubsystem(i) -- the AmmoBin 0xBCB constructs before the weapons 0xBCD/0xBD0).
  3. JAM ROLL: UniformRandom() was stubbed `return 0.0f`, so CheckForJam's `0 < jamChance` ALWAYS
     jammed (a projectile weapon could NEVER fire); replaced with a real LCG [0,1) (fires
     ~1-jamChance of the time -- authentic occasional jams).
Also: the mech's own target slot (owner+0x388) isn't populated in bring-up (the visible fire
targets the gEnemyMech global), so BTPushProjectile falls back to gEnemyMech.

Verified: Mad Cat PUSH=62 / IMPACT=31 (LRM missiles 3.33 dmg each split across the salvo + AFC100
autocannon 25 dmg), TARGET DESTROYED, 0 crashes, construction heapcheck-clean; BLH un-regressed
(also fires its ballistic weapon now).  Diagnostics BT_PROJ_LOG ([projectile] PUSH/IMPACT).

REMAINING (deferred): the byte-exact world-entity Missile (Projectile : Mover, MP-replicable via
Registry::MakeEntity) -- the port projectile is master-local only (no MP replication); the real
per-weapon fire-rate/heat wiring off the subsystem sim (mech4 beam path is still the bring-up harness).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-07 14:19:38 -05:00
co-authored by Claude Opus 4.8
parent 2bcca26cea
commit f52bf057e6
6 changed files with 230 additions and 38 deletions
+26 -9
View File
@@ -1220,15 +1220,32 @@ muzzleVelocity — a DEDICATED bridge, not CreateEmitter, because its FireWeapon
per the binary). All three `static_assert`-locked (offsetof/sizeof). Verified: Mad Cat (LRM/ballistic) + BLH
construct the real weapon classes + tick authentic charge/ammo/heat/recoil, DESTROYED-in-8, 0 crashes,
heapcheck-clean through construction. Factory now **18 of 20** (remaining: SubsystemMessageManager 0xBD3
[WAVE 8], Gyroscope 0xBC4 [deferred]). **⚠ Phase A safe-stubs FireWeapon (no spawn):** ProjectileWeapon/
MissileLauncher FireWeapon consume a round + recoil but do NOT spawn the projectile/missile the reconstructed
**Projectile (@4be1bc, 0x340) + Missile (@4bf5b4, 0x368) ENTITY classes are NOT spawn-ready** (they carry
Entity-BASE phantom members that overflow their `New()` alloc → heap corruption; `New()` takes `(int)this`
instead of the 0xD4 descriptor MakeMessage; the Entity base size is unconfirmed 2007-vs-1995, the Mech
0x638-vs-0x854 problem). **Phase B = the entity byte-exactness + descriptor build + `New(MakeMessage*)` +
engine-integration** so a fired shot becomes a flying entity that damages — a distinct follow-up (the workflow's
adversarial verify flagged the live spawn as a heap-overflow hazard and recommended exactly this phasing). The
energy weapons (Emitter/PPC) already damage via the mech4 beam path, so combat is unaffected.
[WAVE 8], Gyroscope 0xBC4 [deferred]).
**✅✅ WAVE 7 Phase B DONE — the Mad Cat's LRMs now LAUNCH flying missiles that fly + damage (autocannon rounds
too).** The byte-exact WORLD-ENTITY reconstruction (Projectile @4be1bc 0x340 / Missile @4bf5b4 0x368) is
**infeasible on the engine**: measured `sizeof(2007 engine Entity)=0x1BC` vs the 1995 binary's 0x300, so the
reconstruction's raw base-offset reads (velocity@0x1dc, roster@0x124, motion@0x250) read GARBAGE on the engine
(the Mech 0x638-vs-0x854 gap, but the entity INTEGRATOR depends on those offsets). So — like the mech drive /
beam renderer — flying projectiles are a **PORT reconstruction** (`BTPushProjectile`/`BTUpdateProjectiles` in
mech4.cpp): seeded from the launcher's fire with the decomp's real muzzle (`GetMuzzlePoint`) / launch speed
(`|launchVelocity|`) / per-shot damage (`damageData`, split across `missileCount`), they fly to the target
(tracer via `BTPushBeam`) and deliver the weapon's damage on impact through the SAME `Entity::TakeDamage` path as
the beam (aim `Mech::FirstVitalZone()`). **THREE bring-up fixes were needed to make a projectile weapon fire at
all** (all found by tracing, marked): (1) **the trigger** — `fireImpulse` was only driven for the Emitter;
`ProjectileWeaponSimulation` now sets `fireImpulse = gBTWeaponTrigger` too (else `CheckFireEdge` never sees an
edge); (2) **the ammo bin** — `OwnerSubsystemCount/OwnerSubsystem` were stubbed `->0` so `ammoBinLink` never
resolved and `ConsumeRound` always failed; redirected to the real roster (`owner->GetSubsystemCount()/
GetSubsystem(i)` — the AmmoBin 0xBCB constructs before the weapons 0xBCD/0xBD0); (3) **the jam roll** —
`UniformRandom()` was stubbed `return 0.0f`, so `CheckForJam`'s `0 < jamChance` ALWAYS jammed (a projectile
weapon could NEVER fire); replaced with a real LCG `[0,1)` (fires ~1-jamChance of the time — authentic occasional
jams). Also: the mech's own target slot (`owner+0x388`) isn't populated in bring-up (the visible fire targets the
`gEnemyMech` global), so `BTPushProjectile` **falls back to `gEnemyMech`**. Verified: Mad Cat PUSH=62 / IMPACT=31
(LRM missiles 3.33 dmg each split across the salvo + AFC100 autocannon 25 dmg), TARGET DESTROYED, 0 crashes; BLH
un-regressed (also fires its ballistic weapon now). Diagnostics `BT_PROJ_LOG` (`[projectile] PUSH/IMPACT` +
per-weapon fire trace). **The energy weapons (Emitter/PPC) still damage via the mech4 beam path.** REMAINING
(deferred): the byte-exact world-entity Missile (`Projectile : Mover`, MP-replicable via `Registry::MakeEntity`)
— the port projectile is master-local only (no MP replication); the real per-weapon fire-rate/heat wiring off
the subsystem sim (the mech4 beam path is still the bring-up harness).
**✅ HEAT-FLOW LINKAGE CONNECTED — weapon heat now conducts to the central sink** (verified: `[heat]
conduct self.E=.. -> other.E`, sustained fire `FIRED #1..#21+`, combat intact, no crash). THREE fixes,
each faithful to the decomp (no stand-ins):
+1 -1
View File
@@ -168,7 +168,7 @@ badge=VGL
patch=Yellow
role=Role::Default
dropzone=one
vehicle=bhk1
vehicle=madcat
vehicleValue=1000
color=White
[largebitmap]
+3
View File
@@ -741,6 +741,9 @@ protected:
// our entries are Mech__DamageZone, populated by the Mech ctor). Defined in
// mech.cpp where Mech__DamageZone is a complete type.
Mech__DamageZone *Zone(int i) const;
// First VITAL damage zone index (0 if none) -- for concentrated fire that kills.
// Defined in mech4.cpp (Mech__DamageZone::vitalDamageZone is protected; Mech has access).
int FirstVitalZone() const;
int flags; // entity flags word (this+0x28 region)
int stance; // posture state
int ammoState; // @0x44c 0 none / 1 leaking / 2 dry
+123
View File
@@ -678,6 +678,125 @@ void
out = m->localOrigin.linearPosition; // safe non-garbage fallback (owner origin)
}
// First vital damage-zone index (Mech__DamageZone::vitalDamageZone is protected; Mech has access).
int
Mech::FirstVitalZone() const
{
for (int k = 0; k < damageZoneCount; ++k)
if (Zone(k)->vitalDamageZone)
return k;
return 0;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Port-side tracked-projectile service (WAVE 7 Phase B -- flying missiles/rounds).
// The 1995 Projectile/Missile WORLD-ENTITY classes cannot be revived byte-exact:
// the 2007 engine Entity base is 0x1BC bytes vs the 1995 binary's 0x300, so the
// reconstruction's raw base-offset reads (velocity@0x1dc, roster@0x124, motion@
// 0x250) read garbage on the engine (the same 0x638-vs-0x854 gap the Mech has,
// but the entity integrator depends on those offsets). So a fired projectile is
// a PORT reconstruction (like the beam renderer): seeded from the launcher's fire
// with the decomp's real muzzle / launch velocity / per-shot damage, it flies
// toward the target (tracer via BTPushBeam) and delivers the weapon's damage on
// impact through the SAME Entity::TakeDamage path as the beam. The byte-exact
// world-entity Missile (Projectile : Mover, MP-replicable) is the deeper follow-up.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
struct BTProjectile {
Point3D pos;
Vector3D dir;
Scalar speed;
Scalar traveled;
Scalar range;
Entity *target;
Point3D targetPos;
Scalar damage;
int active;
};
static BTProjectile gProjectiles[64];
extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, float);
// Called from ProjectileWeapon / MissileLauncher::FireWeapon (via the extern below)
// with the live muzzle, the owner's locked target entity + point, the launch speed
// (|muzzleVelocity|), and the weapon's per-shot damage.
void
BTPushProjectile(const Point3D &muzzle, void *target, const Point3D &targetPos,
Scalar speed, Scalar damage)
{
// Bring-up: the mech's own target slot (owner+0x388) isn't populated (the visible
// fire path targets the gEnemyMech global directly), so fall back to it here.
Point3D tpos = targetPos;
if ((target == 0 || tpos.x == 0.0f) && gEnemyMech != 0)
{
target = gEnemyMech;
tpos = ((Mech *)gEnemyMech)->localOrigin.linearPosition;
}
Vector3D d;
d.x = tpos.x - muzzle.x; d.y = tpos.y - muzzle.y; d.z = tpos.z - muzzle.z;
Scalar len = (Scalar)sqrtf(d.x*d.x + d.y*d.y + d.z*d.z);
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target << " enemy=" << (void*)gEnemyMech
<< " len=" << len << " speed=" << speed << " dmg=" << damage << "\n" << std::flush;
if (len < 0.001f) return;
for (int i = 0; i < 64; ++i)
{
if (gProjectiles[i].active) continue;
BTProjectile &p = gProjectiles[i];
p.pos = muzzle;
p.dir.x = d.x/len; p.dir.y = d.y/len; p.dir.z = d.z/len;
p.speed = (speed > 1.0f) ? speed : 120.0f; // |launchVelocity|; sane fallback
p.traveled = 0.0f;
p.range = len + 40.0f; // impact by the target, else expire just past it
p.target = (Entity *)target;
p.targetPos = tpos; // resolved target position (fallback-aware)
p.damage = damage;
p.active = 1;
return;
}
}
// Per-frame (viewpoint mech): advance each projectile, draw its tracer, deliver damage on impact.
static void
BTUpdateProjectiles(Scalar dt)
{
for (int i = 0; i < 64; ++i)
{
BTProjectile &p = gProjectiles[i];
if (!p.active) continue;
Point3D prev = p.pos;
Scalar step = p.speed * dt;
p.pos.x += p.dir.x*step; p.pos.y += p.dir.y*step; p.pos.z += p.dir.z*step;
p.traveled += step;
BTPushBeam(prev.x, prev.y, prev.z, p.pos.x, p.pos.y, p.pos.z, 0x00FFB030u, 0.18f, 1.4f); // warm tracer
Scalar dx = p.targetPos.x - p.pos.x, dy = p.targetPos.y - p.pos.y, dz = p.targetPos.z - p.pos.z;
if (dx*dx + dy*dy + dz*dz < (10.0f*10.0f) || p.traveled >= p.range)
{
Entity *tgt = p.target;
// Deliver only to the known-valid enemy (bring-up), same zone/damage path as the beam.
if (tgt != 0 && tgt == gEnemyMech && p.damage > 0.0f)
{
Mech *m = (Mech *)tgt;
if (m->damageZoneCount > 0)
{
int zone = m->FirstVitalZone(); // concentrated fire -> a kill
Damage dmg;
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = p.damage;
dmg.burstCount = 1;
dmg.impactPoint = p.pos;
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
0 /*inflictor id: bring-up*/, zone, dmg);
tgt->Dispatch(&take_damage);
DEBUG_STREAM << "[projectile] IMPACT damage=" << p.damage << " zone=" << zone << "\n" << std::flush;
}
}
p.active = 0;
}
}
}
void
Mech::PerformAndWatch(const Time& till, MemoryStream *update_stream)
{
@@ -694,6 +813,10 @@ void
const Logical isPlayerMech =
(application != 0 && (Entity *)this == application->GetViewpointEntity());
// WAVE 7 Phase B: advance/render/impact the flying projectiles once per frame (viewpoint).
if (isPlayerMech && dt > 0.0001f)
BTUpdateProjectiles(dt);
// 1-Hz non-player mech telemetry (multiplayer diagnosis): every mech that is
// NOT the local viewpoint -- whatever its instance claims -- with position.
if (!isPlayerMech && getenv("BT_REPL_LOG") && dt > 0.0001f)
+24 -11
View File
@@ -63,6 +63,13 @@
#if !defined(TESTBT_HPP)
# include <testbt.hpp>
#endif
#include <math.h>
// WAVE 7 Phase B -- the port-side flying-projectile service (defined in mech4.cpp, a
// complete-Mech TU with the render + damage path). A fired missile becomes a tracked
// projectile that flies to the target + delivers this weapon's damage on impact.
extern void BTPushProjectile(const Point3D &muzzle, void *target, const Point3D &targetPos,
Scalar speed, Scalar damage);
//###########################################################################
//###########################################################################
@@ -219,21 +226,27 @@ void MissileLauncher::FireWeapon()
// Begin the per-shot recharge cooldown (recoil bleeds to 0 before ReadyToFire).
recoil = rechargeRate;
// Resolve the muzzle / lead geometry (used to seed each missile's descriptor).
// Resolve the muzzle / lead geometry (seeds each flying missile).
Point3D muzzle;
GetMuzzlePoint(muzzle); // @004b9948
damageData.burstCount = missileCount; // @0x3d4 (was shadow "salvoCount")
// ⚠ SPAWN DEFERRED (WAVE 7 Phase B): the shipped code loops missileCount times
// building a 0xD4-byte Missile MakeMessage (muzzle transform + lead + the 12-word
// Damage descriptor + launchVelocity + owner/target) and calls Missile::New
// (@004bf8bc -> ctor @004bf5b4, allocs 0x368). The Missile ENTITY class is not
// spawn-ready (Entity-base phantom members overflow the 0x368 alloc; New() takes
// (int)this instead of the descriptor) -> a live spawn would corrupt the heap. The
// launcher SUBSYSTEM ticks authentically (charge/ammo/heat/recoil, damage split
// across missileCount) but does NOT launch until the entity work lands. See
// scratchpad/wave7_maps.txt (Missile map) + docs/SUBSYS_PLAN.md.
// for (int i = 0; i < missileCount; ++i) Missile::New(&descriptor); // <-- Phase B
// WAVE 7 Phase B: launch `missileCount` flying projectiles toward the owner's target.
// (Port reconstruction -- the byte-exact world-entity Missile is blocked by the 2007
// engine Entity base mismatch; see BTPushProjectile / mech4.cpp.) Speed = |launchVelocity|
// (resource MuzzleVelocity); per-missile damage = damageData.damageAmount (already
// divided by missileCount in the ctor).
char *o = (char *)owner; // inherited MechSubsystem::owner (the Mech)
void *target = (o != 0) ? *(void **)(o + 0x388) : 0; // owner's locked target entity (null in bring-up)
Point3D targetPos = (o != 0) ? *(Point3D *)(o + 0x37c) : muzzle; // owner target point
Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
+ launchVelocity.y*launchVelocity.y
+ launchVelocity.z*launchVelocity.z);
// Always launch -- BTPushProjectile falls back to gEnemyMech when the owner's target
// slot isn't populated (bring-up); missileCount missiles per salvo.
int nmiss = (missileCount > 0 && missileCount < 40) ? missileCount : 1;
for (int i = 0; i < nmiss; ++i)
BTPushProjectile(muzzle, target, targetPos, speed, damageData.damageAmount);
simulationFlags |= 0x1; // replication-dirty
Check_Fpu();
+53 -17
View File
@@ -85,6 +85,11 @@
#if !defined(TESTBT_HPP)
# include <testbt.hpp>
#endif
#include <math.h>
// WAVE 7 Phase B -- the port-side flying-projectile service (defined in mech4.cpp).
extern void BTPushProjectile(const Point3D &muzzle, void *target, const Point3D &targetPos,
Scalar speed, Scalar damage);
//#############################################################################
@@ -96,7 +101,15 @@ static const Scalar _DAT_004bc068 = 1.0f; // jam-chance clamp ceiling
static const Scalar _DAT_004bc678 = 1.0f; // muzzle-speed term (unresolved)
static const Scalar _DAT_004bc67c = 1.0f; // muzzle-speed term (unresolved)
static Scalar UniformRandom() { return 0.0f; } // FUN_00408050 (deterministic stand-in)
// FUN_00408050 -- uniform [0,1) random (jam roll). The old `return 0.0f` stub ALWAYS
// jammed (0 < any positive jam chance), so a projectile weapon could never fire. A real
// pseudo-random (LCG) restores authentic behavior: it fires ~(1-jamChance) of the time.
static Scalar UniformRandom()
{
static unsigned s = 0x12345678u;
s = s * 1103515245u + 12345u;
return (Scalar)((s >> 16) & 0x7fff) / 32768.0f;
}
static int ResolveTracerModel(void*) { return 0; } // explosion/tracer model handle
static int ResolveSubsystemName(const ResourceDirectories*, const char*) { return -1; } // FUN_004215b0
@@ -145,15 +158,19 @@ Logical
// bodies compile and are wired up there.
//
int
ProjectileWeapon::OwnerSubsystemCount(Mech * /*owner*/) const
ProjectileWeapon::OwnerSubsystemCount(Mech *owner) const
{
return 0;
// Real roster (binary reads owner->subsystemCount @0x124). The AmmoBin (0xBCB)
// constructs before the projectile weapons (0xBCD/0xBD0) in the factory loop, so
// its roster slot is populated by the time this weapon's ctor resolves it.
return (owner != 0) ? owner->GetSubsystemCount() : 0;
}
Subsystem *
ProjectileWeapon::OwnerSubsystem(Mech * /*owner*/, int /*index*/) const
ProjectileWeapon::OwnerSubsystem(Mech *owner, int index) const
{
return 0;
// Real roster lookup (binary: subsystemArray[index] @0x128).
return (owner != 0) ? owner->GetSubsystem(index) : 0;
}
Logical
@@ -227,7 +244,8 @@ ProjectileWeapon::ProjectileWeapon(
// NOT the resource flags -- the same authoritative-simulation gate the AmmoBin
// uses (owner&0xC==0 && owner&0x100). This is what arms the per-frame tick on
// the bring-up's authoritative mech.
if (((owner->simulationFlags & 0xC) == 0) && ((owner->simulationFlags & 0x100) != 0))
Logical installPerf = (((owner->simulationFlags & 0xC) == 0) && ((owner->simulationFlags & 0x100) != 0));
if (installPerf)
{
SetPerformance(&ProjectileWeapon::ProjectileWeaponSimulation); // member-ptr {0x4bbd04,..}
}
@@ -235,6 +253,9 @@ ProjectileWeapon::ProjectileWeapon(
{
simulationFlags |= 0x2; // this[10] |= 2 (mark "not simulated")
}
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projweap] CTOR name=" << GetName() << " ownerFlags="
<< owner->simulationFlags << " installPerf=" << (int)installPerf << "\n" << std::flush;
// Link to the AmmoBin subsystem referenced by the resource and copy the
// initial display fields out of it. (The Mech subsystem-roster lookup lives
@@ -502,8 +523,19 @@ void
UpdateEject(time_slice); // @004bbb50 -- idle unless ejectState==0
// 2. Rising-edge trigger from the owning Mech's discharge signal.
// Bring-up: the controls mapper is bypassed, so drive fireImpulse from the
// per-frame gBTWeaponTrigger (same as EmitterSimulation) -- else CheckFireEdge
// never sees an edge and a projectile weapon never fires.
extern int gBTWeaponTrigger;
fireImpulse = (Scalar)gBTWeaponTrigger; // this+0x31C
Logical trigger = CheckFireEdge(); // MechWeapon @004b9608
static int s_pwtick = 0;
if (getenv("BT_PROJ_LOG") && (((++s_pwtick) % 240) == 0 || trigger))
DEBUG_STREAM << "[projweap] tick " << GetName() << " trig=" << (int)trigger
<< " gTrig=" << gBTWeaponTrigger << " ready=" << (int)ReadyToFire()
<< " state=" << weaponAlarm.GetState() << " recoil=" << recoil << " x\n" << std::flush;
// 3. Firing state machine (weaponAlarm @0x350). Modelled on the RP analog
// RivetGun::RivetGunSimulation (WEAPSYS.cpp): the Jammed / NoAmmo latches
// persist and only escalate on a trigger pull; the default (ready) branch
@@ -559,26 +591,30 @@ void
// Pull a round; a dry / not-ready bin latches NoAmmo and aborts the shot.
if (!ConsumeRound()) // FUN_004bd4f4 (AmmoBin::FeedAmmo)
{
return;
}
// Begin the per-shot recharge cooldown (recoil bleeds back down to 0 in
// DrawFiringCharge before the weapon is ReadyToFire again).
recoil = rechargeRate; // this[0xfa] = this[0xf7]
// Resolve the live muzzle (used to seed the projectile descriptor).
// Resolve the live muzzle.
Point3D muzzle;
GetMuzzlePoint(muzzle); // MechWeapon @004b9948
// ⚠ SPAWN DEFERRED (WAVE 7 Phase B): the shipped code builds a ~0xD4-byte projectile
// MakeMessage here (muzzle transform + lead point + the 12-word Damage descriptor +
// owner/target) and hands it to Projectile::New (@004be384). The reconstructed
// Projectile/Missile ENTITY classes are NOT spawn-ready yet: they carry Entity-base
// phantom members that overflow their New() alloc (0x340/0x368), and New() currently
// takes (int)this instead of the descriptor -> a live spawn would corrupt the heap.
// So the weapon SUBSYSTEM ticks authentically (charge/ammo/heat/recoil) but does NOT
// spawn until the entity byte-exact base + descriptor build land. See scratchpad/
// wave7_maps.txt (Projectile/Missile maps) + docs/SUBSYS_PLAN.md.
// Projectile::New((int)this); // <-- Phase B
// WAVE 7 Phase B: launch one flying ballistic round toward the owner's target (port
// reconstruction; the byte-exact world-entity Projectile is blocked by the 2007 engine
// Entity base mismatch -- see BTPushProjectile / mech4.cpp). Speed = |launchVelocity|.
char *o = (char *)owner; // inherited MechSubsystem::owner (the Mech)
void *target = (o != 0) ? *(void **)(o + 0x388) : 0; // owner's locked target entity (null in bring-up)
Point3D targetPos = (o != 0) ? *(Point3D *)(o + 0x37c) : muzzle; // owner target point
Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
+ launchVelocity.y*launchVelocity.y
+ launchVelocity.z*launchVelocity.z);
// Always launch -- BTPushProjectile falls back to gEnemyMech when the owner's target
// slot isn't populated (bring-up).
BTPushProjectile(muzzle, target, targetPos, speed, damageData.damageAmount);
simulationFlags |= 0x1; // replication-dirty
Check_Fpu();