Read BT's own respawn code and checked it against BTL4OPT.EXE itself. FAITHFUL: BT's Player::VehicleDeadMessageHandler (FUN_0042db80, part_003.c:12029) gates on message->deathCount == player->deathCount AND player+0x40 != 1 (simulationState != DropZoneAcquiredState) -- exactly WinTesla's two gates -- then runs the same closest-DropZone search skipping "win*" zones, dispatches AssignDropZone and re-posts to itself on a timer. So the retry loop and the cross-machine hunt are authentic 1995 behaviour, not a WinTesla artifact. NOT FAITHFUL: BT's BTPlayer::VehicleDeadMessageHandler (FUN_004c012c, part_013.c:10504) ENDS with *(param_1 + 0x290) = 0 -- it CLEARS the field our reconstruction calls deathPending, at the end of every death. Verified in the raw binary: +0x290 is written in exactly three places in the whole executable (0x0b75fb, 0x0bffe3, 0x0c0a05) and ALL THREE store a zeroed register (xor ecx,ecx / xor eax,eax / xor edx,edx immediately before). There is NO write of 1 or of any non-zero value to +0x290 anywhere in BTL4OPT.EXE. Two are ctor/reset sweeps; the middle one is the death handler, sitting right after the call to Post and add esp,0x14, matching the decompiled tail exactly. So 1995 has NO death-pending gate. We invented it (btplayer.cpp:505) and then needed six clear sites to patch the strandings it caused (:382 :469 :1431 :1442 :1461 :1532). #57 and #55 are artifacts of that invention. It is also what makes a ghost PERMANENT: in BT a failed respawn is harmless (the 2s re-post keeps hunting, the next death starts a clean cycle); in ours the first failure latches the pilot and every later death is SWALLOWED forever -- exactly the field signature of 8 cycles, 6 stranded, none recovering. Fix proposed in the doc (match the binary: clear instead of latch, drop the dedup gate) but NOT applied -- six sites depend on the latch and the dedup is load-bearing, so it wants a deliberate two-node bench, not a 1am edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
226 lines
17 KiB
Markdown
226 lines
17 KiB
Markdown
# The GHOST MECH / zombie-wreck failure — field analysis 2026-07-29
|
||
|
||
**Status:** mechanism established [T1/T2]; the missing link (why the drop-zone handshake fails) is
|
||
still open. Field session: 2026-07-29 night, Steam MP, 5 players, build 4.11.642. Raw logs and the
|
||
verbatim agent findings are in `scratchpad/night6/` (uncommitted — they contain machine names and
|
||
Steam identities). Tracker: #81 (zombie wreck), #57 (deathPending latch), #45 (tally replication).
|
||
|
||
## The one-sentence version
|
||
A death whose **drop-zone reply never arrives** leaves the respawn cycle stranded, so the mech is
|
||
never reset and never repainted — and because the render-side wreck swap is **one-way**, the pilot
|
||
keeps driving a burning hulk that sinks out of the world after ~18 s and then cannot be drawn at
|
||
all. The "wreckage moving and shooting" report and the "invisible ghost" report are **the same
|
||
failure at two different ages**.
|
||
|
||
## Established from the field logs [T1]
|
||
- Exactly **8 death cycles** in the whole session. Every one has the same 3-line signature at
|
||
consecutive lines: `death cycle START` → `*** DESTROYED ***` → `WARNING: death ... SWALLOWED`.
|
||
- **Only 2 of 8 reached `RESET at drop zone`.** A stranded cycle predicts ghosting **perfectly**:
|
||
8/8 cycles, 10/10 player-matches, in both directions.
|
||
- Match 2: all four non-host players died, all four stranded, zero RESETs anywhere = "everybody
|
||
ghosted". **The host did not ghost because he never died** — his log holds no death cycle at all.
|
||
(There is nothing special about being host; an earlier reading of this was wrong.)
|
||
- Match 1: three deaths, two stranded, one completed. The completion is the control case, and it is
|
||
the "self-corrected for one player" the testers reported.
|
||
- ⚠ **The SWALLOWED warning is benign.** It fires on 8 of 8 deaths, including both clean
|
||
completions and a build-641 death, so its base rate is 100% and its correlation with ghosting is
|
||
zero. It is not even a second death — it is the same death re-entering the notify from inside the
|
||
cycle. Do not treat it as a signal (an earlier reading did).
|
||
- ⚠ **Player IDs are per-match**: node = lobby token octet + 1, host always `.1`. `player 3:1` is a
|
||
different human in every match. Any cross-machine identification must decode this first.
|
||
- ⚠ An earlier "three spurious RESETs on the host" finding was an **aggregation artifact** — one
|
||
log file spans ~18 sessions and each session legitimately logs one spawn RESET.
|
||
|
||
## The render mechanism [T1, code]
|
||
- `btl4vid.cpp:1255-1300` — the wreck sink is a **pure timer**, nothing about movement: 0.25 s
|
||
reveal, then `sink = -0.025·t²`, and at `-8.0` the hulk/debris/flames get `SetDrawObj(NULL)` plus
|
||
`[BTrender] wreck buried (sink complete)`. That threshold is reached at **t ≈ 18 s**.
|
||
- The wreck objects hang off the mech's own render tree as an offset, so **they travel with the
|
||
pilot** — hence a moving, burning, shooting wreck for the first ~18 s.
|
||
- `mech4.cpp:1827` — a healthy respawn calls `BTRebuildMechModel` on Reset precisely because "the
|
||
wreck swap is one-way on the render side". No reply → no Reset → no rebuild → the hulk sinks and
|
||
the player is invisible but still simulated and driveable.
|
||
- Peers apply the wreck swap correctly and in a bit-identical order across machines, so the ghost is
|
||
**not** an entity peers never learned about — it is an entity whose presentation is frozen dead
|
||
and then hidden.
|
||
|
||
## The stall itself [T0 WinTesla — NOT proven authentic]
|
||
`engine/MUNGA/DROPZONE.cpp`: a slot is granted only if `IsAvailable()` — busy for `DOWN_TIME = 5 s`
|
||
after use, and any other player's vehicle within 2 m marks it busy. **When no slot is free the
|
||
handler reposts the message to itself every 0.1 s at `MaxEventPriority` and never replies**, so the
|
||
requester's `deathPending` latch is never cleared.
|
||
⚠ **Provenance:** `engine/MUNGA` is **WinTesla**, the later Windows port of MUNGA taken from updated
|
||
Red Planet (which converted the Division hardware to software). **The 1995 game never ran it.** So
|
||
this behaviour is authoritative for what we compile, *not* for what the pods did. BT's own
|
||
`munga/dropzone.cpp` is **absent from the decomp export** — the 1995 assignment policy is an
|
||
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]
|
||
|
||
**4. EVERY MP RESPAWN IS A CROSS-MACHINE ROUND TRIP — and that is the single point of failure**
|
||
[T2, two-node bench `scratchpad/night6/mp_ghost.sh`]. Measured on a 2-node rig with the host:local
|
||
labels: each player's request was answered by **the OTHER machine's** DropZone.
|
||
```
|
||
mp_a.log: [dzreq] player 2:1 asking for a drop zone ... <- A's own player asks
|
||
mp_b.log: [dz] GRANTED slot 7 to 2:1 death#1 ... <- B answered it
|
||
mp_b.log: [dzreq] player 3:1 asking for a drop zone ... <- B's own player asks
|
||
mp_a.log: [dz] GRANTED slot 0 to 3:1 death#1 ... <- A answered it
|
||
```
|
||
`FindGroup("DropZones")` iterates **every** DropZone entity including replicants of remotely-mastered
|
||
ones and picks the geometrically closest, so a respawn is: *my request → an arbitrary peer's
|
||
DropZone → that peer's reply → back over the wire to me.* With five players that is five round trips
|
||
through arbitrary peers, and **one degraded peer can ghost everybody else.**
|
||
That dovetails exactly with the otherwise-unexplained field finding that **one machine stopped
|
||
processing the effect/death-transition stream 57 % into match 1 and never recovered** (0 explosions /
|
||
0 wreck swaps / 0 burials while the other four logged 4/4/4). A node in that state that owns the
|
||
closest drop zone cannot answer anyone's respawn — which is what "everybody ghosted" looks like.
|
||
It also explains why solo is 100 % reliable (the whole handshake is in-process) and why the
|
||
2-node bench passes (both nodes healthy, replies land, `deathCount` agrees).
|
||
|
||
**Revised theory of the ghost (two layers):**
|
||
- *Structural:* respawn depends on a cross-machine round trip through a peer that may be lagging,
|
||
stalled, or holding a stale view of my player. Not drop-zone starvation (`slots=8`) and not a
|
||
single lost reply (the hunt retries every 2 s forever).
|
||
- *Mechanism when the reply does arrive but is stale/mismatched:* the silent discard in 2 — the
|
||
reply's `deathCount` disagrees and it is dropped without a trace, which couples this to #45.
|
||
|
||
**Two candidate fixes, both needing the next field logs to choose between:**
|
||
(a) **Prefer a locally-mastered DropZone** when one exists, keeping the handshake in-process and
|
||
removing the remote dependency entirely — cheap and robust, but it changes which physical pad you
|
||
land on, so check it against the maps first. (b) **Make the reply path tolerant**: accept a granted
|
||
reply whose `deathCount` is *ahead* of ours (our counter is behind) instead of discarding it, and
|
||
never leave `deathPending` latched on a discard. ⚠ Neither should be guessed at blind — the
|
||
`[ghost] DROP-ZONE REPLY DISCARDED` line's mismatch direction decides it in one line of log.
|
||
|
||
## HOW THE BINARY HANDLES IT — `deathPending` is OUR INVENTION [T1, decisive]
|
||
Read BT's own respawn code (it *is* in the export, even though `munga/dropzone.cpp` is not) and
|
||
checked it against the raw binary.
|
||
|
||
**The hunt is faithful.** BT's `Player::VehicleDeadMessageHandler` = `FUN_0042db80`
|
||
(`part_003.c:12029`) gates on `message->deathCount == player->deathCount` **and**
|
||
`player+0x40 != 1` — i.e. `simulationState != DropZoneAcquiredState`, exactly WinTesla's two gates —
|
||
then runs the same closest-DropZone search skipping `"win*"` zones, dispatches `AssignDropZone`, and
|
||
**re-posts the message to itself** on a timer. So the retry loop and the cross-machine hunt are
|
||
authentic 1995 behaviour, not a WinTesla artifact.
|
||
|
||
**But the latch is not.** BT's `BTPlayer::VehicleDeadMessageHandler` = `FUN_004c012c`
|
||
(`part_013.c:10504`) ends with:
|
||
```
|
||
++deathCount; message->deathCount = deathCount; --lives;
|
||
updateModel |= 1; alarm(+0x2c, 1); save dropZoneLocation;
|
||
Post(HighPriority, self, message, Now()+delay);
|
||
*(param_1 + 0x290) = 0; // <-- the field our reconstruction calls deathPending
|
||
```
|
||
**The binary CLEARS that field at the end of every death.** Verified against `BTL4OPT.EXE` itself:
|
||
`+0x290` is written in exactly **three** places in the whole executable
|
||
(`file_off 0x0b75fb`, `0x0bffe3`, `0x0c0a05`), and **all three store a zeroed register** —
|
||
`xor ecx,ecx` / `xor eax,eax` / `xor edx,edx` immediately before. There is **no write of 1, or of
|
||
any non-zero value, to `+0x290` anywhere in the binary.** Two of the three are constructor/reset
|
||
sweeps; the middle one is the death handler above (it sits right after the `call` to `Post` and
|
||
`add esp,0x14`, matching the decompiled tail exactly).
|
||
|
||
**So in 1995 there is no death-pending gate at all.** Our `btplayer.cpp:505` does
|
||
`deathPending = 1`, and six separate sites then have to clear it (`:382 :469 :1431 :1442 :1461
|
||
:1532`) — each one a patch for a different stranding scenario. Gitea **#57 and #55 are artifacts of
|
||
that invention**, not of the original design.
|
||
|
||
**And that is what makes a ghost PERMANENT.** In BT a failed respawn is harmless: the 2-second
|
||
re-post keeps hunting, and the next death starts a clean cycle. In ours the first failure latches
|
||
the pilot — every later death hits the dedup and is SWALLOWED ("deaths so far 1" forever), so the
|
||
pilot can never recover for the rest of the mission. That is precisely the field signature: 8 death
|
||
cycles, 6 stranded, none of them ever recovering.
|
||
|
||
**Proposed fix (faithful, and it removes the whole bug class):** match the binary — clear the field
|
||
at the end of the death handler instead of latching it, and drop the dedup gate that depends on it.
|
||
⚠ Six sites currently rely on that latch's existence, and the SWALLOWED dedup is load-bearing for
|
||
the "one death, one cycle" behaviour, so this wants a deliberate two-node bench rather than a
|
||
late-night edit. Caveat on the identification: `+0x290 ↔ deathPending` rests on our own
|
||
reconstruction's mapping (our compiled `BTPlayer` is only `0x28c` bytes, so the offsets cannot
|
||
match directly) — but the *behavioural* evidence (BT's death handler clears it; nothing in the
|
||
binary ever sets it) is read straight from the executable.
|
||
|
||
## 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**
|
||
the bottleneck. Suspicion moves to the handshake: a lost reply, or a `deathCount` mismatch — the
|
||
engine gates the hunt on `message->deathCount == deathCount` and the DropZone's resend safety net is
|
||
keyed on `lastDeathCount == message->deathCount`, so one mismatch breaks both silently (cf. #45).
|
||
|
||
## Instrumentation shipped (`ba6756c`) — always-on, no env var needed
|
||
| line | meaning |
|
||
|---|---|
|
||
| `[dz] POOL name= slots= downTime= proximityBlock=` | once per mission: the pool size |
|
||
| `[dz] GRANTED slot N to entity E death#D waited=Xs` | one per respawn; `waited` is the headline; tags `(GHOST RECOVERED)` past 15 s |
|
||
| `[dz] STALL` / `[dz] GHOST LIKELY` | escalating, rate-limited per waiter; names **why** each slot is busy (age + last user) — this discriminates cooldown saturation from proximity blocking |
|
||
| `[dzreq] player P asking ... try=N msgDeath= ourDeathCount=` | requester half; flags `*** MISMATCH ***` |
|
||
`BT_DROPZONE_LOG=1` adds a verbose per-request slot dump for bench work.
|
||
|
||
## Next actions, in order
|
||
1. **Recover BT's 1995 drop-zone policy** from `content/BTL4OPT.EXE` (decomp gap). The key question:
|
||
when no slot was available, did BT also repost forever and never reply? If yes, the stall is
|
||
authentic and our divergence is elsewhere; if no, implement what BT did.
|
||
2. **Read the next field logs' `[dz]`/`[dzreq]` lines** — they answer cooldown-vs-proximity and
|
||
lost-reply-vs-deathCount-mismatch directly.
|
||
3. Ask the testers one question that discriminates a second defect: **did a flaming mover stay
|
||
visible for more than ~20 s?** If yes, the sink tick is not running for that entity (updater
|
||
early-return, or a render-tree rebuild resetting `wreckAge`) — a second bug on top of this one.
|
||
4. Separately filed/needed: one machine **stopped processing the effect/death-transition stream**
|
||
57 % into a match and never recovered (0 explosions / 0 wreck swaps / 0 burials while the other
|
||
four logged 4/4/4) — that machine was playing a stale world, and it is why that player reported
|
||
the whole "desync cluster".
|
||
|
||
## The desync cluster is four separate things, not one bug [T2]
|
||
- **Throttle reads zero** — local, and present in the *good* sessions too. ⚠ Unresolved nuance: the
|
||
internal `thr` field is 0 in every field `[drive]` sample while mechs moved, but reads 0.8 in solo
|
||
benches; whether that is the same thing the player saw on his gauge is **not** established.
|
||
- **No weapon audio** — local audio-source starvation caused by the **map's ambient emitters**
|
||
(`arena2_morning` ≈ 10.6 effects/s × ~2.5 voices pins the 256-source pool); thousands of
|
||
`ACQUIRE FAILED ... pool is exhausted`. Unrelated to crits or the network.
|
||
- **Weapons fire without damage** — replication staleness. The shooter resolves damage and awards
|
||
its own score locally, so a shooter always *sees* hits even when the victim applies nothing.
|
||
- **Other mechs lagging/misplaced** — the same stale-world condition as item 4 above.
|
||
|
||
## Why tonight and not before
|
||
Nothing in 642 broke multiplayer. Critical hits landing for real (first time) made deaths far more
|
||
frequent, and every death is a chance for this stall — so a rare race became a common one. Fewer
|
||
players ⇒ fewer deaths ⇒ clean matches, which is exactly what the testers observed.
|