Cross-pod beams: replicate emitter discharge via subsystem update records

User report: lasers only visible on the window firing them.  The peer's
replicant emitters never learned the master fired.

THE AUTHENTIC PIPE (decomp-verified):
- FUN_0041c350 (the "beam keepalive" ServiceDischarge/ContinueDischarge call)
  does TWO things: queue the LOCAL deferred beam-effect callback (@0x4bac0c)
  on the app+0x34 manager -- our per-weapon render walk already plays that
  role -- and set the subsystem DIRTY bit, which maps to the 2007 engine's
  updateModel / ForceUpdate().
- Replication rides SUBSYSTEM UPDATE RECORDS inside the mech's update message:
  the roster walk already hands the entity's stream to every subsystem's
  PerformAndWatch; Simulation::WriteSimulationUpdate serializes each requested
  updateModel bit; Entity::UpdateMessageHandler routes received records by
  subsystemID to the subsystem's ReadUpdateRecord.  All engine machinery --
  the missing pieces were the Emitter's serialize/apply pair + the triggers.

CORRECTIONS to the dormant task-33-era transcriptions (never exercised --
nothing ever set updateModel -- so the latent misreads never surfaced):
- The weapon-family VTABLE SLOT MAP was swapped: slot 6 = ReadUpdateRecord,
  7 = WriteUpdateRecord, 9 = TakeDamage (evidence: Mech hierarchy symmetry +
  body semantics; @004ba568 resolves an EntityID at rec+0x30 through the
  entity index -- record semantics, not Damage).  Renamed across MechWeapon /
  Emitter / ProjectileWeapon; the real Emitter::TakeDamage @004bafc8 is
  undecoded (inherits MechWeapon for now).
- Emitter/MechWeapon Write: `*record = 0x38/0x18` is the record LENGTH, not
  recordID; rec+0x30 is the TARGET's EntityID (GetEntityID()), not a colour --
  the old `CopyColor(targetEntity+0x184)` was also a databinding trap.
- OVERRIDE-SIGNATURE TRAP: the decls used each class's own shadowing
  UpdateRecord typedef as the param type, silently NOT overriding the engine
  virtual (the base ran instead; nothing would ever have serialized).
  Base-typed params (Simulation__UpdateRecord*), casts inside.
- Emitter::ReadUpdateRecord reconstructed (@004ba568): target EntityID resolve
  (drop unknown non-null targets), MechWeapon alarm apply chain, beam fields.
