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
+38 -24
View File
@@ -233,59 +233,73 @@ Logical
//
//
// @004b964c -- slot 6. Apply damage through the base, then set the weapon's
// display alarm: a destroyed weapon (damage->destroyed flag, +0x14) clears the
// alarm to 0, otherwise the alarm tracks the incoming damage level (+0x10).
// @004b96d4 -- slot 9 (TASK #51 RENAME: this body was mislabeled slot-6
// ReadUpdateRecord; @004b96d4 chains @004b0efc = PoweredSubsystem::TakeDamage,
// which dispatches on *param==4 == Damage::damageType -- damage semantics).
// The PoweredSubsystem::TakeDamage body (electrical-short handling) is not yet
// reconstructed; chain to the engine Subsystem base, and keep the damage-zone
// alarm tracking this port has shipped with (best-effort; the binary's alarm
// display on weapon damage flows through the unreconstructed @004b0efc chain).
//
void
MechWeapon::TakeDamage(Damage &damage)
{
Subsystem::TakeDamage(damage); // FUN_0041bd34
Subsystem::TakeDamage(damage); // engine base (real chain: @004b0efc)
// A destroyed weapon clears its display alarm; otherwise the alarm tracks
// the damage-zone state set by the base TakeDamage above. (The decomp read
// the post-damage destroyed/level flags out of the damage record; here we
// read them back off the resolved damage zone.)
// Read the ENGINE Subsystem::damageZone (the real built zone), qualified -- the
// nearer MechSubsystem::damageZone is a ReconDamageZone-shim SHADOW of it (exposed
// once HeatableSubsystem re-based onto MechSubsystem). TODO: remove that shadow.
// Port behavior (kept): a destroyed weapon clears its display alarm;
// otherwise the alarm tracks the resolved damage-zone state.
::DamageZone *dz = this->Subsystem::damageZone;
if (dz != 0
&& dz->GetGraphicState() == DamageZone::DestroyedGraphicState)
{
weaponAlarm.SetLevel(0); // FUN_0041bbd8(this+0xd4, 0)
weaponAlarm.SetLevel(0);
}
else
{
weaponAlarm.SetLevel(
(dz != 0 && dz->damageLevel > 0.0f) ? 1 : 0
); // FUN_0041bbd8(this+0xd4, level)
);
}
}
//
// @004b9690 -- slot 7. Append the weapon's replication fields to the update
// record: record type 0x18, the alarm state (this+0x364), and a "ready/idle"
// flag derived from this+0x360.
// record. DISASM CORRECTIONS (task #51): `*message = 0x18` writes the record
// LENGTH (record[0] = recordLength), not recordID; and message[5] derives from
// this+0x360 == weaponAlarm+0x10 (LevelCountB), not from the level. BASE-TYPED
// param: with the class's own UpdateRecord typedef this silently failed to
// override the engine virtual, so the engine base ran and no weapon field
// ever serialized.
//
void
MechWeapon::WriteUpdateRecord(UpdateRecord *message, int update_model)
MechWeapon::WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model)
{
Subsystem::WriteUpdateRecord(message, update_model); // FUN_0041c500
Subsystem::WriteUpdateRecord(message, update_model); // FUN_0041c500 (header + subsystemID)
message->recordID = 0x18; // *message = 0x18
message->alarmState = weaponAlarm.GetLevel(); // message[4] = *(this+0x364)
message->weaponIdle = (weaponAlarm.GetLevel() == 0) ? 1 : 0; // message[5]
MechWeapon__UpdateRecord *rec = (MechWeapon__UpdateRecord *)message;
rec->recordLength = sizeof(MechWeapon__UpdateRecord); // *message = 0x18
rec->alarmState = weaponAlarm.GetLevel(); // message[4] = *(this+0x364)
rec->weaponIdle = (weaponAlarm.LevelCountB() == 0) ? 1 : 0; // message[5] = (*(this+0x360)==0)
}
//
// @004b96d4 -- slot 9. MechWeapon adds nothing of its own; defers to the
// PoweredSubsystem implementation (paired with WriteUpdateRecord above).
// @004b964c -- slot 6 (TASK #51 RENAME: was mislabeled TakeDamage). Apply a
// weapon update record on the REPLICANT: chain the Simulation base
// (@0041bd34 -- lastUpdate + simulationState), then the weapon alarm --
// reset-through-0 first when the record's weaponIdle flag is set, then the
// replicated alarm level.
//
void
MechWeapon::ReadUpdateRecord(UpdateRecord *message)
MechWeapon::ReadUpdateRecord(Simulation__UpdateRecord *message)
{
PoweredSubsystem::ReadUpdateRecord(message); // FUN_004b0efc
Simulation::ReadUpdateRecord(message); // FUN_0041bd34
MechWeapon__UpdateRecord *rec = (MechWeapon__UpdateRecord *)message;
if (rec->weaponIdle != 0) // record+0x14
{
weaponAlarm.SetLevel(0);
}
weaponAlarm.SetLevel(rec->alarmState); // record+0x10
}
//