A pilot leaving mid-refresh no longer takes the room with them

From the playtest dump: an access violation reading address zero inside
strncpy, two minutes into a session, dispatched from a window message -
WM_TIMER, wParam 7.

Seven is kLobbyTimerId. That timer runs every 750ms while hosting or
seated, and its work is RefreshRoster -> RPL4Lobby_RosterLines ->
CollectMembers, which reads each member's row out of the lobby.

CollectMembers took Steam's answers straight as strings: eleven strncpy
and atoi calls fed directly from GetLobbyMemberData with nothing between.
Steam answers NULL - not "" - for a member it no longer knows, and the
roster is polled on a timer, so somebody leaving between
GetNumLobbyMembers and the read of their row is ordinary rather than
exotic. strncpy then walks off address zero, which is exactly the
faulting instruction.

The file already knew this: LobbyText() has guarded the lobby-wide reads
from the start, and all six other direct readers test for NULL. Only the
member rows went unguarded. So they get the matching accessor,
MemberText(), and CollectMembers goes through it - which covers the two
atoi calls as well, since atoi(NULL) reads address zero just as happily.

Not confirmed frame by frame: the dump is a tester's own build
(D:\Games\RP412, stamped 13 Aug 16:33) and no matching pdb came with it,
so the frames resolve only as offsets. What is symbol-independent is the
message number and the timer id, read straight off the stack; from there
this is the one path in that handler that does an unguarded strncpy on a
pointer Steam is allowed to return NULL for. The call depth after
inlining matches too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-13 20:50:11 -05:00
co-authored by Claude Opus 5
parent aaedbd1043
commit 835580ab91
+33 -16
View File
@@ -293,6 +293,26 @@ namespace
return (text != NULL) ? text : "";
}
//-------------------------------------------------------------------
// The same for one member's own row.
//
// Steam answers NULL - not "" - for a member it no longer knows,
// and the roster is polled on a 750ms timer, so somebody leaving
// between GetNumLobbyMembers and the read of their row is ordinary
// rather than exotic. Every caller treats the answer as a string:
// strncpy walks off address zero, and so does atoi.
//-------------------------------------------------------------------
const char *MemberText(CSteamID member, const char *key)
{
if (!gInLobby)
{
return "";
}
const char *text =
SteamMatchmaking()->GetLobbyMemberData(gLobby, member, key);
return (text != NULL) ? text : "";
}
//---------------------------------------------------------------
// Launch: roster into lobby data, peers into the transport, and
// the hosted-race path primed on the owner
@@ -328,39 +348,36 @@ namespace
memset(member, 0, sizeof(*member));
member->id = SteamMatchmaking()->GetLobbyMemberByIndex(gLobby, i);
strncpy(member->ip,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "ip"),
MemberText(member->id, "ip"),
sizeof(member->ip) - 1);
member->consolePort = atoi(
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "cp"));
member->gamePort = atoi(
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "gp"));
member->consolePort = atoi(MemberText(member->id, "cp"));
member->gamePort = atoi(MemberText(member->id, "gp"));
strncpy(member->name,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "nm"),
MemberText(member->id, "nm"),
sizeof(member->name) - 1);
strncpy(member->vehicle,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "vh"),
MemberText(member->id, "vh"),
sizeof(member->vehicle) - 1);
strncpy(member->color,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "cl"),
MemberText(member->id, "cl"),
sizeof(member->color) - 1);
strncpy(member->badge,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "bd"),
MemberText(member->id, "bd"),
sizeof(member->badge) - 1);
strncpy(member->team,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "tm"),
MemberText(member->id, "tm"),
sizeof(member->team) - 1);
strncpy(member->position,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "ps"),
MemberText(member->id, "ps"),
sizeof(member->position) - 1);
strncpy(member->netRev,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kNetRevKey),
MemberText(member->id, kNetRevKey),
sizeof(member->netRev) - 1);
strncpy(member->build,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kBuildKey),
MemberText(member->id, kBuildKey),
sizeof(member->build) - 1);
member->camera = (atoi(
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kCamKey)) != 0)
? True : False;
member->camera =
(atoi(MemberText(member->id, kCamKey)) != 0) ? True : False;
member->published =
member->ip[0] != '\0' && member->consolePort > 0 && member->gamePort > 0;
}