#81 ghost: light the SILENT DISCARD -- the only path a respawn can vanish on
Walked the respawn handshake end to end and measured the assumptions. Two
eliminations and one find:
ELIMINATED: the engine hunt re-posts to itself every 2s forever while the mech
is dead (PLAYER.cpp:325-327; its only state-based exit never fires, see below),
and the DropZone re-grants the SAME slot to a repeat request from the same
requester+deathCount even while busy (DROPZONE.cpp:182-194). So neither a
transient "no slot" nor a single lost reply can strand a respawn -- the request
keeps being re-sent and re-granted. A permanent strand needs a permanent cause.
THE FIND: BTPlayer::DropZoneReplyMessageHandler ends
if (!playerVehicle) ... else if (deathCount == message->deathCount) ... else { return; }
and that last branch was a BARE RETURN WITH NO LOG. The drop zone grants a
spot, replies, the numbers disagree, the reply is dropped, deathPending stays
latched, the mech is never Reset -> permanent ghost, zero evidence. That is
why 6 of 8 field cycles stranded silently. Now always-on, printing the
DIRECTION of the mismatch: msgDeath < ours = genuinely stale (dropping is
right); msgDeath > ours = our counter is behind and we just threw away a LIVE
respawn -> points straight at #45 (the death tally does not replicate).
Deliberately NOT auto-recovered: guessing would be a stand-in, and the wrong
guess resets a mech that is still alive.
LANDMINE DOCUMENTED IN CODE: Set_Alarm_Level is an empty stub (btstubs.cpp:87),
so the death path's Set_Alarm_Level(this+0x2c,1) and the reply's (+0x2c,2) are
no-ops. Their values decode against Player's enum as DropZoneAcquiredState(1)
and VehicleTranslocatedState(2), which makes "these should obviously be
SetSimulationState() calls" both attractive and CATASTROPHIC: the engine hunt is
gated on GetSimulationState() != DropZoneAcquiredState, so setting 1 on death
would stop AssignDropZone ever being dispatched and ghost EVERY pilot. Measured
our simulationState at 0x24 (not 0x2c) and the write leaves it 0; in the binary
+0x2c is the Simulation-base alarm (the field the mech side calls graphicAlarm,
@0x4ac126 "owner alarm+0x2C -> level 9"), which our layout models only on Mech.
Verified solo: a healthy death+respawn logs the grant and the RESET and emits
ZERO discard/gate-off lines (no false positives).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
372cbfa344
commit
0dbdad17f3
@@ -56,6 +56,51 @@ this behaviour is authoritative for what we compile, *not* for what the pods did
|
||||
unrecovered gap. Recovering it (byte-scan + windowed disasm, the technique that recovered
|
||||
`FUN_004a6344`) is the open task.
|
||||
|
||||
## 2026-07-30 dig: the respawn handshake, walked end to end
|
||||
Re-read the whole chain (death → +5 s re-post → engine hunt → DropZone grant → reply → Reset) and
|
||||
**measured** the parts that were assumptions. Results, in order of importance:
|
||||
|
||||
**1. The engine hunt RETRIES EVERY 2 SECONDS, forever, while the mech is dead** [T0]
|
||||
(`PLAYER.cpp:325-327` posts the message back to itself unconditionally; its only state-based exit is
|
||||
`GetSimulationState() == DropZoneAcquiredState`, which never happens — see 3). The DropZone also has
|
||||
a resend net: a repeat request from the same requester + same `deathCount` re-grants the **same**
|
||||
slot even while it is busy (`DROPZONE.cpp:182-194`). **So "a transient no-slot" and "one lost reply"
|
||||
are both ELIMINATED as causes** — the request keeps being re-sent and re-granted every 2 s. A
|
||||
permanent strand needs a permanent reason.
|
||||
|
||||
**2. THE SILENT DISCARD — the one remaining unlit path, now instrumented** [T1]
|
||||
`BTPlayer::DropZoneReplyMessageHandler` is `if (!playerVehicle) … else if (deathCount ==
|
||||
message->deathCount) … else { return; }`. That final branch was a **bare return with no logging**:
|
||||
the drop zone granted a spot and replied, the numbers disagreed, and the reply was dropped —
|
||||
`deathPending` stays latched, the mech is never `Reset`, and the pilot is a permanent ghost **with
|
||||
zero trace in the log**. That is exactly why 6 of 8 field cycles stranded leaving no evidence. Now
|
||||
always-on (`[ghost] DROP-ZONE REPLY DISCARDED …`) and it prints the **direction** of the mismatch,
|
||||
which decides the fix: `msgDeath < ours` = genuinely stale (dropping is correct); `msgDeath > ours` =
|
||||
**our counter is behind and we threw away a live respawn** — which points straight at #45 (the death
|
||||
tally does not replicate correctly). Deliberately NOT "recovered" yet: guessing the direction would
|
||||
be a stand-in, and the wrong guess respawns a mech that is still alive.
|
||||
|
||||
**3. ⚠ LANDMINE — do not convert the two `Set_Alarm_Level` raw writes to `SetSimulationState()`.**
|
||||
`Set_Alarm_Level` is an **empty stub** (`btstubs.cpp:87`), so the death path's
|
||||
`Set_Alarm_Level(this+0x2c, 1)` and the reply path's `(this+0x2c, 2)` are **no-ops** today. Their
|
||||
values decode perfectly against `Player`'s enum (`PLAYER.h:273-279`) as `DropZoneAcquiredState`(1)
|
||||
and `VehicleTranslocatedState`(2) — `DropZoneAcquiredState = Entity::StateCount`, and `Entity` adds
|
||||
no states, so it is **1**. That makes "obviously these should be `SetSimulationState` calls" a very
|
||||
attractive and **catastrophic** fix: the engine hunt is gated on `GetSimulationState() !=
|
||||
DropZoneAcquiredState`, so setting state 1 on death would stop `AssignDropZone` from ever being
|
||||
dispatched and make **every** pilot ghost permanently.
|
||||
Measured: our `BTPlayer::simulationState` is at **0x24**, not 0x2c, and the write leaves it at 0
|
||||
(`[ghost] death raw-write(+0x2c,1): simState 0 -> 0 (hunt gate still open)`). In the binary `+0x2c`
|
||||
is the **Simulation-base alarm** — the same field the mech side calls `graphicAlarm` (`@0x4ac126`:
|
||||
"owner alarm+0x2C -> level 9"). Our layout models that only as a `Mech` member, so a `BTPlayer` has
|
||||
nowhere to put it; the stub is harmless until we recover what reads a *Player's* alarm in the binary
|
||||
(likely a cockpit/HUD respawn indicator). [T1 offsets, T3 purpose]
|
||||
|
||||
**Revised theory of the ghost:** not drop-zone starvation (see below) and not a lost reply (see 1),
|
||||
but a **`deathCount` mismatch between the player and the reply**, silently discarding a live
|
||||
respawn — MP-specific in a way solo structurally cannot reproduce (solo respawn works every time;
|
||||
6 of 8 MP cycles stranded), and directly coupled to the known #45 replication defect.
|
||||
|
||||
## What the instrumentation already killed
|
||||
`slots=8` (measured, `[dz] POOL`). An 8-slot pool **cannot** be cooldown-starved by 5 players (at
|
||||
most 5 slots on cooldown at once), so the shared `dropzone=one` in `tools/eggmodel.py:42` is **not**
|
||||
|
||||
@@ -429,6 +429,15 @@ void
|
||||
static int s_forDeath = -999;
|
||||
if (s_forDeath != message->deathCount) { s_forDeath = message->deathCount; s_tries = 0; }
|
||||
++s_tries;
|
||||
// #81: the OTHER gate. The engine hunt (PLAYER.cpp:229) requires
|
||||
// BOTH message->deathCount == deathCount AND simState !=
|
||||
// DropZoneAcquiredState. Report both, so a stranded respawn names
|
||||
// its own cause instead of being inferred.
|
||||
if (GetSimulationState() == (unsigned)DropZoneAcquiredState)
|
||||
DEBUG_STREAM << "[ghost] hunt GATED OFF: simState="
|
||||
<< (int)GetSimulationState() << " == DropZoneAcquiredState"
|
||||
<< " -- the engine will not dispatch AssignDropZone, so no"
|
||||
<< " reply, no RESET, permanent ghost\n" << std::flush;
|
||||
if (s_tries <= 3 || (s_tries % 5) == 0)
|
||||
DEBUG_STREAM << "[dzreq] player " << BTMatchHostOf(GetEntityID())
|
||||
<< ":" << (int)GetEntityID() << " asking for a drop zone"
|
||||
@@ -578,6 +587,25 @@ void
|
||||
DEBUG_STREAM << "[respawn] BUG (Gitea #59): the death path left this "
|
||||
"player's watchers DELAYED -- ExecuteWatchers is dead for it now\n"
|
||||
<< std::flush;
|
||||
// ⚠ LANDMINE, MEASURED 2026-07-30 (#81 dig). `Set_Alarm_Level` is an empty
|
||||
// STUB (btstubs.cpp:87), so this write and its partner in
|
||||
// DropZoneReplyMessageHandler (+0x2c, 2) are NO-OPS today.
|
||||
//
|
||||
// DO NOT "fix" them by calling SetSimulationState(). The values 1 and 2
|
||||
// decode against Player's enum as DropZoneAcquiredState and
|
||||
// VehicleTranslocatedState (PLAYER.h:273-279), which makes that conversion
|
||||
// look obviously right -- and it would BREAK RESPAWN ENTIRELY: the engine's
|
||||
// respawn hunt is gated on `GetSimulationState() != DropZoneAcquiredState`
|
||||
// (PLAYER.cpp:231), so setting state 1 on death means AssignDropZone is
|
||||
// never dispatched for that death and EVERY pilot ghosts permanently.
|
||||
//
|
||||
// +0x2c is not simulationState (ours is at 0x24, measured). In the binary
|
||||
// it is the Simulation-base ALARM indicator -- the same field the mech side
|
||||
// calls `graphicAlarm` (@0x4ac126 sets "owner alarm+0x2C -> level 9"). Our
|
||||
// layout models that only as a Mech member, so a BTPlayer has nowhere to
|
||||
// put it; the stub is therefore harmless until we find what reads a
|
||||
// PLAYER's alarm in the binary (unrecovered; likely a cockpit/HUD respawn
|
||||
// indicator). [T1 offsets, T3 purpose]
|
||||
Set_Alarm_Level((char *)this + 0x2c, 1); // FUN_0041bbd8(this+0x2c, 1)
|
||||
|
||||
//
|
||||
@@ -1438,6 +1466,36 @@ void
|
||||
}
|
||||
else // stale / old message
|
||||
{
|
||||
//
|
||||
// #81 GHOST: THIS IS THE LAST PLACE A RESPAWN CAN VANISH WITHOUT A
|
||||
// TRACE. The drop zone granted us a spot and dispatched the reply, but
|
||||
// its deathCount does not match ours -- so the reply is dropped, the
|
||||
// mech is never Reset, `deathPending` stays latched, and the pilot is a
|
||||
// permanent GHOST: dead, un-reset, still simulated and driveable, and a
|
||||
// burning wreck on every peer (which then sinks after ~18s and can
|
||||
// never be drawn again, btl4vid.cpp:1277). Until now this branch was a
|
||||
// bare `return` with no log at all, which is exactly why 6 of 8 field
|
||||
// death cycles stranded on 2026-07-29 leaving no evidence.
|
||||
//
|
||||
// ALWAYS ON, and it prints the DIRECTION of the mismatch, which decides
|
||||
// the fix: msgDeath < ours means the reply is genuinely stale (a
|
||||
// superseded death) and dropping it is correct; msgDeath > ours means
|
||||
// OUR counter is behind (a missed increment / a replicated write -- cf.
|
||||
// #45, the death tally does not replicate correctly) and the reply is
|
||||
// for a live death we should have honoured. Do not "recover" here
|
||||
// until the field logs say which way it goes -- guessing would be a
|
||||
// stand-in, and the wrong guess re-spawns a mech that is still alive.
|
||||
//
|
||||
DEBUG_STREAM << "[ghost] DROP-ZONE REPLY DISCARDED for player "
|
||||
<< BTMatchHostOf(GetEntityID()) << ":" << (int)GetEntityID()
|
||||
<< " -- msgDeath=" << message->deathCount
|
||||
<< " ourDeathCount=" << deathCount
|
||||
<< (message->deathCount < deathCount
|
||||
? " (reply is STALE -- superseded death)"
|
||||
: " *** OUR COUNTER IS BEHIND -- this was a live respawn and we just threw it away ***")
|
||||
<< " deathPending=" << deathPending
|
||||
<< (deathPending != 0 ? " <== LATCHED: this pilot is now a GHOST" : "")
|
||||
<< "\n" << std::flush;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1543,6 +1601,23 @@ BTPlayer::BTPlayer(
|
||||
<< " deathPending@0x" << (int)offsetof(BTPlayer, deathPending)
|
||||
<< std::dec << " <-- the raw write targets byte 0x284\n"
|
||||
<< std::flush;
|
||||
// #81 GHOST DIG: the death path does Set_Alarm_Level(this+0x2c, 1)
|
||||
// and the drop-zone reply does (this+0x2c, 2). Decoded against
|
||||
// Player's enum those are DropZoneAcquiredState(1) and
|
||||
// VehicleTranslocatedState(2) -- i.e. the BINARY's +0x2c is the
|
||||
// Player's simulationState. The engine's respawn hunt is gated on
|
||||
// `GetSimulationState() != DropZoneAcquiredState`
|
||||
// (engine/MUNGA/PLAYER.cpp:229), so if that raw byte really is our
|
||||
// simulationState, EVERY death marks "drop zone already acquired"
|
||||
// and the hunt can never run -> permanent ghost. Print where our
|
||||
// compiled layout actually puts it.
|
||||
DEBUG_STREAM << "[layout] simulationState@0x" << std::hex
|
||||
<< (int)((char *)&simulationState - (char *)this)
|
||||
<< std::dec << " (raw death write targets 0x2c;"
|
||||
<< " DropZoneAcquiredState=" << (int)DropZoneAcquiredState
|
||||
<< " VehicleTranslocatedState=" << (int)VehicleTranslocatedState
|
||||
<< ") liveState=" << (int)GetSimulationState() << "\n"
|
||||
<< std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user