Combat: FIX missile splash over-application -- once per salvo, not per round (task #62)
Live regression: a clustered bystander died in ~2 missile salvos ("suddenly
lethal"). Root cause, not authentic: the arcade fires ONE cluster Missile per
trigger (burstCount=missileCount) doing ONE SplashDamage event with baseBurst =
missileCount, floored at 1 ONCE. The port re-expresses that cluster as N flying
BTProjectile rounds, and task #62 fired splash PER ROUND -- each baseBurst=1,
each floored at 1 -- so the distance floor was applied N times = ~missileCount x
too much splash.
Fix: BTProjectile.splashBurst tags ONLY the salvo-lead round. MissileLauncher::
FireWeapon passes nmiss (the cluster count) on i==0 and 0 on every other round;
the contact + world-impact splash hooks fire only when splashBurst>0, using it as
baseBurst. One splash event per salvo with baseBurst=missileCount -- matches the
single arcade cluster missile. Replicant mirror rounds carry 0 (damage 0 too) so
the master's cross-pod splash isn't doubled. AC unaffected (not a MissileLauncher;
splash_burst defaults 0).
Verified headless (BT_SPAWN_ENEMY=2 clustered rig, 45s): bystander now takes 6
splash events (1/salvo, baseBurst=6) and SURVIVES (was ~2 shots); primary dies to
direct hits in ~3 trigger pulls (36 missiles == single-enemy TTK); AC does not
splash; no crash. Also: BT_SPAWN_ENEMY read as a COUNT (=2 clusters a bystander
within SplashRadius) for eyeballing splash; entity-id logging on [enemy]/[splash].
KB: combat-damage.md documents the N-round cluster trap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
12fbc023a8
commit
145a69f865
@@ -469,9 +469,24 @@ resolves launcher → `BTWeaponAmmoBin` → `BTAmmoRoundModelResource` (+0x1e8)
|
||||
`BTApplySplashDamage` walks `BTGetTargetCandidates` (excludes shooter), skips the direct victim +
|
||||
destroyed/zoneless mechs, applies the `dist>radius` gate + the burst falloff, delivers through the
|
||||
shooter's msgmgr (cross-pod) or `Dispatch`. Hooked at BOTH detonation paths (world-impact + contact).
|
||||
**Verified:** radius resolves to 30 for missiles / 0 for AC+Emitters; near-miss dist=15 → 1 burst
|
||||
delivered to a bystander; live missiles detonate + exclude the direct victim; AC does not splash; no
|
||||
crash. Env `BT_SPLASH_LOG`, `BT_SPLASH_TEST` (synthetic near-miss), `BT_AF_MISSILE` (missile autofire).
|
||||
|
||||
**⚠ THE SALVO-LEAD FIX (task #62 bug, found 2026-07-13 by live regression) — the N-round trap.**
|
||||
The arcade fires ONE cluster Missile per trigger (burstCount=missileCount) → ONE SplashDamage event
|
||||
with `baseBurst = missileCount`, floored at 1 ONCE. The port re-expresses that cluster as N flying
|
||||
`BTProjectile` rounds (visual tracers). Firing splash PER ROUND (each `baseBurst=1`, each floored at
|
||||
1) applied the floor N times = **~`missileCount`× too much splash** — a clustered bystander died in
|
||||
~2 salvos (user-reported "missiles suddenly lethal"; direct hits were fine — a single enemy survived
|
||||
24). Fix: `BTProjectile.splashBurst` tags ONLY the salvo-lead round (`FireWeapon` passes `nmiss` on
|
||||
`i==0`, `0` on the rest); splash fires once per salvo with `baseBurst = missileCount`. Replicant
|
||||
mirror rounds carry `0`/damage-0 (master delivers splash cross-pod — no double). **Lesson:** a cluster
|
||||
weapon re-expressed as N rounds must apply per-CLUSTER effects (splash, and note the direct hit's
|
||||
zone-concentration is likewise SPREAD in the port vs the arcade's single-zone cluster — a separate,
|
||||
pre-existing under-concentration) once per salvo, not once per round.
|
||||
**Verified:** radius resolves to 30 for missiles / 0 for AC+Emitters; near-miss dist=15 → 1 burst;
|
||||
live rig (2 clustered mechs, 45s): bystander takes 6 splash events (1/salvo, `baseBurst=6`) and
|
||||
SURVIVES (was ~2 shots pre-fix); primary dies to direct hits in ~3 trigger pulls (36 missiles, ==
|
||||
single-enemy TTK); AC does not splash; no crash. Env `BT_SPLASH_LOG`, `BT_SPLASH_TEST` (synthetic
|
||||
near-miss), `BT_AF_MISSILE` (missile autofire), `BT_SPAWN_ENEMY=2` (clustered bystander rig).
|
||||
**Deferred [T3]:** the per-player enable sub-gate `Missile+0x360 = BTPlayer+0x264` (part_013.c:8757) —
|
||||
its decomp writers (4668/10512) read as a per-frame state toggle, not a clean config flag, and the
|
||||
port's `showDamageInflicted` label is itself a guess; port treats authored `SplashRadius>0` as the
|
||||
|
||||
@@ -937,31 +937,60 @@ void
|
||||
if (enemy_res)
|
||||
{
|
||||
enemy_res->Lock();
|
||||
const ResourceDescription::ResourceID enemyResID = enemy_res->resourceID;
|
||||
|
||||
// Place it ahead of the player's drop ALONG THE SPAWN FACING (the mech
|
||||
// COUNT: BT_SPAWN_ENEMY is read as a count -- "1" (or any truthy value)
|
||||
// drops one dummy; "2"+ CLUSTERS extra dummies laterally so a bystander
|
||||
// sits within a missile's SplashRadius (30) of the primary -- the rig for
|
||||
// eyeballing splash damage (task #62): shoot ONE, watch the OTHER take
|
||||
// collateral. Clamped to a sane range.
|
||||
int enemyCount = atoi(getenv("BT_SPAWN_ENEMY"));
|
||||
if (enemyCount < 1) enemyCount = 1; // non-numeric truthy -> 1
|
||||
if (enemyCount > 8) enemyCount = 8;
|
||||
|
||||
// Base origin: 120 ahead of the player ALONG THE SPAWN FACING (the mech
|
||||
// faces local -Z; rotate that axis into world by the spawn orientation).
|
||||
// The old fixed "z -= 120" assumed the bring-up drive's forced heading=0;
|
||||
// the real-controls drive keeps the authentic spawn orientation, so the
|
||||
// dummy must follow the actual facing or the auto-walk leaves it behind.
|
||||
Origin enemy_origin = mech_location;
|
||||
Origin base_origin = mech_location;
|
||||
UnitVector spawnZ;
|
||||
{
|
||||
AffineMatrix facing;
|
||||
facing.BuildIdentity();
|
||||
facing = mech_location.angularPosition; // rotation from the spawn pose
|
||||
UnitVector spawnZ;
|
||||
facing.GetFromAxis(Z_Axis, &spawnZ); // local Z basis in world
|
||||
enemy_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z
|
||||
enemy_origin.linearPosition.y -= spawnZ.y * 120.0f;
|
||||
enemy_origin.linearPosition.z -= spawnZ.z * 120.0f;
|
||||
base_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z
|
||||
base_origin.linearPosition.y -= spawnZ.y * 120.0f;
|
||||
base_origin.linearPosition.z -= spawnZ.z * 120.0f;
|
||||
// FACE THE PLAYER: copying the spawn pose left BOTH mechs facing
|
||||
// the same way (the player stared at the enemy's back). Flip the
|
||||
// enemy's yaw 180 deg about Y so the two face each other. Engine
|
||||
// yaw convention (MATRIX.cpp:196-209): the Z basis = (sin y, 0,
|
||||
// cos y) -> yaw = atan2(z.x, z.z); enemy yaw = that + pi.
|
||||
Scalar spawnYaw = (Scalar)atan2((double)spawnZ.x, (double)spawnZ.z);
|
||||
enemy_origin.angularPosition =
|
||||
base_origin.angularPosition =
|
||||
EulerAngles(0.0f, spawnYaw + 3.14159265f, 0.0f);
|
||||
}
|
||||
enemy_res->Unlock();
|
||||
|
||||
// Right vector in the ground plane (perp to the forward XZ): cluster the
|
||||
// extra dummies sideways so a hit on the primary catches the bystander.
|
||||
Vector3D rightv(spawnZ.z, 0.0f, -spawnZ.x);
|
||||
{
|
||||
Scalar rl = (Scalar)sqrt((double)(rightv.x*rightv.x + rightv.z*rightv.z));
|
||||
if (rl < 1e-4f) { rightv.x = 1.0f; rightv.z = 0.0f; }
|
||||
else { rightv.x /= rl; rightv.z /= rl; }
|
||||
}
|
||||
|
||||
for (int ei = 0; ei < enemyCount; ++ei)
|
||||
{
|
||||
Origin enemy_origin = base_origin;
|
||||
// #0 at the primary spot; extras step +/-25u (inside SplashRadius 30).
|
||||
Scalar off = (ei == 0) ? 0.0f
|
||||
: 25.0f * (Scalar)(((ei + 1) / 2)) * ((ei & 1) ? 1.0f : -1.0f);
|
||||
enemy_origin.linearPosition.x += rightv.x * off;
|
||||
enemy_origin.linearPosition.z += rightv.z * off;
|
||||
|
||||
Mech::MakeMessage
|
||||
create_enemy(
|
||||
@@ -970,7 +999,7 @@ void
|
||||
host_manager->MakeUniqueEntityID(),
|
||||
(Entity::ClassID)Mech::MechClassID,
|
||||
EntityID::Null,
|
||||
enemy_res->resourceID,
|
||||
enemyResID,
|
||||
Mech::DefaultFlags,
|
||||
enemy_origin,
|
||||
Motion::Identity,
|
||||
@@ -979,8 +1008,6 @@ void
|
||||
playerMission->GetColorName(),
|
||||
((BTMission *)playerMission)->GetPatchName());
|
||||
|
||||
enemy_res->Unlock();
|
||||
|
||||
Mech *enemy = Mech::Make(&create_enemy);
|
||||
if (enemy)
|
||||
{
|
||||
@@ -1001,15 +1028,20 @@ void
|
||||
// (btl4app.cpp:591); this dummy is a master too, so start its own
|
||||
// -- without it the authentic ground block skips its collision half.
|
||||
enemy->StartCollisionAssistant();
|
||||
gEnemyMech = enemy; // the player's targeting step locks onto this
|
||||
DEBUG_STREAM << "[enemy] spawned target mech at ("
|
||||
if (ei == 0)
|
||||
gEnemyMech = enemy; // the player's targeting step locks onto the primary
|
||||
DEBUG_STREAM << "[enemy] spawned dummy #" << ei
|
||||
<< " id=" << enemy->GetEntityID()
|
||||
<< (ei == 0 ? " (PRIMARY/direct-target)" : " (bystander/splash)")
|
||||
<< " at ("
|
||||
<< enemy_origin.linearPosition.x << ", "
|
||||
<< enemy_origin.linearPosition.y << ", "
|
||||
<< enemy_origin.linearPosition.z << ")\n" << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
DEBUG_STREAM << "[enemy] Mech::Make returned NULL\n" << std::flush;
|
||||
DEBUG_STREAM << "[enemy] Mech::Make returned NULL (#" << ei << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
@@ -783,6 +783,12 @@ struct BTProjectile {
|
||||
int weaponSubsys; // firing weapon's roster index (-1 = unthreaded) --
|
||||
// resolves the weapon's ExplosionModelFile (mslhit/
|
||||
// acanhit) in the messmgr, task #7 bundling
|
||||
int splashBurst; // SALVO-LEAD cluster count for splash (task #62 fix):
|
||||
// >0 ONLY on the first round of a missile salvo -- the
|
||||
// baseBurst for ONE splash event = the whole cluster
|
||||
// (matches the ONE arcade cluster missile). 0 on every
|
||||
// other round (a straight tracer / non-lead volley round)
|
||||
// so splash is NOT re-applied + floored-at-1 per round.
|
||||
int active;
|
||||
};
|
||||
static BTProjectile gProjectiles[64];
|
||||
@@ -1061,9 +1067,10 @@ void
|
||||
e->Dispatch(&td);
|
||||
}
|
||||
if (s_log)
|
||||
DEBUG_STREAM << "[splash] victim=" << (void *)e << " dist=" << dist
|
||||
DEBUG_STREAM << "[splash] victim=" << e->GetEntityID() << " dist=" << dist
|
||||
<< " radius=" << radius << " bursts=" << bursts
|
||||
<< " amount=" << dmg.damageAmount << "\n" << std::flush;
|
||||
<< " amount=" << dmg.damageAmount << " baseBurst=" << (int)baseBurst
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1073,7 +1080,7 @@ void
|
||||
void
|
||||
BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, const Point3D &targetPos,
|
||||
Scalar speed, Scalar damage, const Vector3D *launch_velocity, int guided,
|
||||
int weapon_subsys)
|
||||
int weapon_subsys, int splash_burst)
|
||||
{
|
||||
// MUZZLE (muzzle wave, 2026-07-12): the passed muzzle is now the weapon's
|
||||
// AUTHENTIC mount segment (GetMuzzlePoint reads the real segmentIndex --
|
||||
@@ -1170,6 +1177,7 @@ void
|
||||
p.guided = guided; // autocannon shells fly straight (no seeker in the binary's plain Projectile)
|
||||
p.shooter = (Entity *)shooter;
|
||||
p.weaponSubsys = weapon_subsys;
|
||||
p.splashBurst = splash_burst; // >0 only on a salvo-lead round
|
||||
|
||||
// RACK-TUBE SPREAD [T3, physically grounded]: the binary's Missile
|
||||
// entities each launch from their own rack tube (per-tube authored
|
||||
@@ -1300,14 +1308,14 @@ static void
|
||||
// inside the collision branch @part_013.c:10045, which fires
|
||||
// for a world hit as well as a mover hit). A missile that
|
||||
// bursts on terrain near a mech still catches it in the blast.
|
||||
// No direct victim here, and no-op for non-missile rounds
|
||||
// (BTApplySplashDamage returns radius 0 for the AC / p.damage 0).
|
||||
if (p.damage > 0.0f)
|
||||
// ONCE per salvo (splashBurst>0), baseBurst = the cluster count;
|
||||
// no direct victim here. No-op for non-missile rounds.
|
||||
if (p.damage > 0.0f && p.splashBurst > 0)
|
||||
{
|
||||
Damage sdmg;
|
||||
sdmg.damageType = Damage::ExplosiveDamageType;
|
||||
sdmg.damageAmount = p.damage;
|
||||
sdmg.burstCount = 1;
|
||||
sdmg.burstCount = p.splashBurst; // cluster count as baseBurst
|
||||
sdmg.impactPoint = hp;
|
||||
extern void BTApplySplashDamage(Entity *, int, const Point3D &,
|
||||
Entity *, const Damage &);
|
||||
@@ -1407,13 +1415,21 @@ static void
|
||||
<< (mgr ? " (msgmgr bundled)" : " (direct)")
|
||||
<< " (zone cyl-resolved)\n" << std::flush;
|
||||
|
||||
// SPLASH (task #62): a detonating round with an authored
|
||||
// SplashRadius also damages every OTHER mech in the blast
|
||||
// (tgt excluded -- it took the direct hit above). No-op for
|
||||
// weapons with no splash radius (lasers never reach here).
|
||||
// SPLASH (task #62): a detonating missile SALVO damages every
|
||||
// OTHER mech in the blast (tgt excluded -- it took the direct hit
|
||||
// above). Fires ONCE per salvo, on the salvo-LEAD round only
|
||||
// (splashBurst>0), with baseBurst = the whole cluster count -- the
|
||||
// ONE arcade cluster missile. The other visual rounds of the
|
||||
// salvo carry splashBurst=0 so the falloff floor-at-1 is applied
|
||||
// once, not N times (the missileCount-x over-splash bug).
|
||||
if (p.splashBurst > 0)
|
||||
{
|
||||
Damage sdmg = dmg;
|
||||
sdmg.burstCount = p.splashBurst; // cluster count as baseBurst
|
||||
extern void BTApplySplashDamage(Entity *, int, const Point3D &,
|
||||
Entity *, const Damage &);
|
||||
BTApplySplashDamage(p.shooter, p.weaponSubsys, p.pos, tgt, dmg);
|
||||
BTApplySplashDamage(p.shooter, p.weaponSubsys, p.pos, tgt, sdmg);
|
||||
}
|
||||
}
|
||||
}
|
||||
p.active = 0;
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
|
||||
const Point3D &targetPos, Scalar speed, Scalar damage,
|
||||
const Vector3D *launch_velocity = 0, int guided = 1,
|
||||
int weapon_subsys = -1);
|
||||
int weapon_subsys = -1, int splash_burst = 0);
|
||||
|
||||
//###########################################################################
|
||||
// Port-side salvo replication state (missile-visibility wave).
|
||||
@@ -305,9 +305,15 @@ void MissileLauncher::FireWeapon()
|
||||
// on the authored up-tilt, then the seeker loft + steering arc it onto the target.
|
||||
int nmiss = (missileCount > 0 && missileCount < 40) ? missileCount : 1;
|
||||
for (int i = 0; i < nmiss; ++i)
|
||||
// SPLASH (task #62 fix): the whole salvo re-expresses ONE arcade cluster
|
||||
// missile, which does ONE SplashDamage event with baseBurst = the cluster
|
||||
// count. Tag ONLY the first round as the salvo lead (splash_burst = nmiss);
|
||||
// the rest carry 0 so splash fires once per salvo, not once per visual round
|
||||
// (which floored-at-1 N times = ~missileCount-x over-splash).
|
||||
BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount,
|
||||
&launchVelocity, 1,
|
||||
subsystemID /*messmgr explosion bundling at impact (task #7)*/);
|
||||
subsystemID /*messmgr explosion bundling at impact (task #7)*/,
|
||||
(i == 0) ? nmiss : 0 /*salvo-lead cluster splash*/);
|
||||
|
||||
// Salvo replication (missile-visibility wave): bump the fire counter + stamp
|
||||
// the aim point; the extended update record carries both to peer nodes.
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
|
||||
const Point3D &targetPos, Scalar speed, Scalar damage,
|
||||
const Vector3D *launch_velocity = 0, int guided = 1,
|
||||
int weapon_subsys = -1);
|
||||
int weapon_subsys = -1, int splash_burst = 0); // AC: no cluster splash (default 0)
|
||||
|
||||
|
||||
//#############################################################################
|
||||
|
||||
Reference in New Issue
Block a user