Gitea #59: the first death permanently disabled ExecuteWatchers -- simulationFlags |= 0x1 should be ForceUpdate()

Verified the claim in the binary myself with capstone before changing behaviour:
  0x4c0155:  or word ptr [ebx + 0x18], 1
+0x18 is Simulation::updateModel, a **Word** -- and the 16-bit 'or word ptr'
settles it, since simulationFlags is an LWord at +0x28 and would assemble as
'or dword ptr'.  So the authentic op is updateModel |= DefaultUpdateModelFlag,
which is exactly Simulation::ForceUpdate() (SIMULATE.h:146-147).

The old transcription wrote simulationFlags |= 0x1 instead.  Bit 0 there is
DelayWatchersFlag (SIMULATE.h:170); Simulation::Simulate then skips
ExecuteWatchers() forever (SIMULATE.cpp:461), and NOTHING clears it -- the only
ClearWatcherDelay() in the tree is in the encore path (UPDATE.cpp:215), which a
Player never takes.  So every pilot's first death permanently disabled watcher
execution on their simulation, and the replication mark the binary intended was
never set at all.  context/reconstruction-gotchas.md had flagged this exact line
as the un-audited sibling of the #12 dirty-bit class, asking for the disasm
first; that is now on the record.

LIVE VERIFICATION (scratchpad/sim3.py, 3 mechs + BT_MP_FORCE_DMG, 180s):
4 consecutive death/respawn cycles on pod3, every one completing --
  death cycle START (death #N) -> RESET at drop zone [COMPLETE]
with 'player watchersDelayed=0' after every death, and no crash.  Also
re-confirms the #57 latch fix under repeated deaths (the interleaved SWALLOWED
lines are the authentic dedup of a second notification, each followed by a
completed RESET).

Leaves a silent regression guard: the death path now logs only if it ever finds
the watcher-delay flag set again.

Rigs: sim3.py (3-mech combat + force damage), destest.py (2-node designation
proof: 18 designations, deathPending clean on all of them).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
This commit is contained in:
arcattack
2026-07-25 09:04:07 -05:00
co-authored by Claude Opus 5
parent 2dd4ee3910
commit 2518e43719
3 changed files with 276 additions and 2 deletions
+42 -2
View File
@@ -483,7 +483,38 @@ void
scenarioRole->GetReturnFromDeath() - 1);
}
simulationFlags |= 0x1; // request a forced update
//
// MARK FOR REPLICATION (Gitea #59). The binary's instruction here is
// `or word ptr [ebx + 0x18], 1` (verified with capstone at 0x4c0155). Offset
// +0x18 is `Simulation::updateModel`, a **Word**, and the 16-bit `or word ptr`
// confirms it -- `simulationFlags` is an LWord at +0x28 and would assemble as
// `or dword ptr`. So the authentic operation is
// `updateModel |= DefaultUpdateModelFlag`, which IS exactly
// `Simulation::ForceUpdate()` (SIMULATE.h:146-147).
//
// The old transcription wrote `simulationFlags |= 0x1` instead. Bit 0 of
// simulationFlags is **`DelayWatchersFlag`** (SIMULATE.h:170), and
// `Simulation::Simulate` skips `ExecuteWatchers()` for good whenever it is set
// (SIMULATE.cpp:461). Nothing clears it -- the only `ClearWatcherDelay()` in
// the tree is inside the encore path (UPDATE.cpp:215), which a Player never
// takes. Net effect: **the first death of any pilot permanently disabled
// watcher execution on that player's simulation**, and the replication mark
// the binary intended was never set at all.
//
// (`context/reconstruction-gotchas.md` flagged this exact line as the one
// un-audited sibling of the #12 dirty-bit mis-mapping class, asking for the
// disasm before changing it. That is the disasm.)
//
ForceUpdate(); // updateModel |= DefaultUpdateModelFlag
// Gitea #59 regression guard. The PLAYER's watcher-delay flag must never be
// set by the death path again -- the old `simulationFlags |= 0x1` set it and
// nothing in the tree clears it, permanently killing ExecuteWatchers for this
// Player. Silent unless the bug returns. Verified live 2026-07-25:
// watchersDelayed=0 across 4 consecutive death/respawn cycles.
if (AreWatchersDelayed())
DEBUG_STREAM << "[respawn] BUG (Gitea #59): the death path left this "
"player's watchers DELAYED -- ExecuteWatchers is dead for it now\n"
<< std::flush;
Set_Alarm_Level((char *)this + 0x2c, 1); // FUN_0041bbd8(this+0x2c, 1)
//
@@ -1647,7 +1678,16 @@ void BTPilotSetObjectiveMech(void *pilot, void *target_vehicle)
{
if (pilot == 0)
return;
((BTPlayer *)pilot)->objectiveMech = (Mech *)target_vehicle;
BTPlayer *p = (BTPlayer *)pilot;
p->objectiveMech = (Mech *)target_vehicle;
// PROOF LINE for the 0x284 bug (Gitea #48/#57): the designation must land on
// objectiveMech and must leave deathPending ALONE. Before the fix this wrote
// a Mech* into deathPending and killed the pilot's respawn for the session.
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] designate: objectiveMech=" << (void *)p->objectiveMech
<< " deathPending=" << p->deathPending
<< (p->deathPending == 0 ? " (clean)" : " *** LATCH SET -- BUG ***")
<< "\n" << std::flush;
}
void *BTPilotObjectiveMech(void *pilot)