K/D scoreboard ROOT-CAUSED (#45) + respawn latch fix (#57) + plate stage ops (#58)

FIXES (compile clean; exe link pending -- a game client still holds btl4.exe):

#57 respawn latch: deathPending was released in ONE place (btplayer.cpp:343) and
only if a LATER re-post happened to find the mech alive -- so if the +5s re-post
landed before the drop-zone reply, or never arrived, the flag stayed set while
the pilot respawned and fought on, and their NEXT death hit the dedup at :391
and was swallowed whole (no warp, no tally, no hunt) for the rest of the
process.  Tonight 3 of 3 deaths in the final round were swallowed, each by a
player who had respawned successfully earlier in the same process.  Clear moved
to the actual completion point (after Mech::Reset at the drop zone) + the four
abandon paths that also stranded it.  Cycle logging promoted to always-on
(START / RESET / SWALLOWED warning) + a RESPAWN matchlog record.

#58 reticle callsign plate drew as a SOLID RECTANGLE on some maps: my
dpl2d_DrawTexturedRect bound a texture but never set COLOROP/ALPHAOP, and
L4D3D.cpp:1085 sets both to SELECTARG1 from TFACTOR (ignores the texture, fills
with a constant).  Which state was live depended on what the 3D pass drew last
-- hence 'depends on the level'.  Now sets MODULATE texture x diffuse for both
channels and restores.  Latent sibling noted (CameraHUDDrawQuad, same omission).

ROOT-CAUSE DOC (no code): docs/KD_SCOREBOARD_PLAN.md.  Headlines:
 - the port reads DEATHS from the WRONG FIELD: the binary's PilotList draws
   +0x27c/+0x280; we labelled +0x280 'pad_0x280', never write it, and
   substituted the engine's Player::deathCount (+0x200, the respawn identity
   number seeded to -2) -> remote rows read a clamped fake 0 by construction;
 - BTPlayer::VehicleDeadMessageHandler @0x4c05c4 is MISSING from our decomp
   export (functions_index.tsv jumps 4c052c -> 4c083c, verified) -- that silence
   is what produced the 'pad' belief.  An absent function is not evidence of an
   absent behaviour;
 - neither counter rides an update record, so divergence never self-heals and
   bystanders show 0/0 all mission;  BTPlayerCountObservedDeath is unreachable
   dead code;
 - 'a kill I didn't earn' = last-hitter-takes-all via lastInflictingID, and
   COLLISIONS count (5 of 47 deaths had a type=0 killing blow);
 - the phantom kill is authentic 1995 (the kill handler bumps the victim's
   killCount too);
 - btplayer.cpp:453 does simulationFlags |= 0x1 where the binary does
   ForceUpdate() -- bit 0 is DelayWatchersFlag and nothing clears it, so the
   first death of any pilot permanently disables ExecuteWatchers on that
   player's simulation.  Filing separately.

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 01:50:07 -05:00
co-authored by Claude Opus 5
parent 326023812a
commit 5174c638ce
4 changed files with 325 additions and 4 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ precise than anything you can infer.
| Term / acronym definitions | `reference/glossary.yaml` |
| The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) |
| The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) |
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md` |
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md` |
---
+231
View File
@@ -0,0 +1,231 @@
# KILLS/DEATHS Scoreboard — Root Cause + Fix Plan (Gitea #45)
**Status: ROOT-CAUSED, fix NOT written. Requires a DESIGN DECISION (see section 3).**
2026-07-25, from a read-only 9-agent investigation; every binary claim re-verified
with capstone against `content/BTL4OPT.EXE`. Provenance: workflow
`wf_225a6196-5a8`. Field data: 88 matchlogs in `content/matchlogs/`.
## Headline
1. **The port reads DEATHS from the wrong field.** The binary's `PilotList` draws
`pilot+0x27c` (KILLS) and `pilot+0x280` (DEATHS)
(`reference/decomp/all/part_014.c:3392-3393`, `fild [edi+0x27c]` / `[edi+0x280]`
in `PilotList::Execute` @0x4cabd0). Our port declares `+0x280` as
**`pad_0x280`** (`btplayer.hpp:394`), never writes it, and substituted the
ENGINE's `Player::deathCount` (+0x200) -- which is the respawn handshake's
identity number, seeded to **-2** (`PLAYER.cpp:777`). So a remote pilot's
DEATHS cell reads a clamped fake `0` **by construction**. [T1]
2. **A FUNCTION IS MISSING FROM OUR DECOMP EXPORT, and that is why the KB got it
wrong.** `BTPlayer::VehicleDeadMessageHandler` lives at **@0x4c05c4** and is
**absent** from `reference/decomp/functions_index.tsv`, which jumps
`4c052c` (146 B) -> `4c083c` (verified independently). The KB inferred
"+0x280 has no writers" from that silence and labelled it padding. The real
handler was disassembled from the binary in this pass: it does
`cmp ebx,edi / je / inc [edi+0x280] / inc [ebx+0x280]` @0x4c0670-0x4c067a.
`btplayer.hpp:231` still misattributes the handler to `@004c012c`, which is
really a DropZoneReply-path helper (`inc [ebx+0x200]` @0x4c0139). [T1]
**Implication beyond this bug: the export has holes -- an absent function is
not evidence of an absent behaviour.**
3. **The observed-tally model is unsound here and only half-wired.** Per kill the
port produces exactly TWO counter writes on TWO machines: `++killCount` on the
killer's node, `++deathCount` on the victim's node. **Neither rides an update
record** -- `Player__UpdateRecord` is `currentScore + dropZoneLocation` only
(`PLAYER.h:73-80`) -- so a divergence can never self-heal and **bystanders show
0/0 for everyone, all mission.** [T0/T1]
4. **`BTPlayerCountObservedDeath` is unreachable dead code.** Its only call site
(`mech4.cpp:2019-2027`) is gated on `ReplicantInstance` but sits INSIDE
`Mech::UpdateDeathState`'s transition block, which a replicant can never enter
(it takes the mode-9 early return at `mech4.cpp:1832`). [T1]
5. **"It showed I got a kill I didn't" = last-hitter-takes-all, and COLLISIONS
COUNT.** `BTPostKillScore` resolves the killer from
`((Mech*)victim)->lastInflictingID`, whose only writer latches the **last
applied** hit rather than the fatal one -- and ram damage is an ordinary
`TakeDamageMessage`. **5 of the 47 master deaths had a type=0 collision killing
blow.** Bump a softened-up mech and the kill posts to your row. `Mech::Reset`
also never clears the field. [T1]
6. **The phantom kill is AUTHENTIC 1995.** The binary's kill handler increments
`killCount` on the VICTIM's player too (`inc [ebx+0x27c]` @0x4c0397 /
`inc [edx+0x27c]` @0x4c03a3). Both of the binary's partner increments write the
**wrong column**, which reads as a shipped adjacent-offset slip.
[T3 on intent, T1 on the addresses]
7. **A serious unrelated bug found on the way:** `btplayer.cpp:453` does
`simulationFlags |= 0x1` where the binary does `or word ptr [ebx+0x18], 1` =
`updateModel |= DefaultUpdateModelFlag` = **`ForceUpdate()`**
(`SIMULATE.h:146-147`). Bit 0 of `simulationFlags` is `DelayWatchersFlag`
(`SIMULATE.h:170`) and **nothing clears it** -- so today **the first death of
any pilot permanently disables `ExecuteWatchers` on that player's
simulation.** `context/reconstruction-gotchas.md:474-476` flagged this exact
line as un-audited; it is now settled. Filed separately.
**The fix needs a decision, not just a patch:** the arcade had no convergence
channel for the pod's own roster (`@0x513300` slots 6/7 are the ENGINE's
Read/WriteUpdateRecord, so 1995 did not replicate the pair either). Section 3
recommends replicating both counters as a documented divergence, because a
replicated pair is idempotent and self-healing where an event tally never is.
**Step 0 is a BLOCKING PREREQUISITE:** land the #54/#57 death-notification work
first, or the DEATHS column stays 0/0/0 in exactly the rounds users complained
about and any "fixed" claim would be false.
---
# Gitea #45 — "KILLS/DEATHS differ per machine": ROOT CAUSE + FIX PLAN
*(read-only pass; nothing modified. All line numbers are the current working tree at `3260238` + the uncommitted `btplayer.cpp` #57 change.)*
---
## 1. VERDICT
**The observed-tally model is UNSOUND BY DESIGN in this architecture, and only half of it was ever wired.** Per kill event, the port produces exactly **two** counter writes on **two different machines** and nothing on the other three: `++killCount` runs on the **killer's** node only (the `KillScore` ScoreMessage is built on the victim's node and rerouted over the wire by `Entity::Dispatch`, engine/MUNGA/ENTITY.cpp:244-251 [T0]), and `++deathCount` runs on the **victim's** node only (the `VehicleDead(-1)` handler, game/reconstructed/btplayer.cpp:436) [T1]. Neither counter is in any update record — `Player__UpdateRecord` is `currentScore + dropZoneLocation` and nothing else (engine/MUNGA/PLAYER.h:73-80, PLAYER.cpp:631-641/645-661 [T0]), and `BTPlayer` adds no override — so **a divergence can never self-heal**, and every bystander shows 0/0 for everyone forever. The intended symmetric half, `BTPlayerCountObservedDeath` (btplayer.cpp:1581), is **unreachable dead code**: its only call site (mech4.cpp:2019-2027) is gated on `ReplicantInstance` but sits *inside* `Mech::UpdateDeathState`'s transition block, which a replicant can never enter — a replicant learns of the death as replicated `simulationState 9`, which is exactly what routes it to the mode-9 early return at mech4.cpp:1832 [T1]. On top of that the port **reads DEATHS from the wrong field**: the binary's PilotList draws `pilot+0x27c` and `pilot+0x280` (reference/decomp/all/part_014.c:3392-3393; `fild [edi+0x27c]`/`[edi+0x280]` in `PilotList::Execute` @0x4cabd0) [T1], but the port declares `+0x280` as `pad_0x280` (btplayer.hpp:394), never writes it, and substituted the **engine's** `Player::deathCount` (+0x200) — which is the respawn handshake's identity number (seeded to **2** at PLAYER.cpp:777, gated at PLAYER.cpp:230) and therefore reads a clamped fake "0" on every remote row by construction (btplayer.cpp:1547-1554) [T1]. Finally, the port faithfully reproduces a **1995 phantom kill**: the `KillScore` branch increments `killCount` on the *victim's* player as well as the killer's (btplayer.cpp:702/704 ≡ `inc [ebx+0x27c]` @0x4c0397 / `inc [edx+0x27c]` @0x4c03a3, decomp part_013.c:10633-10636) [T1].
**Why "faithful" cannot fix this:** the binary is symmetric-but-crossed. I disassembled the un-exported handler myself — `@0x4c05c4` is `BTPlayer::VehicleDeadMessageHandler` (handler table `0x512fec=0x17 / 0x512ff0→"VehicleDead" / 0x512ff4=0x4c05c4`, dumped from `content/BTL4OPT.EXE`) and it does `cmp ebx,edi / je / inc [edi+0x280] / inc [ebx+0x280]` @0x4c0670-0x4c067a, where `edi = GetEntityPointer(msg+0x2c)` is the **killer's Player** (proved by `Player__StatusMessage(edi, type 1, 6.0f)` @0x4c064d added to `this` @0x4c0668, and by `this->objectiveMech(+0x284) = edi->playerVehicle(+0x1fc)` @0x4c06dd-0x4c06e3 — the revenge lock) and `ebx = this` is the **victim's Player** [T1]. So each handler bumps the *same* offset on *both* parties: one kill gives both pilots +1 in column 1 on the killer's machine and +1 in column 2 on the victim's machine. That is the reported bug, in 1995. And `@0x513300` slots 6/7 are `0x42e2ac`/`0x42e2e4` — the **engine's** `Player::Read/WriteUpdateRecord` — so the arcade did not replicate the pair either [T1]. There is no authentic convergence channel for the pod's own pilot list; the console messages (@0x4c06a4-0x4c06da and the kill-side twin) go to `ConsoleClientID` and never feed a pod's roster. **A decision, not just a patch, is required.**
Two facts that dissolve standing uncertainties:
- **Column semantics are settled without the art.** `+0x27c` is written *only* by the KILL handler and `+0x280` *only* by the DEATH handler (whole-image: ctor zeroes at `param_1[0x9f]/[0xa0]`, part_013.c:10873-10874; the two `inc` pairs; the two `fild` reads). Whatever the Comm pixmap prints, column 1 is the kill-event counter and column 2 the death-event counter. [T1]
- **`FUN_004c05c4` is ABSENT from the decomp export** — `reference/decomp/functions_index.tsv` jumps `4c052c` (146 B) → `4c083c` [T1]. That absence is *the* root cause of the whole wrong mental model: the KB inferred "+0x280 has no writers" from silence and labelled it a pad. `btplayer.hpp:231` still attributes `VehicleDeadMessageHandler` to `@004c012c`, which is actually a DropZoneReply-path helper (`inc [ebx+0x200]` @0x4c0139 — I disassembled it).
---
## 2. PER-SYMPTOM MAP
Field baseline I re-derived independently (88 files in `content/matchlogs/`, 42 `-egg OPERATOR.EGG`, 32 with `MISSION st=4 run`; **7 death-bearing multi-node rounds** of 5,5,5,4,4,3,3 nodes + one 3-node no-death round; **47** `DEATH inst=M`, **96** `inst=R`, **43** `PLAYER_DEAD`; **max 5 game nodes** — host 1 is the relay, `PEER_UP host=1 type=3`). **The parent brief is wrong on three counts: 88 files not 45; the final round had THREE deaths not two; and the brief's "two nodes each" award claim is a different, earlier round (the `:108/:109` round).** The final round is `-egg OPERATOR.EGG`, `MISSION run 00:04:50.775` LIVINGROOM-local, 5 nodes (2=MS-FIREFLY, 3=XIAOLONG, 4=SIM-PIT, 5=LIVINGROOM-PC, 6=GAMERSLAB), victims **4:21, 6:21, 5:21 — all three killed by 3:21 (XIAOLONG)** — and all three kill awards land on XIAOLONG alone (`SCORE player=3:1 type=2 award=5.00/35.50/11.76 kills=1/2/3`, matching the victims' `killdmg 5.000/35.502/11.764` measured on three *different* machines). **Zero `PLAYER_DEAD` on any of the five nodes.**
| Player quote | Speaker's seat | Mechanism (file:line) | Fixed by #57? |
|---|---|---|---|
| **"im seeing cyd got a kill but no death count"** | the **killer's** node (only a node that ran the kill handler shows any kill at all) | Two stacked bugs on one row: (a) **phantom kill**`if (sender_owner) ++sender_owner->killCount;` btplayer.cpp:704, where `sender_owner = MECH_OWNING_PLAYER(sender_mech)` and `senderMechID` is the **victim** (btplayer.cpp:1723); the object bumped is the victim's *replicant* `BTPlayer`, which is exactly the object the row reads (roster = local "Players" group, mechmppr.cpp:1258-1300). (b) **no death** on that row because nothing on this node writes the victim's deaths: the observed tally is dead code (mech4.cpp:2019) and the remote copy's `Player::deathCount` is stuck at the 2 seed, clamped to 0 (btplayer.cpp:1551). | **NO** |
| **"its showing i got a kill also which i didnt"** | speaker's **own row on their own screen** — the phantom cannot do this (it credits the victim, on the killer's node) | **Last-hitter-takes-all via `lastInflictingID`.** `BTPostKillScore` resolves the killer from `((Mech*)victim)->lastInflictingID` (btplayer.cpp:1706-1710); that field's only writer is `Mech::TakeDamageMessageHandler` (mech.cpp:746) and it latches the **last applied** hit, not the fatal one, and **a bare collision counts**: ram damage is a normal `TakeDamageMessage` (mech4.cpp:6606-6629). 5 of the 47 master deaths had a **type=0 (collision)** killing blow — including this round's `6:21` (`amt=35.502`) and `4:47` (`RAM raw=109268.5 → amt=109.269`). Bump a softened-up mech and the kill posts to **your** node, **your** row. Secondary: `Mech::Reset` never clears `lastInflictingID`/`lastInflictingDamage` (mech4.cpp:1708-1822), and the binary *seeds* it to the mech's own id in the ctor (@0x4a16ed-0x4a16fb) where our port leaves it 0:0. | **NO** |
| **"K/D still not showing in upper right"** | a **bystander** (MS-FIREFLY, and any node not party to a kill) | Structural: only the two participants' nodes write anything; neither counter rides an update record (PLAYER.h:73-80). Bystanders show 0/0 for every pilot for the whole mission. | **NO** |
| **"I'm not seeing any deaths on my screen"** | any node — this one has **three** layers | (i) **remote pilots' deaths are never tallied anywhere**: `BTPlayerCountObservedDeath` unreachable + no replication + 2 seed → clamped 0. (ii) **in the final round even the victims' OWN deaths were not tallied**: 5 logs, 3 `DEATH inst=M`, **0 `PLAYER_DEAD`** ⇒ the `deathCount == -1` branch never reached `btplayer.cpp:436` on any of the three victim nodes. That is the **#54 defect**, not #45. (iii) DEATHS is read from `Player::deathCount`, i.e. it is coupled to the respawn cycle and to the #57 latch. | (i) NO · (ii) **NO — see below** · (iii) partially |
**#57 explains none of the four quotes, and the comment now in the working tree is factually wrong.** btplayer.cpp:1294-1305 asserts "*tonight 3 of 3 deaths in the final round were swallowed, each by a player who had respawned successfully earlier in the same process*." `deathPending` is set at **exactly one site** (btplayer.cpp:413), immediately *downstream* of the dedup and *upstream* of `++deathCount` (:436) and the unconditional `PLAYER_DEAD` emit (:441-443); it starts 0 in the ctor (:1355). Each of those five logs contains exactly **one** `MISSION run` and each victim has exactly **one** `DEATH inst=M` and **zero** `PLAYER_DEAD` — so the failing death was that pilot's **first** death in that process and **no latch could have been stranded**. Same for the 4th case (MYTHRIC-BEAST `6:25`, `74.99.73.241_matchlog_20260724_230825_39056.txt`). Corpus-wide, `PLAYER_DEAD == DEATH inst=M` in **every** log except those four. #57 is a real bug correctly fixed; it is not what bit them. **Do not let it stand in the changelog as the explanation for the final round.**
Elapsed-time correlation also rules out a mission-timer/`MissionEndingState` cause: tallied deaths occur at t+415 s, +421 s, +428 s, +464 s, +469 s while untallied ones occur at +274 s, +381 s, +401 s, +497 s. No PEER_DOWN precedes any of the four. The surviving candidate is the one `docs/RESPAWN_REARM_PLAN.md` §6 already named: **`Mech::GetPlayerLink() == 0` on the victim's own master mech**, which is the *single shared gate* of both dispatch sites (btplayer.cpp:1735 and mech4.cpp:2045) — and the kill award landing on the killer's node in all four proves `BTPostKillScore` ran past btplayer.cpp:1724, so the failure is at or after :1735. `playerLink` is NULL-initialised (ENTITY.cpp:935) and written only by `Entity::PlayerLinkMessageHandler` (ENTITY.cpp:807-813) with **no null check, no retry, no log**, fed by one `InitializePlayerLink` at drop-in (btplayer.cpp:1216).
---
## 3. THE FIX
**Design decision, stated plainly:** the two counters must be **replicated, not observed**. Justification, in order of force: (1) a per-node observed tally *cannot* work for KILLS on a bystander because the killer is unknown there — all 96 `DEATH inst=R` records carry `killer=0:0 killdmg=0.000`, because `lastInflictingID` rides no record (mech.cpp:2338-2545 writer / :2002-2302 reader) and damage is applied only on the victim's master (0 of 5574 `DMG` records are `inst=R`); (2) the binary offers no convergence channel for the pod roster, so *no* choice here is authentic and the honest move is the smallest documented divergence; (3) `Player` already replicates `currentScore` through this exact record and is already `ForceUpdate()`d on every change (btplayer.cpp:551/765/776) — which is why the SCORE column agrees cross-node while K/D cannot. **A replicated pair is idempotent and self-healing: it converges after a dropped packet, which an event tally never does.**
**Double-count safety is achieved by construction: exactly ONE writer per counter, always the owning master, and every other node is a pure reader.** After the deletions below, `killCount` is written only in `BTPlayer::ScoreMessageHandler`, which `Verify`-aborts on a replicant (btplayer.cpp:597-606 ≡ binary @0x4c02f3) — master-only; `deaths` is written only in the `deathCount == -1` branch, reachable only on a master because `Entity::Dispatch` reroutes replicant dispatches. **No node ever both observes and receives.**
### Step 0 — BLOCKING PREREQUISITE: land the #54 death-notification fix first
Without it the DEATHS column is still 0/0/0 in the exact round the users complained about, and any "#45 fixed" claim would be false. Land `docs/RESPAWN_REARM_PLAN.md` §4 Step 0 + Step 1 as written: the `DEAD_NOTIFY` record at mech4.cpp:2045 and `PLAYER_LINK` at the tail of `InitializePlayerLink`, plus the reverse-link fallback (`Player::playerVehicle == this`, resolved in `mech.cpp` where both types are complete — **not** a raw offset) at `Mech::PlayerLinkMessageHandler` (mech.cpp:675) and at the point of use (mech4.cpp:2044). Also clear `deathPending = 0` in the first-spawn branch (btplayer.cpp:~1239).
### Step 1 — give DEATHS the authentic field, and stop reading the respawn counter
- `game/reconstructed/btplayer.hpp:393-394``int pad_0x280;``int deaths; // @0x280 the binary's DEATHS cell (inc @0x4c067a)`. **New name, not `deathCount`** — re-declaring an engine-base field is gotcha #1 (reads `0xCDCDCDCD` + mis-offsets).
- `btplayer.hpp:324``GetDeaths()` returns `deaths`, not the inherited `deathCount`.
- `btplayer.cpp:1478``pad_0x280 = 0;``deaths = 0;`.
- `btplayer.cpp:1541-1554``BTPilotDeaths`: **delete the `< 0 ? 0` clamp** and the comment block explaining it. The clamp existed only to hide the 2 seed of a field that is no longer the display value; keeping it would mask a real negative.
- `game/reconstructed/btl4gau3.cpp:511-516` — fix the comment ("DEATHS uses the real deathCount, not the binary's dead pad_0x280" is now provably backwards); the two `Draw` calls stay as-is.
- `btplayer.cpp:436-443`**add `++deaths;`** immediately before `++deathCount;`, and print `deaths` in the `PLAYER_DEAD` record alongside it. **Keep `++deathCount`**: in the binary +0x200 is advanced on the *respawn* side (`inc [ebx+0x200]` @0x4c0139 inside `FUN_004c012c`, whose decompiled caller is DropZoneReply @0x4bffd0, part_013.c:10480) and `@0x4c05c4` only *reads* it (@0x4c072b) — but our respawn handshake and #57 both depend on the current placement, and changing it risks the identity gate at PLAYER.cpp:230. Document the divergence; do not touch it in this change.
### Step 2 — fix the mis-transcribed dirty mark (this is a bug fix, and it is what makes replication authentic)
`btplayer.cpp:453``simulationFlags |= 0x1; // request a forced update`**`ForceUpdate();`**. `context/reconstruction-gotchas.md:474-476` flags this exact line as the one un-audited sibling of Gitea #12 and says "verify against the VehicleDead handler's disasm before changing". **Settled here:** the instruction is `or word ptr [ebx+0x18], 1` at 0x4c0155 = `updateModel |= DefaultUpdateModelFlag` = *exactly* `Simulation::ForceUpdate()` (SIMULATE.h:146-147) [T1]. `simulationFlags` bit 0 is `DelayWatchersFlag` (SIMULATE.h:170) and nothing clears it — so today the first death of any pilot **permanently disables `ExecuteWatchers` on that player's simulation**. The binary marks the player for replication right after the death bookkeeping; we currently do not mark it at all.
### Step 3 — delete the partner increments and the dead tally
- `btplayer.cpp:703-704`**delete** `if (sender_owner) ++sender_owner->killCount;`. This is the phantom kill. **Keep** the `Player__StatusMessage(sender_owner, 0, 6.0f)` block at :717-726 — that is a separate, authentic behaviour (@0x4c0632-0x4c0668's mirror). Do **not** add the binary's partner death bump (`inc [edi+0x280]` @0x4c0674); we never had it.
Record the finding: both binary partner increments write the **wrong column** (kill handler → the victim's KILLS instead of DEATHS; death handler → the killer's DEATHS instead of KILLS). The mirror-image symmetry, plus the two self-kill guards (`cmp edi,eax/je` @0x4c0393 and `cmp ebx,edi/je` @0x4c0670) being deliberately per-other-party, makes a shipped 1995 adjacent-offset slip the only economical reading. **[T3 on intent, T1 on all four addresses.]** Under replication the partner writes are redundant *and* would fight the replicated value.
- `btplayer.cpp:1572-1592`**delete `BTPlayerCountObservedDeath`** and its comment block; `btplayer.hpp:389` — delete the `friend`; `mech4.cpp:2014-2027` — delete the whole `ReplicantInstance` block and its comment (which asserts the opposite of mech4.cpp:1836-1840 180 lines earlier). It cannot execute today, and under replication it would double-count.
### Step 4 — replicate the pair (the one deliberate divergence)
In `btplayer.hpp`, beside the existing message typedefs:
```
struct BTPlayer__UpdateRecord : public Player::UpdateRecord { int killCount; int deaths; };
```
plus `typedef BTPlayer__UpdateRecord UpdateRecord;` and, in the `protected:` block next to the handlers, overrides matching `Simulation`'s virtuals (SIMULATE.h:127-131; Player's own overrides at PLAYER.h:431-436 are the pattern):
```
void WriteUpdateRecord(Simulation::UpdateRecord *record, int update_model);
void ReadUpdateRecord(Simulation::UpdateRecord *record);
```
Bodies in `btplayer.cpp`, modelled exactly on PLAYER.cpp:645-661 / :631-641:
- **Write:** `Player::WriteUpdateRecord(record, update_model);` **then** `update->recordLength = sizeof(*update); update->killCount = killCount; update->deaths = deaths;` — the length overwrite must come **after** the base call, because each level rewrites it (ENTITY.cpp:343 → PLAYER.cpp:657).
- **Read:** `Player::ReadUpdateRecord(message);` then copy both ints back. `simulation->ReadUpdateRecord(update)` is a virtual call (ENTITY.cpp:388) so this resolves correctly on a replicant `BTPlayer`.
- Add `ForceUpdate();` after `++killCount` (btplayer.cpp:702) — btplayer.cpp:776 already covers the normal exit, but the `playerVehicle == 0` early return at :762-767 must also ship it (it already calls `ForceUpdate()`; verify).
- Flag it: **`@0x513300` slots 6/7 are the engine's, so the arcade did NOT override these — this is a documented PORT DIVERGENCE [T3]**, taken because no authentic mechanism converges the pod roster.
### Step 5 — drop the duplicate `VehicleDead(-1)` dispatch
`btplayer.cpp:1732-1742`**delete** the victim dispatch from `BTPostKillScore`; keep `mech4.cpp:2044-2058`. Both fire in one straight-line pass of `UpdateDeathState` (`BTPostKillScore` is called at mech4.cpp:1963, the second dispatch at :2053, no return between) on the same `GetPlayerLink()`, and `Dispatch` at a valid master receives synchronously (ENTITY.cpp:268) — so the second **always** trips the dedup and fires the always-on `SWALLOWED` warning on every single death, making the #57 tripwire a permanent false positive. The binary sends exactly one (built @0x4a07c0, dispatched @0x4a0887). `BTPostKillScore` then means only what its name says.
### Step 6 — same wave, sequenced AFTER the counters: restore the authentic killer channel
The 1995 `VehicleDeadMessage` is BT-extended and carries the **killer's PLAYER EntityID at msg+0x2c** (resolved @0x4c0611-0x4c062b); our engine class has only `deathCount` + `dropZoneID` (PLAYER.h:107-123) and `BTPostKillScore` constructs it with no killer at all. Subclass it in BT (`class BTPlayer__VehicleDeadMessage : public Player::VehicleDeadMessage { EntityID killerPlayerID; };`), build it at mech4.cpp:2048, and wire the **three** authentic consumers the port fakes or omits: `killerName` (btplayer.cpp:464 is a canned `SelfDestructName`, and the comment at :456-464 wrongly says the binary reads msg+0x1c — that is the `deathCount` the `-1` test uses at 0x4c05df), the **type-1 "KILLED BY" ticker line** (@0x4c0641-0x4c0668, the mirror of the type-0 cell at btplayer.cpp:717-726), and the **revenge lock** `objectiveMech = killer->playerVehicle` (@0x4c06dd-0x4c06e3). Not required for #45's counters — **do not gate #45 on it.**
### Step 7 — kill attribution (separate defect, same report)
- `Mech::Reset` (mech4.cpp ~1755, next to the `graphicAlarm.SetLevel(0)`): clear `lastInflictingID`/`lastInflictingDamage` (already a to-do at `docs/RESPAWN_REARM_PLAN.md` Step 5).
- Seed `lastInflictingID` to the mech's **own** EntityID in the ctor — the binary does (`EntityID` copy-ctor `mech+0x43c ← mech+0x184` @0x4a16ed-0x4a16fb) — so an unattributed death resolves to self and `BTPostKillScore`'s `killer != victim` guard (btplayer.cpp:1709) rejects it. Our port leaves it default `0:0`.
- Guard `sender_mech` against NULL at btplayer.cpp:661 and :690 (`MECH_OWNING_PLAYER`/`MECH_TONNAGE` are dereferenced before any test; `GetEntityPointer` returns 0 for an unknown id).
- Note for a later wave, **not** this one: the binary does not attribute from `mech+0x43c` at all — it builds the award inside the victim's `TakeDamage` handler on the *was-alive→now-dead edge of that hit* (@0x4a052b-0x4a05ce). Replacing `lastInflictingID` with that edge is the faithful cure for "a kill I didn't earn"; it is a bigger change than #45.
### KB / comment sweeps required by this change
`btl4gau3.cpp:513-516` and `docs/GAUGE_COMPOSITE.md:531` ("the binary's dead pad_0x280") — it is the authentic DEATHS cell. `context/gauges-hud.md:330-340` + the `open_questions` entry at :15 ("**RESOLVED 2026-07-12 [T2]** deaths now tally per node from locally observed events") — it has never executed once. `btplayer.cpp:1572-1580` and :1692-1702 (both claim the KILLS credit already reaches remote local copies "via the player link") and `mech4.cpp:2014-2018` — all contradicted by ENTITY.cpp:244; mech4.cpp:1836-1840 is the correct statement. `btplayer.cpp:299-301` ("the binary also runs killer/victim score bumps (+0x280 via @0042e580)") — 0x42e580 is `AddStatusMessage`; the +0x280 pair is @0x4c0674/0x4c067a. `btplayer.hpp:231``VehicleDeadMessageHandler` is `@004c05c4`, not `@004c012c`. `btplayer.cpp:437` "message+0x38" — that is `FUN_004c012c`'s DropZoneReply message; the VehicleDeadMessage's `deathCount` is at msg+0x1c. `context/gauges-hud.md:332` cites `PLAYER.cpp:759` for the 2 seed; it is :777. `docs/RESPAWN_REARM_PLAN.md` §5 issue map — "**#45 SUBSUMED — same defect, not two**" is **wrong**: the respawn defect explains only the victim's own DEATHS in 4 of 47 deaths; the cross-machine disagreement is structural and independent. And **fix the decomp export for 0x4c05c4** (`reference/ghidra_scripts/`) before anyone else reasons from that gap.
---
## 4. VERIFICATION
**Rig** (2-node localhost relay — the recipe verified in `context/multiplayer.md:352-360` and reused by `docs/RESPAWN_REARM_PLAN.md` §6):
```
python tools/btconsole.py --relay 1500 MP_RELAY.EGG --bind 127.0.0.1
# pod A = the VICTIM
BT_RELAY=127.0.0.1:1500 BT_SELF=10.99.0.1:1502 BT_MATCHLOG=1 \
BT_SCORE_LOG=1 BT_DEATH_LOG=1 BT_CAM_LOG=1 BT_SPAWN_XZ=0,0 \
btl4 -egg MP_RELAY.EGG -net 1501
# pod B = the KILLER (BT_MP_FORCE_DMG makes B shoot A's replicant once a second, mech4.cpp:4823)
BT_RELAY=127.0.0.1:1500 BT_SELF=10.99.0.2:1602 BT_MATCHLOG=1 \
BT_SCORE_LOG=1 BT_DEATH_LOG=1 BT_MP_FORCE_DMG=1 BT_SPAWN_XZ=0,60 \
btl4 -egg MP_RELAY.EGG -net 1601
```
Use an egg that carries name bitmaps (OPERATOR-class), or the rows render as authentic cache-miss boxes. Kill A **twice: once by weapon fire, once by RAM** (drive B into A) — the collision path is the one that steals credit and it produced 5 of the 47 field deaths.
**Baseline (today's build) — the exact failure signature:**
- A's `btl4.log`: `[respawn] player H:1 death cycle START (death #1)` followed by **one** `[respawn] WARNING: death for player H:1 SWALLOWED` (the duplicate dispatch) and **no** `[score] *** KILL ***`.
- B's `btl4.log`: `[score] *** KILL *** killerPlayer=… killCount=1 …` and **no** death line.
- **Neither** log can ever contain `[score] observed death` — that string is unreachable in this build.
- A's matchlog: `DEATH … inst=M killer=B:mech killdmg=X`, `PLAYER_DEAD player=A:1 deaths=1`, no `SCORE type=2`.
- B's matchlog: `SCORE player=B:1 type=2 award=X kills=1` **plus** `DEATH … inst=R killer=0:0 killdmg=0.000`.
- **On screen:** A shows A K0/D1, B K0/D0. B shows B K1/D0 **and A K1/D0** (the phantom). *That divergence is the bug, reproduced on two machines.*
**Pass criteria after the fix — on BOTH screens, identical:**
| # | artefact | criterion |
|---|---|---|
| 1 | A `btl4.log` | `[death] VehicleDead(-1) dispatched to the owning player` present, **exactly one** `death cycle START`, and **zero** `SWALLOWED` (Step 5 removed the duplicate — the warning is a tripwire again) |
| 2 | A matchlog | `PLAYER_DEAD player=A:1 deaths=1 deathsCell=1`, ~15-35 ms before `DEATH … inst=M` (the working-case ordering, e.g. `69.117.98.17_matchlog_20260724_224438_16708.txt`) |
| 3 | B matchlog | exactly **one** `SCORE … type=2 award=X kills=1`; `award` == A's `killdmg` to 2 dp |
| 4 | **new** `TALLY` records (below) | on B: exactly **one** `TALLY field=kills target=B:1 inst=M new=1` and **no** `target=A:1` line. On A: exactly **one** `TALLY field=deaths target=A:1 inst=M new=1` |
| 5 | **new** `ROSTER` record (below) | after replication settles (≤1 update tick), **byte-identical roster lines on both nodes**: `A kills=0 deaths=1` and `B kills=1 deaths=0`, with `inst=M` on the local row and `inst=R` on the remote one |
| 6 | screen, both pods | Comm pilot list: **same two rows, same order, A 0/1, B 1/0.** Kill A again → both show A 0/2, B 2/0. Kill B once → both show A 1/2, B 2/1 |
| 7 | ram kill | the credited killer is the rammer and the award appears once, on the rammer's node only — and A's `deaths` still increments |
| 8 | control | the SCORE column agreed before this change and must still agree (it is the positive control for the record path) |
**Diagnostics to ADD — the current logging cannot show a remote tally at all, which is why the phantom kill is invisible in 88 logs and cost this investigation four independent traces:**
1. `game/reconstructed/btplayer.cpp:702` (kill) and `:436` (death) — a `TALLY` matchlog record at **every** counter write: `field=`, `target=<host>:<id>`, `inst=M|R` (`GetInstance()`), `new=<value>`. Unconditional (matchlog only arms on `-net`, matchlog.cpp:66-80, so solo stays silent).
2. `game/reconstructed/mechmppr.cpp:1265-1295` (tail of `FillPilotArray`) — a `ROSTER` record, one line per occupied slot: `slot=`, `player=<host>:<id>`, `inst=M|R`, `kills=`, `deaths=`, `score=`. **This is the artefact that would let one round's logs answer "what did the killer's screen actually show".** It also directly settles the one claim nobody measured: that the replicant `BTPlayer` copies really are the objects the rows read.
3. `game/reconstructed/btplayer.cpp:1216` (tail of `InitializePlayerLink`) — a `PLAYER_LINK … link=%p` record. `link=0` convicts #54 in one line.
4. Add checks 4/5 to `tools/matchcheck.py` as standing invariants: **`sum(TALLY field=kills) == count(SCORE type=2)`** and **every node's final `ROSTER` block is identical**. Those two make #45 regression-testable without a 5-pod session.
**Regression case of record:** the final round — 5 nodes, one player takes all three kills, three victims. Reproduce it at 3 nodes if 5 are unavailable; it is simultaneously "one node got all the kills" and "no node counted any death", the cleanest case in the corpus. Per the standing rule, the tracker entry reads **"fix landed, awaiting human verification"** until a human confirms both screens live.
---
## 5. RISKS (concrete, keyed to `context/reconstruction-gotchas.md`)
1. **Shadow field (#1) — the trap this change walks straight into.** The new member must **not** be named `deathCount`: `Player::deathCount` already exists (PLAYER.h) and a re-declaration shadows the base, reads `0xCDCDCDCD`, and mis-offsets everything after it. Name it `deaths`. Likewise `BTPlayer__UpdateRecord` must **not** re-declare `currentScore` or `dropZoneLocation`.
2. **Databinding trap (#3) + no offset lock.** Our `BTPlayer` layout is **not** offset-faithful — `killerName` is declared *after* `deathPending` even though it is `@0x1d0` (btplayer.hpp:396-409), and the `// @0x2xx` comments are documentation of the 1995 object, not of ours. So: (a) never raw-read `pilot+0x280`; go through `GetDeaths()`/`BTPilotDeaths` (btplayer.cpp:1547) — the pre-#43 raw reads are exactly what produced the blank/`-1` scoreboard; (b) **do not add a `static_assert` on `offsetof(BTPlayer, deaths) == 0x280`** — it will fail and it would be asserting a false claim. Lock what is real instead: `sizeof(BTPlayer__UpdateRecord)` and `sizeof(BTPlayer__UpdateRecord) > sizeof(Player__UpdateRecord)`.
3. **/FORCE trap (#6).** `Read/WriteUpdateRecord` are virtuals reached only through the vtable (`simulation->ReadUpdateRecord`, ENTITY.cpp:388). A declared-but-undefined override links "successfully" under `/FORCE` and AVs at a garbage target near `__ImageBase` on the **first replicated player update** — i.e. seconds into any MP session, far from the edit. **Grep the link log for unresolved `BTPlayer::*UpdateRecord` before running.**
4. **Record growth into a shared fixed buffer (#21 shape).** `Simulation::WriteSimulationUpdate` writes straight into the stream with no bounds check before the write (SIMULATE.cpp:311-321), backed by a single static `Update_Buffer[MAX_UPDATE_LENGTH>>2]`, **`MAX_UPDATE_LENGTH == 1400`** (ENTITY.cpp:472-474). A Player emits **one** record per update, so +8 bytes is ample headroom — but it is also the wire `messageLength` (ENTITY.cpp:589), so keep the pair to two ints and do not let this become the precedent for a fat player record.
5. **Wire-format skew.** The record is self-describing (`AdvancePointer(update->recordLength)`, ENTITY.cpp:390) but a **mixed-build session mis-parses the stream from that record onward** — a pod on the old build feeds the new one a short record. All pods must be on the same build for the test; state it in the test instructions.
6. **Dirty-bit mis-mapping (#20) — the change in Step 2 is inside that trap.** Confirm at review that the edit is `ForceUpdate()` (`updateModel@0x18`) and **not** any `simulationFlags@0x28` write. Also check whether the pre-existing `DelayWatchersFlag` that btplayer.cpp:453 has been setting since it shipped was silently suppressing a player-simulation watcher — removing it may *change* behaviour elsewhere (that is a fix, but it will look like a side effect).
7. **Do not remove the entity.** Nothing in this plan may issue `DestroyEntityMessage` on a death — the wreck stays (the P5 teardown crash).
8. **Respawn-handshake coupling.** `Player::deathCount` is the respawn identity gate (`message->deathCount == deathCount`, PLAYER.cpp:230) *and* the #57 latch's sequence number. Step 1 deliberately **leaves it alone** and only stops *displaying* it. Any temptation to "clean up" its increment site while in here will destabilise respawn — defer it to its own change with its own verification.
9. **Deleting `BTPlayerCountObservedDeath` is safe but must be complete.** It survives today only via the `extern` + the `friend` at btplayer.hpp:389; remove the definition, the call site, the extern and the friend together, or `/FORCE` hides the stragglers again.
10. **Deviation ledger.** Two intentional departures ship here — dropping btplayer.cpp:704 (a byte-faithful 1995 increment) and adding the update-record override the binary does not have. Both must land in `docs/RECONCILE.md` with tiers (`[T3]` intent, `[T1]` addresses) so a future reader does not "restore fidelity" and re-open #45.
**Residual unknowns, each with the one check that settles it.** (a) *Why `GetPlayerLink()` was NULL for those four deaths* — the `PLAYER_LINK`/`DEAD_NOTIFY` records of Step 0 in one 2-node run; `link=0` convicts it, `link!=0` with no `death cycle START` moves it to the handler. (b) *Whether the rerouted `BTPlayer` ScoreMessage is reliable-ordered on the Steam/relay seam* — nothing retries it, so a dropped packet loses a kill forever today; with Step 4 the record heals it, which is a further argument for replication. Confirm `Application::SendMessage` to `EntityManagerClientID` in `engine/MUNGA_L4/L4NET.CPP`. (c) *The printed column labels* — only needed to name the columns for the user, not to place the fields; read the Comm background pixmap or `reference/manual/Tesla40_BT_manual.pdf`'s Comm page. (d) *Whether the six corpus-wide excess kill awards (53 `SCORE type=2` vs 47 `DEATH inst=M`, all six outside the multi-node rounds) are a second double-scoring path* — the `TALLY` records of §4 answer it in one solo `BT_SPAWN_ENEMY` run; run it down before anyone claims "kills are awarded exactly once".
+57 -3
View File
@@ -319,6 +319,11 @@ void
// before we respawn, drop it now so the world can never stay black.
if (this == (BTPlayer *)application->GetMissionPlayer())
{ extern void BTWarpForceUnmask(); BTWarpForceUnmask(); }
// ABANDONED CYCLE -> release the latch (Gitea #57). Teardown swallows
// this death, so the pilot must not carry a set `deathPending` into the
// next round -- the pods live across rounds, so a flag stranded here
// silently swallowed every death of the following mission.
deathPending = 0; // this+0x290
Check_Fpu();
return;
}
@@ -366,9 +371,13 @@ void
// world-mask now so we don't stay black waiting for a reincarnation.
if (this == (BTPlayer *)application->GetMissionPlayer())
{ extern void BTWarpForceUnmask(); BTWarpForceUnmask(); }
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] VehicleDead: no DropZones group -- "
"respawn unavailable in this mission\n" << std::flush;
// ABANDONED CYCLE -> release the latch (Gitea #57): this mission
// can never respawn anyone, so holding the flag would swallow
// every later death silently instead of just failing this one.
deathPending = 0; // this+0x290
DEBUG_STREAM << "[respawn] no DropZones group -- respawn "
"unavailable in this mission; death cycle abandoned\n"
<< std::flush;
Check_Fpu();
return;
}
@@ -390,10 +399,21 @@ void
//
if (deathPending != 0)
{
// A death arriving while a cycle is already in flight is authentically
// deduped -- but until Gitea #57 this was also the SILENT failure mode
// for a stranded latch, and it logged nothing at all. Always-on now: if
// this ever fires twice for one pilot, the latch is stuck again.
DEBUG_STREAM << "[respawn] WARNING: death for player "
<< BTMatchHostOf(GetEntityID()) << ":" << (int)GetEntityID()
<< " SWALLOWED -- a death cycle is already pending (deaths so far "
<< deathCount << ")\n" << std::flush;
Check_Fpu();
return;
}
deathPending = 1; // this+0x290
DEBUG_STREAM << "[respawn] player " << BTMatchHostOf(GetEntityID())
<< ":" << (int)GetEntityID() << " death cycle START (death #"
<< (deathCount + 1) << ") -- drop-zone hunt in 5s\n" << std::flush;
// WARP (task #52): our OWN death = the engine's Idle -> InitialCollapse (trigger
// becomes == control state, POVTranslocateRenderable). Collapse the eye-centred
@@ -1223,6 +1243,9 @@ void
{
if (GetSimulationState() == MissionEndingState) // state == 4
{
// ABANDONED CYCLE -> release the latch (Gitea #57): the reply arrived
// during teardown so no reset will happen; do not strand the flag.
deathPending = 0; // this+0x290
Check_Fpu();
return;
}
@@ -1239,6 +1262,9 @@ void
if (!(mech != 0 && mech->GetClassID() == Mech::MechClassID
&& mech->IsMechDestroyed()))
{
// Already alive (a duplicate reply, or the 2s probe after a completed
// reset) -> the cycle is DONE, so the latch must be down (Gitea #57).
deathPending = 0; // this+0x290
Check_Fpu();
return;
}
@@ -1261,6 +1287,34 @@ void
Mech *mech = (Mech *)playerVehicle;
mech->Reset(message->dropZoneLocation, 1 /* True */); // FUN_0049fb74 (heal+move)
//
// THE RESPAWN IS NOW COMPLETE -- release the death-cycle latch HERE
// (Gitea #57). Previously `deathPending` was cleared in exactly one
// place: the `deathCount != -1` re-post branch (:343), and only if that
// re-post happened to find the mech already alive. So the clear
// depended on a LATER message arriving after the reset:
// * if the +5s re-post landed BEFORE this reply (a respawn slower than
// 5s), it saw the mech still dead, did NOT clear, and fell through to
// another hunt -- and nothing cleared the flag afterwards;
// * if no further re-post/probe arrived at all, the flag simply stayed
// set while the pilot was alive and fighting.
// Either way the NEXT death hit the `deathPending != 0` dedup at :391 and
// was swallowed whole -- no warp, no PLAYER_DEAD, no drop-zone hunt --
// leaving the pilot driving a dead mech for the rest of the process.
// That is the field bug: tonight 3 of 3 deaths in the final round were
// swallowed, each by a player who had respawned successfully earlier in
// the same process. The cycle's completion is HERE, so clear it here.
//
deathPending = 0; // this+0x290
DEBUG_STREAM << "[respawn] player " << BTMatchHostOf(GetEntityID())
<< ":" << (int)GetEntityID() << " RESET at drop zone (death #"
<< deathCount << ") -- death cycle complete, latch cleared\n"
<< std::flush;
if (BTMatchLogActive())
BTMatchLog("RESPAWN", "player=%d:%d deaths=%d alive=%d",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(), deathCount,
(mech->IsMechDestroyed() ? 0 : 1));
// WARP (task #52): our OWN respawn = the engine's WaitForReincarnate -> Expand
// release (POVTranslocateRenderable): drop the SetIsDead world mask and blast
// the eye-centred sphere open to reveal the reborn world. LOCAL player only (a
+36
View File
@@ -664,8 +664,44 @@ void dpl2d_DrawTexturedRect(
device->SetFVF(kVertexTexFVF);
device->SetTexture(0, (IDirect3DTexture9 *)texture);
//
// TEXTURE STAGE OPS -- explicitly, every time (Gitea #58). Binding a
// texture is NOT enough: the fixed-function stage still has whatever
// COLOROP/ALPHAOP the last drawer left, and this TU's own list path runs
// UNtextured (`SetTexture(0, 0)`), so nothing here establishes them. In
// particular `L4D3D.cpp:1085-1088` sets
// COLOROP = ALPHAOP = D3DTOP_SELECTARG1 with ARG1 = D3DTA_TFACTOR
// which makes the pipeline ignore the texture completely and fill with a
// constant colour -- i.e. the callsign plate renders as a SOLID RECTANGLE.
// Whether that state happens to be live depends on what the 3D scene drew
// last, which is why the field report was "sometimes, depends on the level
// and the background". MODULATE texture x diffuse for BOTH channels makes
// the draw deterministic: colour = texel * diffuse, alpha = texel * diffuse,
// so a 0x0000 texel (the transparent background of the 1bpp raster upload,
// L4VIDEO.cpp LoadBitSliceTexture) stays invisible.
//
DWORD oCOp = 0, oCA1 = 0, oCA2 = 0, oAOp = 0, oAA1 = 0, oAA2 = 0;
device->GetTextureStageState(0, D3DTSS_COLOROP, &oCOp);
device->GetTextureStageState(0, D3DTSS_COLORARG1, &oCA1);
device->GetTextureStageState(0, D3DTSS_COLORARG2, &oCA2);
device->GetTextureStageState(0, D3DTSS_ALPHAOP, &oAOp);
device->GetTextureStageState(0, D3DTSS_ALPHAARG1, &oAA1);
device->GetTextureStageState(0, D3DTSS_ALPHAARG2, &oAA2);
device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE);
device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
device->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE);
device->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_MODULATE);
device->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_TEXTURE);
device->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_DIFFUSE);
device->DrawPrimitiveUP(D3DPT_TRIANGLESTRIP, 2, quad, sizeof(VertexTex));
device->SetTextureStageState(0, D3DTSS_COLOROP, oCOp);
device->SetTextureStageState(0, D3DTSS_COLORARG1, oCA1);
device->SetTextureStageState(0, D3DTSS_COLORARG2, oCA2);
device->SetTextureStageState(0, D3DTSS_ALPHAOP, oAOp);
device->SetTextureStageState(0, D3DTSS_ALPHAARG1, oAA1);
device->SetTextureStageState(0, D3DTSS_ALPHAARG2, oAA2);
device->SetTexture(0, oldTex);
if (oldTex) oldTex->Release();
device->SetFVF(kVertex2DFVF);