Gitea #43: the Comm pilot list now shows EVERY pilot -- three stacked fixes

1. Roster count used the binary's raw entry+0x29&0x40 read on our compiled
   objects (databinding trap) -> garbage latched pilotCount=1.  Decoded: the
   flag is Player::NonScoringPlayerFlag (bit 14 = Entity::NextBit); both
   loops now use IsScoringPlayer().  Also closes the pilotIDs[1] overrun.
2. The build-once latch races async replicant arrival (rig-proven 1-then-2):
   rebuild on scoring-census change [T3 accommodation, demand-latch
   precedent]; also un-dangles departed peers.  Forensic: '[score] pilot
   roster built: N'.
3. Name icons were a NULL stub + zeros blank authentically -> rows invisible.
   Wired BTPilotNameBitmap (playerBitmapIndex -> Mission small callsign
   raster, the radar-label chain); replaces the raw pilot+0x1e0 key.

Rig-verified: Aeolus + Boreas rows with callsigns on both nodes incl. the
staggered-start race; solo unregressed (1 scoring pilot).

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-24 17:51:52 -05:00
co-authored by Claude Opus 5
parent 7494e2ec6c
commit b7107b1020
4 changed files with 91 additions and 31 deletions
+23 -7
View File
@@ -304,13 +304,29 @@ screen paint over the shared Eng plane, pinning it on the highest screen). Pixel
`BT_PRESET_TEST=<frame>`, `BT_CTRLMAP_LOG`.
## pilotList (Comm KILLS/DEATHS) row semantics + the 1 [T1/T2]
**BUG (Gitea #43, 2026-07-24, rig-reproduced): in LIVE MP only the LOCAL row renders — remote
players never get a row.** The "2-player MP = 2 rows" below is decomp row SEMANTICS [T1], not a
verified MP observation (the audit's live evidence was solo-only). Suspect:
`BuildPilotArray`'s count loop (mechmppr.cpp:1133) sizes the roster via the binary's RAW
`entry+0x29 & 0x40` spectator-flag read on our compiled objects (databinding trap; the fill
loop's same check was already TODO-removed) → pilotCount latches 1 (build-once latch),
`GetPilot(1)` bounds-rejects the remote. Fix + MP re-verify pending — see the issue.
**FIXED + RIG-VERIFIED (Gitea #43, 2026-07-24; awaiting live playtest).** Field report
("never lists anyone in MP") reproduced on a 2-node rig, THREE stacked causes, all fixed:
1. **Raw-flag miscount**: `BuildPilotArray`'s count loop used the binary's raw `entry+0x29 &
0x40` read on our compiled objects (databinding trap) → garbage → roster latched at 1 row.
DECODED: +0x29 bit 0x40 = bit 14 of `simulationFlags` @+0x28 = **`Player::
NonScoringPlayerFlag`** (= `Entity::NextBit`, PLAYER.h:392) — the roster authentically
lists SCORING players (flag cleared when the mech links its player, mech.cpp:688, all
nodes). Both loops now use `Player::IsScoringPlayer()`; also closes a `pilotIDs[1]` heap
overrun the undercount exposed.
2. **Latch race**: the binary's build-once latch assumes 1995 synchronous pod loads; over
async relay TCP the faster node ticks InterpretControls before the peer's replicants
arrive (rig-proven: staggered node logged roster 1 → then peer arrival). [T3
accommodation, demand-latch precedent]: rebuild whenever the scoring CENSUS changes —
also un-dangles a departed peer's freed Player from the roster. Forensic: always-on
`[score] pilot roster built: N scoring pilot(s)` per census change.
3. **Invisible rows**: `LookupPlayerNameBitmap` was a NULL stub + numerals are authentic
`signedBlankedZerosFormat` (zeros draw blank) → an unselected 0/0 row rendered as a black
box: invisible even with a correct roster. Wired `BTPilotNameBitmap` (btplayer.cpp bridge:
compiled `playerBitmapIndex` → `Mission::GetSmallNameBitmap`, the same 64×16 egg rasters
the radar labels use; replaces the binary's raw pilot+0x1e0 key read). Rig-verified:
Aeolus (local, center box) + Boreas (remote, roster strip) both render with callsigns.
NOTE: rows need the egg to carry name bitmaps (operator-console eggs do; bare rig eggs
like MP2.EGG don't → authentic cache-miss blank box).
One ROW PER PILOT in the mission (2-player MP = 2 rows — not duplicate displays). KILLS =
`killCount` (the victim's ScoreMessageHandler credits the shooter cross-player, works for both
rows); DEATHS = `Player::deathCount`: engine-inits to **2** (PLAYER.cpp:759), the LOCAL
+6 -7
View File
@@ -299,11 +299,11 @@ extern void *BTResolveRosterPilot(int slot);
extern int BTPilotKills(void *pilot);
extern int BTPilotDeaths(void *pilot);
extern int BTPilotIsSelected(void *pilot, void *local_player); // SELECT-TARGET highlight (safe)
// App+0xC8 player-name-bitmap cache is not wired in the port (same deferral
// PlayerStatus uses for its nameImage) -> NULL routes DrawMechIcon to the binary's
// own cache-miss branch (the tinted name box), never an AV.
static BitMap *LookupPlayerNameBitmap(int /*name_id*/) { return NULL; }
// Gitea #43: the pilot's SMALL callsign raster (Mission small-bitmap chain via
// the pilot's compiled playerBitmapIndex -- bridge in btplayer.cpp). Replaces
// the port's old NULL-stub cache (which left every row an invisible blank box)
// AND the binary's raw pilot+0x1e0 key read (our compiled layout differs).
extern BitMap *BTPilotNameBitmap(void *pilot);
MethodDescription
PilotList::methodDescription =
@@ -429,8 +429,7 @@ Logical
void
PilotList::DrawMechIcon(void *pilot, int selected)
{
int nameId = *(int *)((char *)pilot + 0x1e0); // pilot name/id key
BitMap *bmp = LookupPlayerNameBitmap(nameId);
BitMap *bmp = BTPilotNameBitmap(pilot); // small callsign raster
int boxColor = selected ? 0xff : 0;
int blitColor = selected ? 0 : 0xff;
if (bmp == NULL)
+17
View File
@@ -1497,6 +1497,23 @@ int BTPilotDeaths(void *pilot)
int deaths = ((BTPlayer *)pilot)->GetDeaths();
return (deaths < 0) ? 0 : deaths;
}
// Gitea #43 completion: the pilot-list NAME icon. The binary blits the
// pilot's SMALL callsign raster -- the same 64x16 egg bitmaps the radar
// labels use (Mission's small-bitmap chain, keyed 1-based by the egg
// 'bitmapindex') -- but the port's PilotList had a NULL-stub lookup, so a
// roster row was a blank box + blanked-zero numerals: invisible until the
// pilot scored. Camera players carry index -1 (PLAYER.cpp:744) -> NULL ->
// DrawMechIcon's authentic cache-miss box branch.
BitMap *BTPilotNameBitmap(void *pilot)
{
if (pilot == 0 || application == 0)
return 0;
Mission *mission = application->GetCurrentMission();
int index = ((BTPlayer *)pilot)->playerBitmapIndex;
if (mission == 0 || index <= 0)
return 0;
return mission->GetSmallNameBitmap(index);
}
// OBSERVED-DEATH tally (MP DEATHS fix): each node maintains every player's
// score from LOCALLY OBSERVED events -- exactly how the KILLS credit already
+45 -17
View File
@@ -1110,18 +1110,33 @@ void
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// @004b0600 -- build the pilot roster once, lazily, from the application's
// "Players" group (skipping spectators flagged 0x40).
// "Players" group, counting SCORING players only. The binary's raw check
// `(entry+0x29 & 0x40) == 0` decoded (Gitea #43): +0x29 is byte 1 of the
// simulationFlags LWord @+0x28, so 0x40 there == bit 14 == Entity::NextBit ==
// Player::NonScoringPlayerFlag (PLAYER.h:392). Players START non-scoring
// (DefaultFlags) and become scoring when their mech links its owning player
// (mech.cpp:688 / btplayer.cpp:1210) -- so camera ships and unseated players
// never get a roster row. Read via the named accessor, NOT the raw offset
// (our compiled layout differs -- the raw read is the databinding trap and
// was THE #43 bug: garbage count latched the roster at 1 row in live MP).
//
void
MechControlsMapper::BuildPilotArray()
{
if (pilotArrayBuilt)
{
return;
}
pilotArrayBuilt = True;
pilotCount = 0;
//
// Scoring census. The binary latches the roster on the FIRST call
// (pilotArrayBuilt, build-once) -- correct on 1995 pod hardware where
// every pod's entities exist before any mission tick. In the port the
// peers' Player/PlayerLink messages arrive over async TCP, and the rig
// proved a frame-level race (Gitea #43): the faster-loading node ticks
// InterpretControls once BEFORE the net queue creates the replicants,
// latching a 1-row roster forever. [T3 accommodation, precedent =
// the demand-latch]: re-run the build whenever the SCORING census
// changes -- late-arriving pilots get their row, and a departed peer's
// destroyed Player leaves the roster instead of dangling (the latched
// design kept a freed pointer for the rest of the mission).
//
int scoring = 0;
EntityGroup *players =
application->GetEntityManager()->FindGroup("Players"); // @0050f44b
@@ -1130,9 +1145,9 @@ void
ChainIteratorOf<Node*> pilots(players->groupMembers); // FUN_00421414
for (Node *entry; (entry = pilots.ReadAndNext()) != 0; )
{
if ((*(byte *)((int)entry + 0x29) & 0x40) == 0) // not a spectator
if (((Player *)entry)->IsScoringPlayer()) // binary: (+0x29 & 0x40) == 0
{
++pilotCount;
++scoring;
}
}
}
@@ -1141,10 +1156,18 @@ void
// an 8-pod game fits). pilotCount beyond that would overrun the block in
// the binary too -- clamp so the `<= pilotCount` zero-loop (which zeroes
// the local slot 0 PLUS pilotCount remotes, faithful) stays in bounds.
if (pilotCount > PilotArraySlots - 1)
pilotCount = PilotArraySlots - 1;
if (scoring > PilotArraySlots - 1)
scoring = PilotArraySlots - 1;
pilotIDs = new int[pilotCount]; // FUN_004022b0
if (pilotArrayBuilt && scoring == pilotCount)
{
return; // steady state -- the binary's built-once path
}
pilotArrayBuilt = True;
pilotCount = scoring;
delete[] pilotIDs; // 0 on first build (ctor)
pilotIDs = new int[pilotCount > 0 ? pilotCount : 1]; // FUN_004022b0
for (int i = 0; i <= pilotCount; ++i)
{
@@ -1153,6 +1176,10 @@ void
FillPilotArray(); // FUN_004b06cc
ChooseDefaultPilot(); // FUN_004b07f0
// Always-on forensic (Gitea #43): one line per census change.
DEBUG_STREAM << "[score] pilot roster built: " << pilotCount
<< " scoring pilot(s)" << std::endl;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -1203,10 +1230,11 @@ void
int slot = 1;
for (Node *entry; (entry = pilots.ReadAndNext()) != 0; )
{
// TODO(mp-roster): the binary skips spectators via a flag byte at
// entry+0x29 (bit 0x40) -- its WinTesla home is unmapped; solo has no
// spectators, so the check is omitted until the flag is reconciled.
if (entry == (Node *)local_pilot) // already slot 0
// Binary @004b0790: skip non-scoring players (the same +0x29 & 0x40
// == NonScoringPlayerFlag check as the count loop -- resolved, Gitea
// #43) and the local pilot (already slot 0).
if (!((Player *)entry)->IsScoringPlayer()
|| entry == (Node *)local_pilot)
{
continue;
}