- ServiceDischarge/ContinueDischarge: ForceUpdate() per keepalive tick + one
  final record at beam end (turns the peer's beam off).
- Mech::DrawWeaponBeams extracted from the player-only drive block so the walk
  runs for REPLICANTS (+ per-mech gun-port cache -- the process-wide statics
  would have served the player's segment pointers as the replicant's muzzles).

VERIFIED 2-node: A fires 57 volleys -> 225 emitter records -> B applies all
225 -> B draws 414 beams (PPC blue / laser red, from A's replicant's own gun
ports).  Solo un-regressed (150 beams, kill chain, no crash).

Also preserved: the full Mech::WriteUpdateRecord @0x4a0c2c recovery
(reference/decomp/mech_writeupdate_004a0c2c.disasm.txt) with all 9 record
types decoded (pose/alarm/leg-state+heat with the body-channel write-through
re-sync, knockdown, death, impact, movementMode) -- transcription deferred;
it replicates remote knockdown/death/heat and was not needed for beams.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-09 19:04:34 -05:00
co-authored by Claude Fable 5
parent a110041a70
commit 29035028fd
11 changed files with 764 additions and 180 deletions
+67 -39
View File
@@ -72,6 +72,7 @@
#if !defined(TESTBT_HPP)
# include <testbt.hpp>
#endif
#include <hostmgr.hpp> // HostManager::GetEntityPointer -- the update-record target resolve (task #51)
// E8 (bring-up): the player fire input. The controls mapper that would normally write
// the weapon's fireImpulse is bypassed (mech4.cpp), so EmitterSimulation reads this
@@ -413,11 +414,21 @@ void
beamFlag = 0; // 0x46c
dischargeTimer = dischargeTime; // 0x440 = 0x43c
SetDirty();
// replicate the beam END: one final Emitter update record turns the
// peer's copy off (the 1995 flush wrote idle-flagged subsystems too).
ForceUpdate();
return;
}
// keep the beam alive for another frame (re-send the beam message).
// CROSS-FAMILY (messaging): SendMessage(PTR_LAB_00511e6c,..) -- FUN_0041c350.
// keep the beam alive for another frame -- BEAM REPLICATION (task #51):
// the 1995 keepalive (FUN_0041c350, template @0x511e6c) marked the
// subsystem dirty + queued the local deferred beam-effect callback; the
// local draw is our per-weapon render walk, and the dirty-mark maps to the
// engine's updateModel bit -- the next PerformAndWatch serializes THIS
// emitter's record (@004ba65c) into the mech's update stream, and the
// peer's replicant emitter applies it (@004ba568) so its own walk draws
// the beam.
ForceUpdate();
(void)PTR_LAB_00511e6c; (void)DAT_00511e70; (void)DAT_00511e74;
}
@@ -449,7 +460,9 @@ void
ResetFiringState(); // @004ba9a8
return;
}
// re-send the beam-keepalive message (CROSS-FAMILY messaging, FUN_0041c350)
// re-send the beam keepalive -- the engine's updateModel bit (task #51,
// same mapping as ServiceDischarge above; 1995 template @0x511e78).
ForceUpdate();
(void)PTR_LAB_00511e78; (void)DAT_00511e7c; (void)DAT_00511e80;
}
}
@@ -552,45 +565,52 @@ void
//
//
// @004ba568 -- slot 6. Resolve the incoming damage's source subsystem (the
// VoltageSource link), then apply the base MechWeapon damage and replicate the
// extra Emitter fields (firingActive, beam endpoint, target). A "no-source"
// damage clears the link and sets the alarm to 2 (Loaded) / 0 (active).
// @004ba568 -- slot 6 (TASK #51 RENAME: was mistranscribed as TakeDamage; the
// body is unambiguous RECORD semantics -- it compares rec+0x30 against
// EntityID::Null (DAT_00522524), resolves it through the entity index, and
// applies alarm/beam fields). Apply an Emitter update record on the
// REPLICANT: resolve the beam TARGET id (drop the record when a non-null id is
// unknown on this host), chain the MechWeapon read (@004b964c, alarm apply),
// then the beam-replication fields. The tail `this+0x10 = this+0x14` is the
// 1995 Simulation lastUpdate=lastPerformance resync (the earlier transcription
// misread it as rechargeLevel=effectiveRange) -- the engine base read already
// maintains lastUpdate.
//
void
Emitter::TakeDamage(Damage &damage)
Emitter::ReadUpdateRecord(Simulation__UpdateRecord *message)
{
int src = FUN_00421070(&damage, DAT_00522524); // damage source (damage+0x30)
if (src == 0)
Emitter__UpdateRecord *rec = (Emitter__UpdateRecord *)message;
if (rec->targetID == EntityID::Null) // FUN_00421070 vs DAT_00522524
{
targetEntity = 0; // 0x474
}
else
{
// look up the named source through the global subsystem registry
targetEntity = (Entity *)ResolveSource(&damage); // this+0x474
targetEntity = (application != 0)
? application->GetHostManager()->GetEntityPointer(rec->targetID)
: 0;
if (targetEntity == 0)
{
return;
return; // unknown target -> drop
}
}
MechWeapon::TakeDamage(damage); // FUN_004b964c
MechWeapon::ReadUpdateRecord(message); // FUN_004b964c (alarm apply)
// CROSS-FAMILY (replication): the shipped Emitter also pulled its beam state
// (firingActive, beamFlag, beamEndpoint, beamColor, targetLocalFlag) out of the
// networked damage record. Those fields are not present on the engine Damage;
// they arrive through the update-record path (WriteUpdateRecord) instead.
if (firingActive == 0)
firingActive = rec->firingActive; // 0x418 <- rec+0x18
weaponAlarm.SetLevel(firingActive != 0 ? 0 : 2); // 0=Firing / 2=Loaded
beamFlag = rec->beamFlag; // 0x46c <- rec+0x28
beamEndpoint = rec->beamEndpoint; // 0x460 <- rec+0x1c
targetLocalFlag = rec->targetLocalFlag; // 0x470 <- rec+0x2c
ClearDirty(); // this+0x28 &= ~2 (idle bit clear)
if (getenv("BT_BEAM_LOG"))
{
weaponAlarm.SetLevel(2); // Loaded
static int s_rd = 0;
if ((s_rd++ % 60) == 0)
DEBUG_STREAM << "[emit-rd] " << (void*)this << " firing=" << firingActive
<< " beamFlag=" << beamFlag << "\n" << std::flush;
}
else
{
weaponAlarm.SetLevel(0); // Firing
}
ClearDirty(); // this+0x28 &= ~2
rechargeLevel = effectiveRange; // this+0x10 = this+0x14 (best-effort)
}
//
@@ -599,23 +619,31 @@ void
// endpoint, beamFlag and the target's colour tag.
//
void
Emitter::WriteUpdateRecord(UpdateRecord *message, int update_model)
Emitter::WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model)
{
MechWeapon::WriteUpdateRecord(message, update_model); // FUN_004b9690
message->recordID = 0x38; // *message
message->firingActive = firingActive; // message[6] <- 0x418
message->targetLocalFlag= targetLocalFlag; // message[10] <- 0x470
message->beamEndpoint = beamEndpoint; // message[7] <- 0x460
message->beamFlag = beamFlag; // message[11] <- 0x46c
if (targetEntity == 0) // 0x474
Emitter__UpdateRecord *rec = (Emitter__UpdateRecord *)message;
// DISASM CORRECTIONS (task #51): `*param_2 = 0x38` writes the record
// LENGTH, not recordID; and rec+0x30 receives the TARGET's EntityID
// (FUN_00420ef4 copies from targetEntity+0x184 = the 1995 entityID --
// the old `CopyColor(...targetEntity+0x184)` was both a misread AND a
// databinding trap in our compiled Entity layout).
rec->recordLength = sizeof(Emitter__UpdateRecord); // *message = 0x38
rec->firingActive = firingActive; // rec+0x18 <- 0x418
rec->beamEndpoint = beamEndpoint; // rec+0x1c <- 0x460
rec->beamFlag = beamFlag; // rec+0x28 <- 0x46c
rec->targetLocalFlag = targetLocalFlag; // rec+0x2c <- 0x470
rec->targetID = (targetEntity != 0)
? ((Entity *)targetEntity)->GetEntityID()
: EntityID::Null; // rec+0x30
if (getenv("BT_BEAM_LOG"))
{
CopyColor(&message->color, DAT_00522524); // FUN_00420ef4
}
else
{
CopyColor(&message->color, (void*)((char*)targetEntity + 0x184));
static int s_wr = 0;
if ((s_wr++ % 60) == 0)
DEBUG_STREAM << "[emit-wr] " << (void*)this << " firing=" << firingActive
<< " beamFlag=" << beamFlag << " subsysID=" << (int)rec->subsystemID
<< " len=" << (int)rec->recordLength << "\n" << std::flush;
}
}