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
+60 -13
View File
@@ -1310,7 +1310,7 @@ SubsystemCluster::SubsystemCluster(
*(int *)((char *)subsystem_in + 0x150) /*third*/, 3 /*frames*/,
(Scalar *)coolantLeak, "LeakGauge"); // BEST-EFFORT raw (subsys+0x150)
operating = False; // @0xC4 this[0x31]
failedState = False; // @0xC4 this[0x31] (1 = destroyed)
subsystem = subsystem_in; // @0xC0 this[0x30]
previousDrawState = 0; // @0xC8 this[0x32]
}
@@ -1362,10 +1362,12 @@ void SubsystemCluster::DrawPanelFrame(int color)
//
void SubsystemCluster::SetChildrenEnable(int enable)
{
// FUN_00444650 forwards visibility; enable==0 (offline) => Disable(True).
if (temperatureBar) temperatureBar->Disable(enable == 0);
if (coolingLoopA) coolingLoopA->Disable(enable == 0);
if (powerSourceA) powerSourceA->Disable(enable == 0);
// POLARITY (PPC-dial fix, take 3): the passed value is the DRAW STATE --
// 0 = alive (children run), nonzero = dead panel (children off). The old
// reading (`enable==0 => Disable`) had it backwards.
if (temperatureBar) temperatureBar->Disable(enable != 0);
if (coolingLoopA) coolingLoopA->Disable(enable != 0);
if (powerSourceA) powerSourceA->Disable(enable != 0);
}
//
@@ -1374,11 +1376,13 @@ void SubsystemCluster::SetChildrenEnable(int enable)
//
int SubsystemCluster::GetDrawState()
{
if (operating != 0)
return 1;
if (*(int *)((char *)subsystem + 0x278) != 4) // BEST-EFFORT raw offset
return 1;
return 0;
// POLARITY (PPC-dial fix, take 3): state 1 = the DEAD-panel presentation
// (black fill + bright frame + X lamps lit); state 0 = ALIVE (frame 0 +
// background art; the WeaponCluster lamp gate runs ONLY in state 0). The
// binary's secondary condition (*(subsystem+0x278) != 4 -> 1) remains
// unidentified [T4] -- a healthy subsystem reads ALIVE here until that
// field is decoded (open-questions).
return failedState ? 1 : 0;
}
Logical SubsystemCluster::TestInstance() const
@@ -1394,7 +1398,18 @@ Logical SubsystemCluster::TestInstance() const
//
void SubsystemCluster::Execute()
{
operating = (*(int *)((char *)subsystem + 0x40) == 1); // BEST-EFFORT raw offset
// PPC-dial fix (2026-07-12, take 3 -- POLARITY): the binary's
// *(subsystem+0x40)==1 means FAILED (it lights the X TwoStates, which the
// engine draws bright on NONZERO, and selects the black-fill dead-panel
// look). The recon's old name `operating` was backwards, which inverted
// the X lamps, the panel look, the children enable AND the weapon lamp
// gate (which runs only in draw-state 0 = alive). Truth source: the
// subsystem's own damage saturation (DistributeCriticalHit destroys at
// >= 1.0, mechdmg.cpp).
{
extern Scalar BTSubsystemDamageLevelOf(void *subsystem_in);
failedState = (BTSubsystemDamageLevelOf(subsystem) >= 1.0f);
}
int state = GetDrawState();
if (state != previousDrawState)
@@ -1450,7 +1465,7 @@ HeatSinkCluster::HeatSinkCluster(
int engPort = renderer_in->FindGraphicsPort(eng_port_name);
heatFailLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID, // @004739d8
engPort, 200, 0x110, fail_lamp_image, 0, 0xff, (int *)&operating, "TwoState");
engPort, 200, 0x110, fail_lamp_image, 0, 0xff, (int *)&failedState, "TwoState"); // bright when FAILED
failSubsystem = hud; // @0x33
failFlag = 0; // @0x34
@@ -1587,6 +1602,12 @@ WeaponCluster::WeaponCluster(
warningState = 0; // @0x39
AddConnection(new GaugeConnectionDirectOf<Scalar>(0, &percentDone, // @00474855
(Scalar *)percentDoneAttr));
// PANEL DIAGNOSTIC (PPC-dial investigation 2026-07-12): remember the
// weapon's name so the Execute probe can attribute its readings; tag the
// recharge arc with the same name for its own [arc] probe.
diagWeaponName = (subsystem_in != 0) ? subsystem_in->GetName() : "?";
((SegmentArc270 *)rechargeArc)->diagName = diagWeaponName;
}
WeaponCluster::~WeaponCluster()
@@ -1630,7 +1651,33 @@ void WeaponCluster::BecameActive()
//
void WeaponCluster::Execute()
{
// PPC-dial fix (2026-07-12): the base Execute BLACK-FILLS + repaints the
// panel on every draw-state flip -- wiping the recharge arc's pixels while
// the arc's INCREMENTAL draw memory (previousSegment) still says "lit".
// The stale arc then draws nothing until the value crosses new segments =
// "tick marks frozen (at the backdrop's painted ring), dot still toggles"
// (user-reported, intermittent). Detect the repaint and reset the arc's
// draw memory so it fully redraws its true fill.
int preDrawState = previousDrawState;
SubsystemCluster::Execute();
if (previousDrawState != preDrawState)
{
if (rechargeArc) rechargeArc->BecameActive();
if (configMap) configMap->BecameActive();
warningState = -2; // lamp: force redraw too
}
// PANEL DIAGNOSTIC (PPC-dial investigation): 1 Hz per-panel dump of the
// exact value the recharge arc + warning lamp read. Answers whether a
// frozen dial is a DATA fault (percentDone pegged) or a DRAW fault
// (value cycles, segments don't).
if (getenv("BT_PANEL_LOG"))
{
static int s_pl = 0;
if ((s_pl++ % 60) == 0)
DEBUG_STREAM << "[panel] " << (diagWeaponName ? diagWeaponName : "?")
<< " percentDone=" << percentDone
<< " warn=" << warningState << "\n" << std::flush;
}
if (previousDrawState == 0)
{
int warn = (WeaponWarnThreshold < percentDone) ? 1 : 0;
@@ -1736,7 +1783,7 @@ BallisticWeaponCluster::BallisticWeaponCluster(
"NumericDisplayScalarTwoState");
destroyedLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID,
engPort, 0xc2, 0x85, "edestryd.pcc", 0, 0xff, (int *)&operating, "TwoState");
engPort, 0xc2, 0x85, "edestryd.pcc", 0, 0xff, (int *)&failedState, "TwoState"); // the destroyed X: bright when FAILED
// ejectWipe (BitMapInverseWipeScalar @004c61c8, eject-timer wipe reading
// subsys+0x3f8). BRING-UP: the BitMapInverseWipeScalar class is not yet
+7 -1
View File
@@ -390,7 +390,12 @@
*stateLamp, // @0xB8 this[0x2E] OneOfSeveralStates
*leakGauge; // @0xBC this[0x2F] LeakGauge
Subsystem *subsystem; // @0xC0 this[0x30]
Logical operating; // @0xC4 this[0x31]
// POLARITY CORRECTED (PPC-dial fix, 2026-07-12): the binary's
// *(subsystem+0x40)==1 means the subsystem FAILED -- it lights the
// destroyed-X TwoStates (engine TwoState draws bright on NONZERO) and
// selects the dead-panel look (GetDrawState 1 = black fill). The old
// name `operating` had it backwards, inverting every consumer.
Logical failedState; // @0xC4 this[0x31] 1 = subsystem destroyed
int previousDrawState; // @0xC8 this[0x32] (Execute repaint key)
// internal helpers (non-virtual; @004c89c4 / @004c8820 / @004c8a28)
@@ -472,6 +477,7 @@
int warningCenterX; // @0xDC this[0x37]
int warningCenterY; // @0xE0 this[0x38]
int warningState; // @0xE4 this[0x39]
const char *diagWeaponName; // PORT diagnostic (appended; clusters are plain-new)
};
class EnergyWeaponCluster : // @004c93b0
+12 -5
View File
@@ -1001,6 +1001,13 @@ void
void
MessageBoard::Execute()
{
// FEED LIVE (2026-07-12): the binary's SetSource binder has no recovered
// caller, so trackedMech stayed NULL and the board never drew. The board
// is the LOCAL cockpit's ticker -- bind the viewpoint mech lazily (the
// same fallback the subsystem cluster uses, :226). The message resolve
// itself reads the mission player's queue regardless.
if (trackedMech == NULL && application != NULL)
trackedMech = (Entity *)application->GetViewpointEntity();
if (trackedMech == NULL) // binary gate :3949
return;
@@ -1030,11 +1037,11 @@ void
}
}
// --- sender name cell (:3983-4009). STRUCTURAL SIMPLIFICATION (marked): the binary
// tracks the previous name ENTITY pointer at 0x9c and erases it via +0x1e0->App+0xc8;
// here previousNameId is a bool (0/1). Functionally identical while DEFERRED (name
// always NULL); RESTORE the entity-pointer tracking + erase branch when the feed goes live.
int nameState = (nameBitmap != NULL) ? 1 : 0;
// --- sender name cell (:3983-4009). The binary tracks the previous name
// ENTITY pointer at 0x9c; with the feed LIVE (2026-07-12) we track the
// resolved BITMAP pointer -- same change-detection granularity (a new
// victim = a different prebuilt raster), no entity re-deref.
int nameState = (int)(size_t)nameBitmap;
if (nameState != previousNameId)
{
localView.MoveToAbsolute(0x80, 0); // vtbl+0x24 (name cell x=128)
+20
View File
@@ -2404,8 +2404,28 @@ SegmentArc270::SegmentArc270(
AddConnection(new GaugeConnectionDirectOf<Scalar>(0, &currentValue, value_pointer));
int n = (number_of_segs < 0) ? -number_of_segs : number_of_segs;
segmentSpan = (Scalar)(n / (n - 1)) * 0.75f; // _DAT_004c62d4 = 0.75
diagName = 0;
}
SegmentArc270::~SegmentArc270() {} // @004c66b3 (base chain)
//
// PORT diagnostic wrapper (frozen-dial hunt, 2026-07-12): log the exact value
// and segment memory the arc runs with, then chain the engine draw untouched.
//
void
SegmentArc270::Execute()
{
if (getenv("BT_PANEL_LOG") && diagName != 0)
{
static int s_al = 0;
if ((s_al++ % 60) == 0)
DEBUG_STREAM << "[arc] " << diagName
<< " val=" << (float)currentValue
<< " prevSeg=" << previousSegment
<< " segs=" << numberOfSegments << "\n" << std::flush;
}
SegmentArc::Execute();
}
// === btl4gau2.cpp begins at @004c6798 (SeekVoltage gauge) -- not part of this TU.
+2
View File
@@ -546,6 +546,8 @@
Scalar *value_pointer, Logical use_thick_lines,
const char *identification_string);
~SegmentArc270(); // dtor thunk @004c66b3
void Execute(); // PORT diag wrapper (chains SegmentArc::Execute)
const char *diagName; // PORT diagnostic (frozen-dial hunt)
protected:
Scalar segmentSpan; // @0xC0 this[0x30]
};
+64 -8
View File
@@ -679,15 +679,23 @@ void
if (currentPositionPointer != NULL)
{
Point3D center = *currentPositionPointer; // FUN_00408440(local_5c, ...)
view.SetFromAxis(W_Axis, center); // FUN_0040b0ac(view,3,center)
// TRANSCRIPTION FIX #2 (the invisible-pip bug): FUN_0040adec writes
// ONLY the 3x3 rotation elements ([0..2],[4..6],[8..10]) -- it never
// touches the translation row. The binary therefore ends with
// view = [R(q) | center]: rotation SET, center PRESERVED. Our old
// `view *= yaw` was the engine's full COMPOSE, which also rotated the
// translation (view = [R | center x R]) -- the subsequent Invert then
// mapped center x R to the origin instead of the viewer, so every
// blip landed hundreds of pixels off-scope. Rotation first
// (rotation-only assignment), translation row LAST.
if (currentAngularPointer != NULL)
{
if (!rockAndRoll)
{
//
// Top-down: keep heading only. Extract the yaw, build a
// yaw-only quaternion and concatenate it.
// yaw-only quaternion, SET the rotation block from it.
//
EulerAngles euler;
euler = *currentAngularPointer; // FUN_0040954c
@@ -695,21 +703,27 @@ void
SinCosPair sc;
sc = Radian(heading); // FUN_00408328
Quaternion yaw(0.0f, sc.sine, 0.0f, sc.cosine); // FUN_00409948
view *= yaw; // FUN_0040adec
view = yaw; // rotation-only (FUN_0040adec semantics)
}
else
{
//
// "Rock and roll": use the full orientation matrix.
// "Rock and roll": use the full orientation.
//
AffineMatrix full;
full = *currentAngularPointer; // FUN_00409968
view *= full; // FUN_0040adec
view = *currentAngularPointer; // rotation-only build
}
}
view.SetFromAxis(W_Axis, center); // FUN_0040b0ac(view,3,center) -- translation LAST
}
worldToView = view; // FUN_0040b244(this+0x324, view)
// TRANSCRIPTION FIX (the invisible-pip bug, 2026-07-12): FUN_0040b244 is
// the full affine INVERSE (cofactor expansion + determinant divide,
// part_001.c:172), not a copy. `view` above is the OPERATOR's local->
// world pose (translate to the mech + yaw); the radar needs its inverse
// -- world->scope, center subtracted, heading unrotated. Read as a copy,
// every pip/name transformed to (world + center) x ppm = thousands of
// pixels off-scope: the scope drew empty at all times.
worldToView.Invert(view); // FUN_0040b244(this+0x324, view)
//
// Bake the metres->pixels scale into the matrix.
@@ -717,6 +731,18 @@ void
Vector3D scale;
scale.x = scale.y = scale.z = pixelsPerMeter;
worldToView.Multiply(worldToView, scale); // FUN_0040b374
if (getenv("BT_MAP_LOG"))
{
static int s_m = 0;
if ((s_m++ % 30) == 0)
DEBUG_STREAM << "[map] w2v ppm=" << pixelsPerMeter
<< " halfW=" << halfWidth << " scale=" << currentScale
<< " m=[" << worldToView(0,0) << "," << worldToView(0,1) << "," << worldToView(0,2)
<< " | " << worldToView(1,0) << "," << worldToView(1,1) << "," << worldToView(1,2)
<< " | " << worldToView(2,0) << "," << worldToView(2,1) << "," << worldToView(2,2)
<< " | t " << worldToView(3,0) << "," << worldToView(3,1) << "," << worldToView(3,2)
<< "]\n" << std::flush;
}
BuildRadarShadow(worldToView); // FUN_004c228c
@@ -803,6 +829,14 @@ void
{
continue;
}
// VISIBLE-RADIUS cull (port): the capability radius above (RadarPercent
// x 4000 m) exceeds the SCOPE radius (currentScale/2 at the current
// zoom); an out-of-scope draw lands beyond the port rect on the shared
// gauge surface. Skip what the scope can't show.
if (delta.x*delta.x + delta.z*delta.z > currentScale * currentScale * 0.25f)
{
continue;
}
Pip *pip = (pips != NULL)
? pips->LookUpPip(entity->GetResourceID()) // FUN_004688ce(pips+0x58,entity+0x1bc,0)
@@ -888,10 +922,23 @@ void
{
continue;
}
// VISIBLE-RADIUS cull (port; see DrawStatic): don't draw past the scope.
if (delta.x*delta.x + delta.z*delta.z > currentScale * currentScale * 0.25f)
{
continue;
}
Pip *pip = (pips != NULL)
? pips->LookUpPip(entity->GetResourceID())
: NULL;
if (mlog)
{
Point3D sp;
sp.Multiply(EntityPosition(entity), worldToView);
DEBUG_STREAM << "[map] pip=" << (void*)pip
<< " screen=(" << sp.x << "," << sp.y << "," << sp.z << ")"
<< " lod=" << LODIndex << " mpp=" << metersPerPixel << "\n" << std::flush;
}
if (pip != NULL)
{
AffineMatrix blipXform;
@@ -987,6 +1034,15 @@ void
// "MapDisplay::DrawNames -- GetSize() / iterator mismatch"
// "d:\\tesla\\bt\\bt_l4\\BTL4RDR.CPP", 0x408
}
else
{
// VISIBLE-RADIUS cull (port; see DrawStatic): a label for a
// contact beyond the scope would blit outside the port rect.
Vector3D d;
d.Subtract(EntityPosition(entity), viewingPosition);
if (d.x*d.x + d.z*d.z > currentScale * currentScale * 0.25f)
entity = NULL; // extract guards NULL -> no label
}
nameArray[i].ExtractFromEntity(entity, worldToView, owner, target); // FUN_004c19fc
}
+78 -17
View File
@@ -669,23 +669,24 @@ void
}
//
// Pop a "destroyed by <killer>" status message onto the pilot HUD.
// Gated on a real attacker player (+ the status pool, a NULL stub in
// bring-up), so the ownerless-dummy solo path skips it (no player to name).
// Pop a "destroyed" status message naming the victim onto the comm
// ticker (MessageBoard feed, LIVE 2026-07-12). ALLOCATION NOTE: the
// binary drew from the MemoryBlock pool @00512f6c and spliced the BT
// vtable; our engine's StatusMessageUpdate reclaims each expired
// message with Unregister_Object + plain delete (PLAYER.cpp:864), so
// the matching allocation here is the engine ctor + Register_Object
// -- same record {playerInvolved, messageType 0, 6.0s}, same queue.
// Gated on a real named player (the ownerless dummy has none).
//
if (sender_owner && StatusMessagePool)
if (sender_owner)
{
StatusMessage *status = (StatusMessage *)StatusMessagePool->New(); // FUN_00402f74(0x512f6c)
if (status)
{
new (status) Player__StatusMessage(
sender_owner, // *(sender+0x190)
0,
STATUS_DISPLAY_TIME // 0x40c00000 == 6.0f
);
*(int *)((char *)status + 0x18) = 0; // status[6] = 0
AddStatusMessage(status); // FUN_0042e580
}
Player__StatusMessage *status = new Player__StatusMessage(
sender_owner, // *(sender+0x190) -- the victim's player
0, // message strip 0 (the kill cell)
STATUS_DISPLAY_TIME // 0x40c00000 == 6.0f
);
Register_Object(status);
AddStatusMessage(status); // FUN_0042e580
}
//
@@ -1359,7 +1360,41 @@ int BTPilotKills(void *pilot)
DEBUG_STREAM << "[score] gauge read: pilot=" << pilot << " kills=" << k << "\n" << std::flush;
return k;
}
int BTPilotDeaths(void *pilot) { return pilot ? ((BTPlayer *)pilot)->GetDeaths() : 0; }
// DEATHS display (MP DEATHS fix): the engine seeds Player::deathCount at -2
// (PLAYER.cpp:759) and only the LOCAL vehicle-acquire path zeroes it -- a REMOTE
// player's copy on this node keeps the raw pre-acquire value (-2/-1) forever,
// which the scoreboard showed verbatim ("DEATHS -1", user-reported). Those
// negatives are the engine's internal spawn-cycle convention, not real deaths;
// clamp them to the observable truth: no deaths yet = 0.
int BTPilotDeaths(void *pilot)
{
if (pilot == 0)
return 0;
int deaths = ((BTPlayer *)pilot)->GetDeaths();
return (deaths < 0) ? 0 : deaths;
}
// OBSERVED-DEATH tally (MP DEATHS fix): each node maintains every player's
// score from LOCALLY OBSERVED events -- exactly how the KILLS credit already
// works (the victim's ScoreMessage handler ++s the SHOOTER's local Player
// copy). This is the symmetric half: when a REPLICANT mech dies here (the
// once-per-death transition runs on every node), tally the death onto its
// owning player's LOCAL copy. The owner's own node counts through its real
// VehicleDead(-1) path, so the caller gates this to replicant mechs -- each
// node then counts every pilot self-consistently. Normalize the -2/-1
// pre-acquire seed first so death #1 reads 1.
void BTPlayerCountObservedDeath(void *player)
{
if (player == 0)
return;
BTPlayer *p = (BTPlayer *)player;
if (p->deathCount < 0)
p->deathCount = 0;
++p->deathCount;
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] observed death -> pilot " << player
<< " deaths=" << p->deathCount << "\n" << std::flush;
}
// Is `pilot` the owner of `local_player`'s current target? (the SELECT-TARGET row
// highlight). Computed here via accessors -- the PilotList's raw-offset version
@@ -1388,11 +1423,37 @@ int BTPilotIsSelected(void *pilot, void *local_player)
// compiled BTPlayer/Player__StatusMessage members (decode AddStatusMessage @0042e580).
// A real (stub) definition is REQUIRED or the /FORCE link AVs MessageBoard::Execute's call.
class BitMap;
// MessageBoard feed (LIVE 2026-07-12): the board is the LOCAL cockpit's comm
// ticker -- read the mission player's engine status queue. statusMessagePointer
// is maintained per frame by Player::StatusMessageUpdate [T0 PLAYER.cpp:520/822]:
// it holds the newest queued Player__StatusMessage until its 6s displayTime runs
// out. The name bitmap resolves through the SAME small-raster chain the radar
// labels use (Mission::GetSmallNameBitmap keyed by the involved player's egg
// bitmapindex).
Logical BTResolveMessageBoard(Entity * /*tracked_mech*/, int *messageId, BitMap **nameBitmap)
{
*messageId = -1;
*nameBitmap = 0;
return False;
BTPlayer *local_player = (application != 0)
? (BTPlayer *)application->GetMissionPlayer() : 0;
if (local_player == 0)
{
return False;
}
Player::StatusMessage *m = local_player->statusMessagePointer;
if (m == 0)
{
return False;
}
*messageId = m->messageType;
Player *involved = m->playerInvolved;
if (involved != 0)
{
Mission *mission = application->GetCurrentMission();
if (mission != 0)
*nameBitmap = mission->GetSmallNameBitmap(involved->playerBitmapIndex);
}
return True;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+1
View File
@@ -374,6 +374,7 @@ class DropZone__ReplyMessage;
int
roleClassIndex; // @0x274 (role resource +0xe4)
friend int BTPlayerRoleLocksAdvanced(void *); // the FUN_004ac9c8 bridge (task #12)
friend void BTPlayerCountObservedDeath(void *); // observed-death tally (MP DEATHS fix)
int
killCount; // @0x27c kills credited to this player
+1
View File
@@ -346,6 +346,7 @@ void
<< " edge=" << (int)fireEdge
<< " alarm=" << (int)weaponAlarm.GetState()
<< " level=" << (float)currentLevel
<< " pct=" << (float)rechargeLevel
<< " tgt=" << (void *)((owner != 0)
? *(Entity **)((char *)owner + 0x388) : 0)
<< std::endl;
+6
View File
@@ -597,6 +597,11 @@ void
<< " inst=" << (int)GetInstance()
<< " invalidZone=" << (int)message->invalidDamageZone
<< " amount=" << message->damageData.damageAmount
<< " type=" << (int)message->damageData.damageType
<< " from=" << message->inflictingEntity
<< " at(" << message->damageData.impactPoint.x
<< "," << message->damageData.impactPoint.y
<< "," << message->damageData.impactPoint.z << ")"
<< " table=" << (void*)damageLookupTable
<< " zones=" << damageZoneCount << "\n" << std::flush;
@@ -1384,6 +1389,7 @@ Mech::Mech(
standingTemplateMaxY = 0.0f;
duckedTemplateMaxY = 0.0f;
templateBottomLift = 0.0f;
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f); // collision-damage guard snapshot
if (GroundReal() && GetCollisionVolumeCount() > 0
&& collisionTemplate != 0 && collisionVolume != 0)
{
+15
View File
@@ -625,6 +625,21 @@ public:
Scalar duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset)
Scalar templateBottomLift; // binary @0x4b8 0.05 x (volume maxX-minX)
// COLLISION-DAMAGE ECONOMY GUARD (the MP ram one-shot, 2026-07-12): the
// mech's TRUE motion at frame entry, snapshotted just before the frame's
// ProcessCollisionList walk. Each StaticBounce in that walk MUTATES
// worldLinearVelocity (the += delta_v reflection); with several solids in
// one frame's list (2007 WinTesla puts TERRAIN in the collision tree; the
// 1995 ground was a heightfield probe, never a list entry) the reflections
// COMPOUND x(1+e) per contact, and a mech-vs-mech contact later in the
// list priced its dispatched damage off a velocity amplified 4x-40x
// (62-pt bump economy -> 1074/112375-pt one-shots; mp_a.log:32651).
// Mech::ProcessCollision restores worldLinearVelocity from this snapshot
// per contact, so contact damage is always priced at the mech's REAL
// approach speed -- which is all the binary's StaticBounce ever saw.
// By-name access only; declared after the layout-locked fields.
Vector3D frameEntryWorldVelocity;
// AUTHENTIC TARGETING (task #36): the engine Reticle struct (MUNGA/RETICLE.h
// [T0]) -- the mech's "TargetReticle" attribute (id 0x1d) binds to it, per the
// RP VTV analog (VTV.h: `Reticle targetReticle` + TargetReticleAttributeID).
+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,
+15
View File
@@ -511,3 +511,18 @@ void
*message_id_out = sub->controlMessageID; // @0xEC
*receiver_out = (Receiver *)sub;
}
//
// BTSubsystemDamageLevelOf -- complete-type bridge (PPC-dial fix, 2026-07-12):
// feeds the gauge cluster's OPERATING flag (the "destroyed X" lamps). The
// observable truth: a subsystem is out of action when its own damage level
// saturates (DistributeCriticalHit destroys at >= 1.0, mechdmg.cpp) -- read
// that instead of the unidentified binary +0x40 field the old raw read
// guessed at (layout noise -> X's over healthy panels).
//
Scalar
BTSubsystemDamageLevelOf(void *subsystem_in)
{
return (subsystem_in != 0)
? ((MechSubsystem *)subsystem_in)->GetSubsystemDamageLevel() : 0.0f;
}
+8
View File
@@ -384,6 +384,10 @@ void
? owner->GetSubsystem(info->subsystemID) : 0;
ResourceDescription::ResourceID explosionID =
ResolveExplosionID(firingWeapon); // weapon+0x3E4
if (getenv("BT_FIRE_LOG"))
DEBUG_STREAM << "[boom-q] subsysID=" << info->subsystemID
<< " weapon=" << (firingWeapon ? firingWeapon->GetName() : "<none>")
<< " explosionID=" << explosionID << std::endl;
if (explosionID != ResourceDescription::NullResourceID
&& !weaponExplosions.Find(explosionID)) // chain+0xFC, slot 0xC
{
@@ -541,6 +545,10 @@ void
explosion_id, Explosion::DefaultFlags, o,
hitID, owner->GetEntityID());
Explosion *e = Explosion::Make(&m);
if (getenv("BT_FIRE_LOG"))
DEBUG_STREAM << "[boom] id=" << explosion_id
<< " at(" << o.linearPosition.x << "," << o.linearPosition.y
<< "," << o.linearPosition.z << ") made=" << (void *)e << std::endl;
if (e != 0)
{
Register_Object(e);
+120 -3
View File
@@ -68,8 +68,49 @@
// 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.
// Missile-arc wave: launch_velocity = the authored MuzzleVelocity VECTOR in the
// shooter's frame (up-tilted rack launch); damage <= 0 = a VISUAL-ONLY round.
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
const Point3D &targetPos, Scalar speed, Scalar damage);
const Point3D &targetPos, Scalar speed, Scalar damage,
const Vector3D *launch_velocity = 0, int guided = 1);
//###########################################################################
// Port-side salvo replication state (missile-visibility wave).
//
// The launcher OBJECT is byte-locked at 0x44C (factory alloc) -- salvo state
// cannot live on it. Keyed by launcher identity: on a MASTER, `fired` counts
// FireWeapon salvos (serialized in the extended update record); on a
// REPLICANT, `seen` tracks the last counter applied (-1 = unsynced, so a
// freshly-joined peer does not replay the master's historical count).
//###########################################################################
namespace {
struct BTSalvoState
{
const void *owner;
int fired; // master: salvos launched
int seen; // replicant: last counter applied (-1 = unsynced)
Point3D target; // master: the last salvo's aim point
};
BTSalvoState gSalvoTable[64];
BTSalvoState &BTSalvoOf(const void *launcher)
{
int freeSlot = -1;
for (int i = 0; i < 64; ++i)
{
if (gSalvoTable[i].owner == launcher)
return gSalvoTable[i];
if (gSalvoTable[i].owner == 0 && freeSlot < 0)
freeSlot = i;
}
BTSalvoState &s = gSalvoTable[(freeSlot >= 0) ? freeSlot : 0];
s.owner = launcher;
s.fired = 0;
s.seen = -1;
s.target = Point3D(0.0f, 0.0f, 0.0f);
return s;
}
}
//###########################################################################
//###########################################################################
@@ -258,15 +299,91 @@ void MissileLauncher::FireWeapon()
+ 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.
// slot isn't populated (bring-up); missileCount missiles per salvo. The authored
// MuzzleVelocity VECTOR rides along (missile-arc wave): the round leaves the rack
// 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)
BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount);
BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount,
&launchVelocity);
// Salvo replication (missile-visibility wave): bump the fire counter + stamp
// the aim point; the extended update record carries both to peer nodes.
{
BTSalvoState &s = BTSalvoOf(this);
++s.fired;
s.target = targetPos;
}
simulationFlags |= 0x1; // replication-dirty
// ENQUEUE the record (visibility fix): the dirty flag alone never
// serializes -- ForceUpdate sets the updateModel bit that makes the next
// PerformAndWatch write THIS subsystem's record into the mech's update
// stream (the exact mechanism the Emitter's beam replication uses,
// emitter.cpp:512). Without it the peer never saw a salvo record at all.
ForceUpdate();
Check_Fpu();
}
//#############################################################################
// WriteUpdateRecord / ReadUpdateRecord (PORT record extension -- missile-
// visibility wave; see the mislanch.hpp banner). The master serializes the
// salvo counter + aim point after the MechWeapon base fields; the replicant
// edge-detects the counter and mirrors the salvo as VISUAL-ONLY rounds
// (damage 0 -- the master's own rounds deliver the damage cross-pod).
//
void
MissileLauncher::WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model)
{
MechWeapon::WriteUpdateRecord(message, update_model); // @004b9690 (alarm fields)
MissileLauncher__UpdateRecord *rec = (MissileLauncher__UpdateRecord *)message;
BTSalvoState &s = BTSalvoOf(this);
rec->recordLength = sizeof(MissileLauncher__UpdateRecord);
rec->salvoCounter = s.fired;
rec->salvoRounds = missileCount;
rec->salvoTarget = s.target;
}
void
MissileLauncher::ReadUpdateRecord(Simulation__UpdateRecord *message)
{
MechWeapon::ReadUpdateRecord(message); // @004b964c (alarm apply)
MissileLauncher__UpdateRecord *rec = (MissileLauncher__UpdateRecord *)message;
BTSalvoState &s = BTSalvoOf(this);
if (s.seen < 0)
{
// first sync after spawn/join: adopt the master's count silently --
// no replay of salvos fired before we could see them.
s.seen = rec->salvoCounter;
}
else if (rec->salvoCounter != s.seen)
{
s.seen = rec->salvoCounter;
// Mirror ONE salvo (a missed record collapses to one -- catch-up
// replays would draw stale phantom volleys). Rounds fly the same
// authored launch vector + seeker-loft arc as the master's, from
// this replicant's own resolved rack ports, damage 0.
Point3D mz;
GetMuzzlePoint(mz); // @004b9948
Scalar spd = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
+ launchVelocity.y*launchVelocity.y
+ launchVelocity.z*launchVelocity.z);
int n = rec->salvoRounds;
if (n < 1 || n > 40)
n = (missileCount > 0 && missileCount < 40) ? missileCount : 1;
for (int i = 0; i < n; ++i)
BTPushProjectile(mz, owner, 0 /*no entity: aim point only*/,
rec->salvoTarget, spd, 0.0f /*VISUAL*/, &launchVelocity);
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] REPLICANT salvo x" << n
<< " at(" << rec->salvoTarget.x << "," << rec->salvoTarget.y
<< "," << rec->salvoTarget.z << ")\n" << std::flush;
}
}
//#############################################################################
// TakeDamage / DeathReset
//
+29
View File
@@ -90,6 +90,35 @@ class Missile;
void TakeDamage(Damage &damage); // inherits MechWeapon::TakeDamage (vtable slot 6, @004b964c)
void DeathReset(Logical full_reset); // best-effort -- chains ProjectileWeapon reset @004bbaf8
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Replication Support (PORT record extension -- missile-visibility wave)
//
// In the 1995 binary a fired Missile was a REPLICATED WORLD ENTITY (its
// own update records, tag 0x78), so the peer pod rendered the round from
// entity replication and the launcher's record stayed the MechWeapon
// base. The port's flying round is a LOCAL tracked projectile
// (BTPushProjectile) -- invisible to peers -- so the launcher's record
// carries the SALVO EVENT instead: a fire counter + the aim point + the
// round count. The replicant-side reader edge-detects the counter and
// launches a VISUAL-ONLY mirror salvo (damage 0; the master's own rounds
// deliver damage cross-pod through TakeDamage). Both nodes run this
// port, so the extended wire format is self-consistent. Salvo STATE
// lives in a port-side table (mislanch.cpp) -- the object layout is
// byte-locked to the 0x44C factory alloc and must not grow.
//
public:
struct MissileLauncher__UpdateRecord:
public MechWeapon::UpdateRecord
{
int salvoCounter; // fires since spawn (edge-detected by peers)
int salvoRounds; // missiles in the salvo (authored missileCount)
Point3D salvoTarget; // the aim point the salvo flew at
};
typedef MissileLauncher__UpdateRecord UpdateRecord;
void WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model); // base-typed param (task #51 override rule)
void ReadUpdateRecord(Simulation__UpdateRecord *message);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Model Support
//
+8 -2
View File
@@ -88,8 +88,13 @@
#include <math.h>
// WAVE 7 Phase B -- the port-side flying-projectile service (defined in mech4.cpp).
// SIGNATURE MUST MATCH mech4.cpp EXACTLY (the missile-arc wave added
// launch_velocity): a stale extern here mangles to a nonexistent symbol, /FORCE
// fills the call with garbage, and the first autocannon shot AVs (user-hit
// crash, 2026-07-12 -- gotcha #3).
extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
const Point3D &targetPos, Scalar speed, Scalar damage);
const Point3D &targetPos, Scalar speed, Scalar damage,
const Vector3D *launch_velocity = 0, int guided = 1);
//#############################################################################
@@ -679,7 +684,8 @@ void
Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
+ launchVelocity.y*launchVelocity.y
+ launchVelocity.z*launchVelocity.z);
BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount);
BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount,
0 /*aim straight at the pick*/, 0 /*BALLISTIC: no seeker on a shell*/);
simulationFlags |= 0x1; // replication-dirty
Check_Fpu();