MP live-play wave: collision economy, missiles, radar transform, panel polarity, comm ticker

The interactive 2-node playtest wave -- every fix decomp-grounded and live-verified:

COLLISION ECONOMY (the ram one-shot): StaticBounce mutates worldLinearVelocity
per contact and ProcessCollisionList walks EVERY touched solid per frame; with
2007 terrain-as-solids the reflections compounded x4-x40 within one frame and a
walking bump one-shot a pristine mech for 112,375 pts (62-pt authentic economy).
Fix: frameEntryWorldVelocity restore per contact (damage always priced at the
real approach speed -- all the binary's physics ever saw); Mech::Reset zeroes
the mover motion (respawn = teleport); [collide-tx]/[mp-hdlr] telemetry.
Gotcha #16 (engine-facility drift class).

MISSILES: peer-visible salvos (the launcher record extension carries a salvo
counter + aim point; ForceUpdate actually enqueues it -- the dirty flag alone
never serialized), the authentic arc (authored MuzzleVelocity vector + the
Seeker's 200m/0.1/300 loft + gain-4 steering, decoded from @004beae4/@004bef78),
world-impact bursts (rounds detonate on cave geometry instead of phasing
through), contact-only damage (flight-cap expiry = fizzle, no more teleport
damage), live re-lead, and ballistic (unguided) shells for autocannons.
projweap's stale BTPushProjectile extern (the /FORCE signature trap, gotcha #6
corollary) crashed the Avatar's first AFC100 shot -- fixed + sweep rule recorded.

RADAR: two transcription bugs made the scope permanently empty -- FUN_0040b244
is the affine INVERSE (not a copy) and FUN_0040adec writes ONLY the 3x3 rotation
(never the translation); worldToView now Invert(view) built rotation-first.
CulturalIcons sorted out of the moving grid (the phantom red pips were map
props), visible-radius culls on all three draw passes, live pip verified at
|delta| x ppm px.  Gotcha #17 (verify the FUN_ body, not its call shape).

WEAPON PANELS (the frozen-dial hunt): the binary's *(subsystem+0x40) means
FAILED -- the recon's 'operating' name was backwards, inverting the destroyed-X
lamps, the panel look, the children enable and the ready-lamp gate (which had
NEVER executed).  Polarity chain corrected end-to-end (failedState, fed by real
damage saturation).  Root cause of the freezes: MFD page-mode gating -- the dev
composite shows ALL pages at once, so off-page dials legitimately stopped; under
BT_DEV_GAUGES the 15 page-plane bits stay active (the exclusive secondary trio
untouched).  The SEH gauge guard now names its kills; repaint-heal resets the
incremental arc after panel repaints; [panel]/[arc] probes added.

COMM/SCORE: MessageBoard LIVE (the engine already shipped the whole
Player__StatusMessage queue; wired the binary's one producer -- the kill branch,
victim's name, 6s -- plus the consumer bridge and a lazy source bind); MP DEATHS
counted via the observed-death tally (each node scores every pilot from locally
observed events, the same model as the KILLS credit) and the -2/-1 engine seed
clamped for display.

DEV UX: node-tagged window titles (-net port), gauge panel reworked (1320x480,
true 4:3 MFD cells, the portrait secondary UNROTATED upright, linear filtering,
BT_GAUGE_SCALE), fixed close spawns via BT_SPAWN_XZ, Boreas flies an Avatar
(first second-chassis live outing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-12 17:24:15 -05:00
co-authored by Claude Fable 5
parent dd27238ceb
commit bb795e2805
30 changed files with 1006 additions and 97 deletions
+210 -12
View File
@@ -764,13 +764,15 @@ int
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
struct BTProjectile {
Point3D pos;
Vector3D dir;
Scalar speed;
Vector3D vel; // world velocity (authored MuzzleVelocity, steered per frame)
Scalar speed; // |vel| held constant through the steer
Scalar traveled;
Scalar range;
Entity *target;
Point3D targetPos;
Scalar damage;
Scalar aimOffsetY; // vertical aim offset vs the target's origin (live re-lead)
Scalar damage; // <= 0 -> VISUAL-ONLY round (replicant-side salvo mirror)
int guided; // 1 = missile (seeker loft + steering); 0 = ballistic (straight)
int active;
};
static BTProjectile gProjectiles[64];
@@ -782,7 +784,7 @@ extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, fl
// locked target entity + point, the launch speed (|muzzleVelocity|), and per-shot damage.
void
BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, const Point3D &targetPos,
Scalar speed, Scalar damage)
Scalar speed, Scalar damage, const Vector3D *launch_velocity, int guided)
{
// The weapon's GetMuzzlePoint falls back to the mech ORIGIN (feet) when its mount
// segment doesn't resolve, so a projectile appeared to launch from the mech's feet.
@@ -824,7 +826,9 @@ void
// like the energy weapons. (The old gEnemyMech fallback pre-dated the
// acquisition and let missiles bypass it.)
Point3D tpos = targetPos;
if (target == 0)
// A VISUAL round (damage <= 0, the replicant-side salvo mirror) flies on the
// replicated aim POINT alone; a live round still requires the designation.
if (target == 0 && damage > 0.0f)
return;
Vector3D d;
d.x = tpos.x - mz.x; d.y = tpos.y - mz.y; d.z = tpos.z - mz.z;
@@ -838,13 +842,45 @@ void
if (gProjectiles[i].active) continue;
BTProjectile &p = gProjectiles[i];
p.pos = mz; // resolved launch port
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
// AUTHENTIC LAUNCH (missile-arc wave): the launcher's authored
// MuzzleVelocity is a VECTOR in the shooter's frame (typically
// up-tilted); rotate it into world by the shooter's pose so the round
// leaves the rack climbing -- the front half of the pod's missile arc.
// (The old code collapsed it to |v| along the straight line to the
// target: dead-flat flight.) Fall back to the straight line when no
// authored vector reaches us (non-missile callers).
int haveLaunch = 0;
if (launch_velocity != 0 && shooter != 0)
{
Mech *sm = (Mech *)shooter;
UnitVector ax, ay, az;
sm->localToWorld.GetFromAxis(X_Axis, &ax);
sm->localToWorld.GetFromAxis(Y_Axis, &ay);
sm->localToWorld.GetFromAxis(Z_Axis, &az);
p.vel.x = ax.x*launch_velocity->x + ay.x*launch_velocity->y + az.x*launch_velocity->z;
p.vel.y = ax.y*launch_velocity->x + ay.y*launch_velocity->y + az.y*launch_velocity->z;
p.vel.z = ax.z*launch_velocity->x + ay.z*launch_velocity->y + az.z*launch_velocity->z;
Scalar lv = (Scalar)sqrtf(p.vel.x*p.vel.x + p.vel.y*p.vel.y + p.vel.z*p.vel.z);
if (lv > 1.0f) { p.speed = lv; haveLaunch = 1; }
}
if (!haveLaunch)
{
p.vel.x = d.x/len*p.speed; p.vel.y = d.y/len*p.speed; p.vel.z = d.z/len*p.speed;
}
p.traveled = 0.0f;
p.range = len + 40.0f; // impact by the target, else expire just past it
p.range = len * 1.3f + 60.0f; // arc margin; expire past the target
p.target = (Entity *)target;
p.targetPos = tpos; // resolved target position (fallback-aware)
// live re-lead (authentic: the Seeker re-leads the MOVING target every
// slice): remember the aim's height above the target's origin so the
// per-frame refresh keeps striking the same body height.
extern int BTIsRegisteredMech(Entity *e);
p.aimOffsetY = (target != 0 && BTIsRegisteredMech((Entity *)target))
? (tpos.y - ((Mech *)target)->localOrigin.linearPosition.y) : 0.0f;
p.damage = damage;
p.guided = guided; // autocannon shells fly straight (no seeker in the binary's plain Projectile)
p.active = 1;
return;
}
@@ -859,23 +895,117 @@ static void
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;
// AUTHENTIC GUIDANCE (missile-arc wave; Seeker::LeadTarget @004beae4 +
// the @004bef78 steering [T1]). The seeker LOFTS the aim point while
// the round is far out -- beyond 200 m the aim rises by
// 0.1 x clamp(range-200, 0, 300) (up to +30 m above the target) -- so
// the missile climbs at range and dives as it closes: the pod's
// "gravity arc". Steering is the port shape of the binary's
// squared-error guidance: rotate the velocity toward the lofted aim at
// the decomp turn gain (MissileTurnGain = 4.0, _DAT_004bf5a4), speed
// held at the authored launch speed.
if (p.guided)
{
// LIVE RE-LEAD (authentic: Seeker::LeadTarget runs every slice on
// the MOVING target): refresh the aim from the target's current
// position, preserving the launch aim's body height.
extern int BTIsRegisteredMech(Entity *e);
if (p.target != 0 && BTIsRegisteredMech(p.target)
&& !((Mech *)p.target)->IsMechDestroyed())
{
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
p.targetPos.y += p.aimOffsetY;
}
Point3D aim = p.targetPos;
Scalar rx = aim.x - p.pos.x, ry = aim.y - p.pos.y, rz = aim.z - p.pos.z;
Scalar range = (Scalar)sqrtf(rx*rx + ry*ry + rz*rz);
if (range > 200.0f) // SeekerLeadMinRange @004bec18
{
Scalar lead = range - 200.0f;
if (lead > 300.0f) lead = 300.0f; // SeekerLeadMaxClamp @004bec24
aim.y += 0.1f * lead; // SeekerLeadCoef @004bec28
ry = aim.y - p.pos.y;
}
if (range > 0.001f)
{
Scalar inv = 1.0f / range;
Scalar cx = p.vel.x / p.speed, cy = p.vel.y / p.speed, cz = p.vel.z / p.speed;
// MissileTurnGain (4.0, _DAT_004bf5a4) far out; DOUBLED inside
// the no-loft radius so the dive converges onto the contact
// sphere (the port shape of the binary's squared-error terminal
// aggressiveness -- error^2 steering climbs steeply near boresight).
Scalar k = ((range < 200.0f) ? 8.0f : 4.0f) * dt;
cx += (rx*inv - cx) * k; cy += (ry*inv - cy) * k; cz += (rz*inv - cz) * k;
Scalar cl = (Scalar)sqrtf(cx*cx + cy*cy + cz*cz);
if (cl > 0.001f)
{
p.vel.x = cx/cl*p.speed; p.vel.y = cy/cl*p.speed; p.vel.z = cz/cl*p.speed;
}
}
}
p.pos.x += p.vel.x*dt; p.pos.y += p.vel.y*dt; p.pos.z += p.vel.z*dt;
p.traveled += p.speed * dt;
// WORLD IMPACT (authentic: the binary missile runs a world collision
// query every frame, FUN_0042291c, and DETONATES on geometry). Ray the
// flight step against the terrain/cave solids -- a lofted round in a
// low cavern bursts on the CEILING instead of punching through it.
{
extern bool BTGroundRayHit(float,float,float, float,float,float,
float, float*,float*,float*);
extern Entity *gBTTerrainEntity; // captured by MakeEntityRenderables
Scalar step = p.speed * dt;
if (gBTTerrainEntity != 0 && step > 0.0001f)
{
Vector3D rd;
rd.x = p.vel.x/p.speed; rd.y = p.vel.y/p.speed; rd.z = p.vel.z/p.speed;
float hx, hy, hz;
if (BTGroundRayHit(prev.x, prev.y, prev.z, rd.x, rd.y, rd.z,
step + 1.0f, &hx, &hy, &hz))
{
// burst on the rock: a tight puff cluster at the hit, no damage
extern void BTPfxTrailPuff(int, float, float, float, float, float, float, int);
for (int pf = 0; pf < 4; ++pf)
BTPfxTrailPuff(0, hx, hy, hz, -rd.x, -rd.y, -rd.z, 2);
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] WORLD burst at(" << hx << ","
<< hy << "," << hz << ")\n" << std::flush;
p.active = 0;
continue;
}
}
}
// AUTHENTIC missile look: the round is a short hot streak, and the trail
// is the real dsrm smoke-trail effect (psfx 0, "the lrm smoke trail")
// puffed along the flight path each frame with local +Z = backward (the
// .PFX's velocities stream the smoke behind the round). This replaces
// the old 3-segment white tracer lines (a bring-up placeholder).
Vector3D bd = p.dir;
Vector3D bd;
bd.x = p.vel.x/p.speed; bd.y = p.vel.y/p.speed; bd.z = p.vel.z/p.speed;
BTPushBeam(prev.x, prev.y, prev.z, p.pos.x, p.pos.y, p.pos.z, 0x00FFB040u, 0.10f, 0.9f); // the round (hot streak)
{
extern void BTPfxTrailPuff(int, float, float, float, float, float, float, int);
BTPfxTrailPuff(0, prev.x, prev.y, prev.z, -bd.x, -bd.y, -bd.z, 2);
}
// CONTACT-ONLY damage (user-verified bug: an arcing round that ran out
// its flight cap while still descending "applied damage without making
// contact"). Proximity = the hit; the flight-cap expiry is a FIZZLE --
// no damage, matching the binary (a missile that dies mid-air detonates
// nothing; only the world-collision hit spawns the Damage entity).
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)
const int contact = (dx*dx + dy*dy + dz*dz < (10.0f*10.0f));
if (!contact && p.traveled >= p.range)
{
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] FIZZLE (flight cap, no contact)\n" << std::flush;
p.active = 0;
continue;
}
if (contact)
{
Entity *tgt = p.target;
// Deliver to the projectile's target mech -- the launcher set p.target
@@ -1053,6 +1183,12 @@ void
projectedVelocity = Motion::Identity;
updateVelocity = Motion::Identity;
updateAcceleration = Motion::Identity;
// The Mover motion scalars too (the binary Reset zeroes the whole motion
// block): a respawn is a TELEPORT -- stale death-frame velocity must never
// survive into the first post-respawn collision/publish.
worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f);
localVelocity = Motion::Identity;
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f);
// --- our RELOCATED gait/motion accumulators -> identity (the 1995 offsets
// the decomp zeroes map to these named members in our layout) ---
@@ -1281,6 +1417,21 @@ void
DEBUG_STREAM << "[damage] *** " << GetEntityID()
<< " DESTROYED (death effects dispatched from the death transition) ***\n"
<< std::flush;
// OBSERVED-DEATH tally (MP DEATHS fix): a REPLICANT's death observed
// here counts onto its owning player's LOCAL score copy -- the
// symmetric half of the cross-pod KILLS credit (see btplayer.cpp
// BTPlayerCountObservedDeath). Masters count through their own
// VehicleDead(-1) path; counting them here too would double-tally.
if (GetInstance() == ReplicantInstance)
{
Player *owning_player = GetOwningPlayer(); // entity+0x190
if (owning_player != 0)
{
extern void BTPlayerCountObservedDeath(void *);
BTPlayerCountObservedDeath(owning_player);
}
}
}
//
@@ -1398,6 +1549,20 @@ void
const float fdot = -((float)wv.x * (float)zAxR.x
+ (float)wv.z * (float)zAxR.z); // mech faces -Z
replMppr->speedDemand = (fdot < 0.0f) ? -spd : spd;
// REPLICANT TURN-STEP (user-reported: a turning peer
// statue-rotates while the local mech steps): the leg SM's
// Standing case arms the turn-in-place "trn" clip (state 4,
// mech2.cpp:590) from mppr->turnDemand -- which nothing fed on
// a replicant, so pivots never stepped. Feed it from the
// REPLICATED yaw rate (updateVelocity.angularMotion.y, stored
// by Mover::ReadUpdateRecord from the master's localVelocity
// -- the same stream DeadReckon rotates the body with). Only
// the +-0.05 threshold matters to the SM (the trn clip
// advances at fixed cadence), so a signed unit demand
// suffices; the deadband ignores dead-reckon jitter.
const float yawRate = (float)updateVelocity.angularMotion.y;
replMppr->turnDemand = (yawRate > 0.02f) ? 1.0f
: (yawRate < -0.02f) ? -1.0f : 0.0f;
// Prime the same clip-advance scalars the master's gait block sets
// each frame -- uninitialized on a replicant they read 0, freezing
// the clip at advance-time dt*0 (observed: legState engaged at 11,
@@ -2406,6 +2571,7 @@ void
if (cols)
{
Point3D before = localOrigin.linearPosition;
frameEntryWorldVelocity = worldLinearVelocity; // collision-damage guard (see mech.hpp)
Damage collisionDamage; // filled by ProcessCollisionList (unused)
ProcessCollisionList(cols, dt, collisionOldPos, &collisionDamage); // pushes localOrigin out
Scalar dx = localOrigin.linearPosition.x - before.x;
@@ -3621,6 +3787,12 @@ void
if (isPlayer && gBlockCooldown > 0.0f)
gBlockCooldown -= dt; // decay the out-of-contact window
// BINARY-TAIL-DEFERRED: collisionTemporaryState = 0 here (@4aa741).
// COLLISION-DAMAGE ECONOMY GUARD: snapshot the TRUE frame motion for the
// per-contact velocity restore in Mech::ProcessCollision (see mech.hpp) --
// the list walk below reflects worldLinearVelocity at every StaticBounce,
// and a multi-solid frame compounds those reflections into a garbage
// velocity that priced mech-vs-mech damage 4x-40x too high (mp_a.log:32651).
frameEntryWorldVelocity = savedWorldVel;
Damage dmg; // ctor zeroes damageAmount
if (cols != 0)
ProcessCollisionList(cols, dt, old_position, &dmg);
@@ -3823,6 +3995,20 @@ static void
DEBUG_STREAM << "[collide] dmg=" << resolved->damageAmount
<< " -> victim class=" << (int)victim->GetClassID()
<< " (zone==-1, resolved by receiver)\n" << std::flush;
// RAM ECONOMY TELEMETRY (the MP ram one-shot investigation): every
// dispatched ram, with the exact velocity StaticBounce priced it from --
// the decisive evidence channel for any future out-of-economy amount.
if (getenv("BT_MP_NET"))
{
const Vector3D &v = inflictor->GetWorldLinearVelocity();
DEBUG_STREAM << "[collide-tx] " << inflictor->GetEntityID()
<< " rams " << victim->GetEntityID()
<< " dmg=" << resolved->damageAmount
<< " |v|=" << (Scalar)sqrtf(v.x*v.x + v.y*v.y + v.z*v.z)
<< " at(" << dmg.impactPoint.x << "," << dmg.impactPoint.y
<< "," << dmg.impactPoint.z << ")\n" << std::flush;
}
}
void
@@ -3838,6 +4024,18 @@ void
return;
}
// COLLISION-DAMAGE ECONOMY GUARD (see mech.hpp frameEntryWorldVelocity):
// this runs once PER CONTACT in the frame's ProcessCollisionList walk, and
// the StaticBounce below reflects worldLinearVelocity (+= delta_v) every
// time. Restore the TRUE frame-entry motion first so a multi-solid frame
// (mech + terrain solids + debris) cannot compound the reflections into an
// amplified velocity: every contact -- and every dispatched mech-vs-mech
// damage amount -- is priced at the mech's real approach speed, which is
// all the 1995 binary's StaticBounce ever saw (its ground was a heightfield
// probe, never a collision-list entry). The post-list velocity is
// overwritten by the response policy / next frame's derive regardless.
worldLinearVelocity = frameEntryWorldVelocity;
// --- the BoxedSolid resolver (:15302-15304); miss => nothing runs -------
Scalar penetration = 0.0f;
if (!collisionVolume->ProcessCollision(collision, worldLinearVelocity,