MP combat step 1 (target any peer) DONE; step 2 (cross-pod damage) localised (task #46)

STEP 1 -- target any peer mech: DONE + verified. A live-mech registry
(BTRegisterMech/BTDeregisterMech from the Mech ctor/dtor) collects
every mech -- player, dummy, and peer replicants. The boresight
world-pick walks BTGetTargetCandidates (all mechs != shooter, closest
ray hit) and the whole fire/damage block retargets from the solo
gEnemyMech to the picked hotTarget; missile/projectile validation
generalised via BTIsRegisteredMech. Verified one-box: instance A
enumerates B's mech as a live ReplicantInstance (20 zones, ownerID=3).
Solo un-regressed (pick->damage->kill clean, 28 hits + DESTROYED).

STEP 2 -- cross-pod damage: dispatch + engine reroute are CORRECT and
invoked with a valid owner, but the dispatched message doesn't reach
the master. Entity::Dispatch (ENTITY.cpp:244) reroutes a replicant's
msg via application->SendMessage(ownerID,...); BT_MP_FORCE_DMG proves A
dispatches at B's replicant with ownerID=3, yet B takes 0 STEP-6
damage. Corrects the KB's [T3] "Dispatch already reroutes": the reroute
FIRES; the break is downstream in the transport/delivery of a
dispatched entity-message (update records flow fine -- net-rx works).
Next MP task = that wire delivery. Diagnostics BT_MP_LOG /
BT_MP_FORCE_DMG retained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-09 10:13:39 -05:00
co-authored by Claude Fable 5
parent 48191d6fdf
commit a9c3e96f20
5 changed files with 233 additions and 119 deletions
+135 -96
View File
@@ -852,8 +852,11 @@ static void
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)
// Deliver to the projectile's target mech -- the launcher set p.target
// from the shooter's 0x388 slot (the picked victim; any peer mech in
// MP, task #46). A replicant target reroutes cross-pod via Dispatch.
extern int BTIsRegisteredMech(Entity *e);
if (tgt != 0 && BTIsRegisteredMech(tgt) && p.damage > 0.0f)
{
Mech *m = (Mech *)tgt;
if (m->damageZoneCount > 0)
@@ -2377,6 +2380,64 @@ void
{
extern int BTGetAimRay(float rx, float ry, float outStart[3], float outDir[3]);
extern Entity *gBTTerrainEntity; // captured by MakeEntityRenderables
extern int BTGetTargetCandidates(Entity *shooter, Entity **out, int maxOut);
Entity *cand[32];
const int nc = BTGetTargetCandidates((Entity *)this, cand, 32);
// Candidate diagnostics + the cross-pod PROOF hook run INDEPENDENT of
// the aim ray (they don't need screen geometry -- and in -net mode the
// aim projection may not be live, gap-map item 5).
static float s_candLog = 0.0f; s_candLog += dt;
const int candLog = (getenv("BT_MP_LOG") && s_candLog >= 1.0f);
if (candLog) s_candLog = 0.0f;
if (candLog)
for (int ci = 0; ci < nc; ++ci)
{
Mech *m = (Mech *)cand[ci];
if (m == 0) continue;
Point3D mp = m->localOrigin.linearPosition;
DEBUG_STREAM << "[mp-cand] " << (void*)m
<< " inst=" << (int)m->GetInstance()
<< " interest=" << m->interestCount
<< " dead=" << (int)m->IsMechDestroyed()
<< " pos=(" << mp.x << "," << mp.y << "," << mp.z << ")"
<< " zones=" << m->damageZoneCount << "\n" << std::flush;
}
// CROSS-POD DAMAGE PROOF HOOK (BT_MP_FORCE_DMG, task #46): once a
// second, dispatch a real TakeDamage at the first live REPLICANT
// candidate -- isolating the Dispatch-reroute-to-owning-master path
// from the MP interactive aim/drive plumbing.
if (getenv("BT_MP_FORCE_DMG"))
{
static float s_fd = 0.0f; s_fd += dt;
if (s_fd >= 1.0f)
{
s_fd = 0.0f;
for (int ci = 0; ci < nc; ++ci)
{
Mech *m = (Mech *)cand[ci];
if (m == 0 || m->GetInstance() != ReplicantInstance
|| m->IsMechDestroyed() || m->damageZoneCount <= 0)
continue;
Damage dmg;
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = kShotDamage;
dmg.burstCount = 1;
dmg.impactPoint = m->localOrigin.linearPosition;
Entity::TakeDamageMessage td(
Entity::TakeDamageMessageID,
sizeof(Entity::TakeDamageMessage),
GetEntityID(), -1, dmg);
((Entity *)m)->Dispatch(&td);
DEBUG_STREAM << "[mp-force] dispatched TakeDamage at replicant "
<< (void*)m << " ownerID=" << (long)m->GetOwnerID()
<< " (reroute -> master via SendMessage)\n" << std::flush;
break;
}
}
}
float rs[3], rd[3];
if (!BTGetAimRay(gBTAimX, gBTAimY, rs, rd))
{
@@ -2386,11 +2447,31 @@ void
{
Point3D rayStart(rs[0], rs[1], rs[2]);
Vector3D rayDir(rd[0], rd[1], rd[2]);
if (gEnemyMech != 0 && !((Mech *)gEnemyMech)->IsMechDestroyed()
&& ((Mech *)gEnemyMech)->PickRayHit(rayStart, rayDir, 4000.0f, &hotPoint))
// MP TARGETING (task #46): walk EVERY other living mech (the solo
// dummy AND every peer replicant, from the live-mech registry) and
// pick the CLOSEST one the boresight ray strikes -- generalising
// the solo gEnemyMech.
float bestDist = 1e30f;
for (int ci = 0; ci < nc; ++ci)
{
hotTarget = gEnemyMech;
pickTarget = gEnemyMech;
Mech *m = (Mech *)cand[ci];
if (m == 0 || m->IsMechDestroyed())
continue;
Point3D hp;
if (!m->PickRayHit(rayStart, rayDir, 4000.0f, &hp))
continue;
float dx = hp.x - rs[0], dy = hp.y - rs[1], dz = hp.z - rs[2];
float d = dx*dx + dy*dy + dz*dz;
if (d < bestDist)
{
bestDist = d;
hotTarget = cand[ci];
hotPoint = hp;
}
}
if (hotTarget != 0)
{
pickTarget = hotTarget;
pickPoint = hotPoint;
++gAimHits;
}
@@ -2540,27 +2621,25 @@ void
gBTMissileTrigger = (missileWanted && targetInArc) ? (gBTMissileTrigger ? 0 : 1) : 0;
}
if (gEnemyMech != 0)
{
Point3D enemyPos = ((Mech *)gEnemyMech)->localOrigin.linearPosition;
// The VICTIM under the boresight (task #46: any peer mech, not just
// the solo gEnemyMech). Range/log/fire all key off THIS mech.
Entity *victim = hotTarget;
Point3D victimPos = (victim != 0)
? ((Mech *)victim)->localOrigin.linearPosition
: localOrigin.linearPosition;
// damage routes ONLY when the boresight pick is the enemy mech
const int designated = (hotTarget != 0);
float ddx = enemyPos.x - localOrigin.linearPosition.x;
float ddy = enemyPos.y - localOrigin.linearPosition.y;
float ddz = enemyPos.z - localOrigin.linearPosition.z;
float ddx = victimPos.x - localOrigin.linearPosition.x;
float ddy = victimPos.y - localOrigin.linearPosition.y;
float ddz = victimPos.z - localOrigin.linearPosition.z;
float range = (float)Sqrt((double)(ddx*ddx + ddy*ddy + ddz*ddz));
// THE AUTHENTIC RANGE GATE (FireWeapon @004bace8 :7758 [T1]): damage
// applies when dist <= the weapon's EFFECTIVE range = (1 - host-zone
// damage) x its AUTHORED WeaponRange (BLH: lasers 500, missiles 800,
// PPCs 900 m -- the [hud] pip dump) -- the
// per-weapon targetWithinRange flag UpdateTargeting computes each
// frame. Any live weapon within reach lands the aggregate shot.
// (Replaces the old kWeaponRange=100 bring-up constant, which
// silently blanked damage between 100 and the real 500 -- lock at
// spawn range 120 dealt nothing until you closed in; user-reported.)
// PPCs 900 m -- the [hud] pip dump) -- the per-weapon targetWithinRange
// flag UpdateTargeting computes each frame. Any live weapon in reach
// lands the aggregate shot.
int anyWeaponInRange = 0;
for (int wi = 0; wi < GetSubsystemCount(); ++wi)
{
@@ -2579,29 +2658,21 @@ void
{
gTargetLogAccum = 0.0f;
DEBUG_STREAM << "[target] aim=(" << gBTAimX << "," << gBTAimY << ")"
<< (hotTarget != 0 ? " ENEMY under boresight (aimed)"
<< (victim != 0 ? " MECH under boresight (aimed)"
: (pickTarget != 0 ? " terrain downrange (beam at scenery)"
: " sky (no target, no discharge)"))
<< " range=" << range
<< (anyWeaponInRange ? " IN WEAPON RANGE" : "")
<< " [enemyPicks=" << gAimHits << " groundPicks=" << gAimGround
<< " [mechPicks=" << gAimHits << " groundPicks=" << gAimGround
<< " noRay=" << gAimNoRay << "]"
<< "\n" << std::flush;
gAimHits = 0; gAimNoRay = 0; gAimGround = 0;
}
// --- FIRING (bring-up): on the trigger, with a target in range and the
// weapon off cooldown, spawn an Explosion at the target. This is the
// real engine fire->effect->render chain (Explosion::Make + the
// engine's explosion renderable) standing in for the per-weapon beam
// (Emitter::FireWeapon) until the emitter subsystem + its beam
// renderable are reconstructed. The shot is gated exactly like the
// real weapon: HasActiveTarget (we just set mech+0x388) AND in range.
if (gFireCooldown > 0.0f)
gFireCooldown -= dt;
const int fireWanted = gBTDrive.fireForced || gBTDrive.fire;
// (the fire-channel pulses moved ABOVE the enemy block -- task #43a)
// Resolve the "explode" effect resource once.
if (gExplodeReady == 0)
@@ -2626,31 +2697,24 @@ void
}
}
if (fireWanted && designated && targetInArc && gFireCooldown <= 0.0f
&& anyWeaponInRange && gExplodeReady == 1) // authentic gate (computed above)
// FIRE + DAMAGE: only vs a MECH under the boresight (terrain/sky picks
// draw beams but deal no damage). Dispatch routes to the picked victim
// -- for a REPLICANT this is a cross-pod hit: Entity::Dispatch reroutes
// the TakeDamageMessage to the owning master over the wire (task #46).
if (fireWanted && victim != 0 && targetInArc && gFireCooldown <= 0.0f
&& anyWeaponInRange && gExplodeReady == 1)
{
gFireCooldown = kFireCooldown;
++gShotCount;
// Impact point (tasks #36/#41): damage happens ONLY when the enemy
// is under the boresight (designated == hotTarget != 0, checked in
// the gate above), so the shot always lands at the PICKED HULL
// POINT -- the STEP-6 cylinder lookup resolves damage to the zone
// under the boresight (aimed fire: leg shots hit legs, torso shots
// the torso). Terrain picks never reach here (beam + scenery
// impact only, no damage dispatch).
// Impact = the PICKED HULL POINT -> the STEP-6 cylinder lookup
// resolves the zone under the boresight (aimed fire).
Point3D impact = hotPoint;
Origin exp_origin = ((Mech *)gEnemyMech)->localOrigin;
Origin exp_origin = ((Mech *)victim)->localOrigin;
exp_origin.linearPosition = impact; // at the hit point
// IMPACT FRAME: the .PFX hit effects are authored with local -Z =
// "out of the struck armor" (DAFC offsets/velocities spray -Z).
// That is the IMPACT normal -- toward the SHOOTER -- not the
// victim's body front: with the victim's own quat a rear/side hit
// flashed on the FAR (front) side of the mech. Build the frame as
// a yaw with -Z aimed from the victim at the shooter. Engine yaw
// convention (MATRIX.cpp:196-209): forward -Z = (-sin y, 0, -cos y)
// -> yaw = atan2(ddx, ddz) points -Z at the shooter. [T0]
// IMPACT FRAME: -Z aimed from the victim at the shooter (the .PFX
// hit spray convention; yaw = atan2(ddx, ddz)). [T0]
exp_origin.angularPosition =
EulerAngles(0.0f, (Scalar)atan2((double)ddx, (double)ddz), 0.0f);
@@ -2662,7 +2726,7 @@ void
gExplodeRes,
Explosion::DefaultFlags,
exp_origin,
gEnemyMech->GetEntityID(), // entity hit
victim->GetEntityID(), // entity hit
GetEntityID()); // shooter (this mech)
Explosion *shot = Explosion::Make(&exp_message);
@@ -2672,34 +2736,20 @@ void
DEBUG_STREAM << "[fire] SHOT #" << gShotCount
<< " -> explosion at target (range=" << range << ")\n" << std::flush;
}
else
{
DEBUG_STREAM << "[fire] Explosion::Make returned NULL\n" << std::flush;
}
// --- DAMAGE (real, STEP 6): dispatch UNAIMED (zone == -1) with the
// beam's world entry point; Mech::TakeDamageMessageHandler resolves
// the zone from the cylinder hit-location table (the STEP-6
// reconstruction) and the base handler routes it to
// Mech__DamageZone::TakeDamage (the real armor/structure model).
// Hits therefore land on the EXTERIOR zone facing the shooter
// (arm/leg/torso -- zones with visible segments that wreck), and
// internal vitals die only through the authentic destruction
// cascade (RecurseSegmentTable / SendSubsystemDamage) -- no more
// invisible one-shot kills on the soft internal vital zone.
if (gEnemyMech->damageZoneCount > 0)
// beam's world entry point. Mech::TakeDamageMessageHandler
// resolves the zone from the cylinder table; the base handler
// routes it to Mech__DamageZone::TakeDamage. Dispatch on a
// REPLICANT is rerouted by Entity::Dispatch to the owning
// master over the wire -- the cross-pod damage path.
if (((Mech *)victim)->damageZoneCount > 0)
{
// (impact computed above -- shared with the hit-explosion origin)
Damage dmg; // default-constructed
// Explosive: the weapon effect is an Explosion (explosive), not an
// energy beam. Also the correct type to exercise the zone armour/
// structure/death model now -- EnergyDamageType(4) shorts attached
// generators via criticalSubsystems[]->plug, which needs the REAL
// subsystem roster (still RECON_SUBSYS stubs -> plug resolves null).
Damage dmg;
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = kShotDamage;
dmg.burstCount = 1;
dmg.impactPoint = impact; // world impact point
dmg.impactPoint = impact;
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID,
@@ -2707,37 +2757,26 @@ void
GetEntityID(), // inflicting = this (shooter)
-1, // UNAIMED -> receiver's cylinder resolves
dmg);
gEnemyMech->Dispatch(&take_damage);
victim->Dispatch(&take_damage);
// gauge scoring wave (Step 6): credit the local player for damage
// dealt -> SCORE climbs per hit (currentScore += tonnageRatio*award).
// No score for pounding the wreck (damage still applies -- zones
// clamp at 1.0, further segments wreck visibly).
if (!gEnemyDestroyed)
BTPostDamageScore(gEnemyMech, kShotDamage);
// SCORE the damage (skip once the victim is dead).
if (!((Mech *)victim)->IsMechDestroyed())
BTPostDamageScore(victim, kShotDamage);
// The handler wrote the resolved zone back into the message.
int zone = take_damage.damageZone;
if (zone >= 0 && zone < gEnemyMech->damageZoneCount)
if (zone >= 0 && zone < ((Mech *)victim)->damageZoneCount)
{
Scalar s = ((Mech *)gEnemyMech)->Zone(zone)->damageLevel; // [0,1], 1.0=destroyed
DEBUG_STREAM << "[damage] hit zone " << zone << "/"
<< gEnemyMech->damageZoneCount
<< " structure=" << s << "\n" << std::flush;
Scalar s = ((Mech *)victim)->Zone(zone)->damageLevel;
DEBUG_STREAM << "[damage] " << (void*)victim << " zone " << zone
<< "/" << ((Mech *)victim)->damageZoneCount
<< " structure=" << s
<< (((Mech *)victim)->GetInstance() == ReplicantInstance
? " (REPLICANT -> cross-pod)" : "")
<< "\n" << std::flush;
}
}
// DEATH detection + effects moved to the VICTIM's own death
// transition (UpdateDeathState, task #42): the killing blow can be
// a MISSILE landing frames after this block (or collision damage),
// and post-#41 the boresight pick skips a dead mech so this block
// never ran against it again -- the death chain silently skipped
// (internally dead, smoking, standing, invulnerable). The
// transition fires once for ANY kill source. (The
// BT_ENABLE_TEARDOWN cdb repro harness was removed with this
// block -- its findings are recorded in docs/HARD_PROBLEMS.md;
// re-add at the transition if the P5 work resumes. The wreck
// STAYS -- teardown = the P5 base-region crash.)
// DEATH effects fire at the VICTIM's own death transition
// (UpdateDeathState, task #42) -- MP-correct: the master runs it.
}
}
}