Conn Man crashed twice in an Owens on 2026-07-27, kept playing, and by the time
we asked for his logs they no longer existed. Not a mystery: every launcher did
if exist content\X.old.log del content\X.old.log
if exist content\X.log ren content\X.log X.old.log
which keeps TWO generations. He relaunched more than twice, so his own bat
deleted both crash sessions. The player who crashes is exactly the player who
relaunches immediately, so the retention window was aimed at the wrong case.
THE LOG. BT_LOG set still means "use this path verbatim" -- that contract is
load-bearing (mp_a.log/mp_b.log for 2-node runs from one cwd, btoperator's
operator_N.log, every scratchpad/mp_*.sh that greps the file it named) and is
untouched. Only when BT_LOG is UNSET does the exe now name the file itself:
content\<stem>_YYYYMMDD.log, appended, one per day, no rotation window to fall
out of. The stem (solo/join/steam/joyconfig) comes from the gates the bat
already sets, so solo still cannot overwrite the MULTIPLAYER log.
Self-named files append UNCONDITIONALLY. The default open mode is ios::out --
TRUNCATE -- and the operator GUI's exported bats never set BT_LOG_APPEND, so
they were truncating already; leaving append implicit would have let the second
launch of the day erase the morning.
IDENTITY, because these arrive from many players at once and Discord renames
half of them to message.txt (most of the night-5 evidence had to be
re-identified by hand):
===== BT411 SESSION build=4.11.603 (d64d75f+) machine=... user=...
callsign=Conn Man mode=solo pid=13192 local=... log=... =====
Also the session separator inside a per-day file, and it puts build+hash ABOVE
any [crash] block so btl4+0xNNNN offsets stay matchable to a PDB across builds.
SIZE. Never delete, but stay sendable: past 8 MB the next launch rolls to
<stem>_YYYYMMDD.1.log. Checked only at open, so a live session is never split.
CONSOLIDATION. launch_report.txt is gone, folded into lastrun_<stem>.txt (one
launch record: the exe appends its block at first breath, the bat appends the
exit line). Per-stem because one shared lastrun.txt let a second launcher's
`del` destroy the first launch's block. Its ABSENCE after a run is now the
#41 "never reached WinMain" probe -- the old "if not exist X.log" test cannot
work against a per-day file, which survives earlier launches, and would have
reported every healthy run as blocked by antivirus. marshal.log folded into
the day log too; it keeps its own handle and gained a CRITICAL_SECTION because
it runs on a worker thread while the engine log stream is single-threaded.
play_steam.bat gained a launch bracket it never had -- which is why a Steam
player killed before WinMain previously left no evidence at all.
_putenv_s, not SetEnvironmentVariableA, to pin the resolved name: MSVC's CRT
keeps its own environment copy, so getenv() in-process does NOT see a
SetEnvironmentVariableA write (measured). btl4console/btl4lobby resolve the
day log via getenv, so with the Win32-only call they silently fell back to
marshal.log and the fold never happened.
logfile.open() is now checked -- a failed open used to rebind cout to a dead
buffer, silently dropping the header, [boot] and any crash stack while
lastrun still claimed log=<name>.
Verified: append across sessions with distinct pids, crash block landing under
its own header, BT_LOG verbatim unmangled, an inherited BT_LOG cleared by the
bats, roll-over at 8 MB, and both forensic verdicts (exe ran / exe blocked).
Console auto-retrieval is unaffected -- it uploads the matchlog, a different
file, and a crashed round never reaches the upload call anyway.
Known gaps, deliberately not in this commit: the setlocal hoist for gate
leakage when two bats share one console (dev-only), and btoperator's exported
bats still lack the lastrun bracket -- do not press Export on a shipped zip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
484 lines
14 KiB
C++
484 lines
14 KiB
C++
//###########################################################################
|
|
// btl4lobby -- the Steam lobby (BT_STEAM only; compiled only when the gate
|
|
// is on -- see CMakeLists.txt). Design: btl4lobby.hpp.
|
|
//###########################################################################
|
|
|
|
#include "btl4lobby.hpp"
|
|
|
|
#include <windows.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
|
|
#pragma pack(push, 8)
|
|
#include "steam/steam_api.h"
|
|
#include "steam/isteammatchmaking.h"
|
|
#pragma pack(pop)
|
|
|
|
//
|
|
// The engine-side transport surface (munga_engine).
|
|
//
|
|
extern int BTSteamNet_Install();
|
|
extern int BTSteamNet_Active();
|
|
extern unsigned long long BTSteamNet_MySteamID();
|
|
|
|
static FILE *lobbyLogFile = NULL;
|
|
static void
|
|
LobbyLog(const char *format, ...)
|
|
{
|
|
if (lobbyLogFile == NULL)
|
|
{
|
|
// Same target as MarshalLog (btl4console.cpp): the per-day log whose
|
|
// resolved name btl4main pinned into BT_LOG, so lobby lines land in the
|
|
// one file players are asked for. Old filename kept as the fallback.
|
|
const char *day_log = getenv("BT_LOG");
|
|
lobbyLogFile = fopen((day_log && *day_log) ? day_log : "marshal.log", "at");
|
|
if (lobbyLogFile == NULL)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
va_list arguments;
|
|
va_start(arguments, format);
|
|
fprintf(lobbyLogFile, "[lobby] ");
|
|
vfprintf(lobbyLogFile, format, arguments);
|
|
fprintf(lobbyLogFile, "\n");
|
|
fflush(lobbyLogFile);
|
|
va_end(arguments);
|
|
}
|
|
#include <stdarg.h>
|
|
|
|
//###########################################################################
|
|
// Synchronous Steam call-result helper (manual polling -- no callback
|
|
// template machinery in this C-style TU).
|
|
//###########################################################################
|
|
|
|
static int
|
|
WaitApiCall(SteamAPICall_t call, void *result, int result_size,
|
|
int expected_callback_id, int timeout_ms)
|
|
{
|
|
ISteamUtils *utils = SteamUtils();
|
|
for (int waited = 0; waited < timeout_ms; waited += 50)
|
|
{
|
|
SteamAPI_RunCallbacks();
|
|
bool failed = false;
|
|
if (utils->IsAPICallCompleted(call, &failed))
|
|
{
|
|
if (failed)
|
|
{
|
|
return -1;
|
|
}
|
|
return utils->GetAPICallResult(call, result, result_size,
|
|
expected_callback_id, &failed) && !failed ? 0 : -1;
|
|
}
|
|
Sleep(50);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
//###########################################################################
|
|
// Member data
|
|
//###########################################################################
|
|
|
|
static CSteamID currentLobby;
|
|
|
|
static void
|
|
PublishSelf(const char *pilot_name, const char *vehicle, const char *color)
|
|
{
|
|
//
|
|
// Identity is implicit (the member's SteamID); the roster TOKENS are
|
|
// minted by the host at GO time -- only the pilot identity publishes.
|
|
//
|
|
ISteamMatchmaking *matchmaking = SteamMatchmaking();
|
|
matchmaking->SetLobbyMemberData(currentLobby, "nm", pilot_name);
|
|
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
|
|
matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
|
|
}
|
|
|
|
static int
|
|
ReadMember(CSteamID lobby, int index, BTLobbyMember *out)
|
|
{
|
|
ISteamMatchmaking *matchmaking = SteamMatchmaking();
|
|
CSteamID member = matchmaking->GetLobbyMemberByIndex(lobby, index);
|
|
const char *published_name = matchmaking->GetLobbyMemberData(lobby, member, "nm");
|
|
if (published_name == NULL || published_name[0] == 0)
|
|
{
|
|
return -1; // not published yet
|
|
}
|
|
memset(out, 0, sizeof(*out));
|
|
out->steamID = member.ConvertToUint64();
|
|
out->consolePort = 1501; // token ports (see L4STEAMNET token table)
|
|
out->gamePort = 1502;
|
|
strncpy(out->name,
|
|
matchmaking->GetLobbyMemberData(lobby, member, "nm"),
|
|
sizeof(out->name) - 1);
|
|
strncpy(out->vehicle,
|
|
matchmaking->GetLobbyMemberData(lobby, member, "vh"),
|
|
sizeof(out->vehicle) - 1);
|
|
strncpy(out->color,
|
|
matchmaking->GetLobbyMemberData(lobby, member, "cl"),
|
|
sizeof(out->color) - 1);
|
|
out->isSelf = (member == SteamUser()->GetSteamID());
|
|
if (out->name[0] == 0)
|
|
{
|
|
strcpy(out->name, "Pilot");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
//###########################################################################
|
|
// The room screen -- the FE's green-on-black style; lists members, host
|
|
// ENTER = GO, ESC = cancel.
|
|
//###########################################################################
|
|
|
|
static int roomIsHost = 0;
|
|
static int roomResult = 0; // 0 pending, 1 go, -1 cancel
|
|
static RECT roomLaunchRect = { 140, 250, 420, 296 };
|
|
static RECT roomLeaveRect = { 140, 306, 300, 336 };
|
|
|
|
static void
|
|
PaintRoom(HWND window)
|
|
{
|
|
PAINTSTRUCT paint;
|
|
HDC dc = BeginPaint(window, &paint);
|
|
RECT client;
|
|
GetClientRect(window, &client);
|
|
HBRUSH background = CreateSolidBrush(RGB(4, 12, 4));
|
|
FillRect(dc, &client, background);
|
|
DeleteObject(background);
|
|
SetBkMode(dc, TRANSPARENT);
|
|
|
|
HFONT font = CreateFontW(-16, 0, 0, 0, FW_NORMAL, 0, 0, 0,
|
|
DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, FIXED_PITCH, L"Consolas");
|
|
HFONT old_font = (HFONT)SelectObject(dc, font);
|
|
SetTextColor(dc, RGB(90, 255, 90));
|
|
|
|
//
|
|
// The RP412 room look (RP_L4/RPL4LOBBY.cpp): pilot roster with
|
|
// loadouts, a framed LAUNCH button for the owner, LEAVE for all.
|
|
//
|
|
WCHAR line[160];
|
|
int y = 16;
|
|
wsprintfW(line, L"STEAM LOBBY -- %s",
|
|
roomIsHost ? L"YOU ARE HOSTING" : L"WAITING FOR THE HOST");
|
|
RECT row = { 24, y, client.right - 24, y + 26 };
|
|
DrawTextW(dc, line, -1, &row, DT_LEFT | DT_TOP | DT_SINGLELINE);
|
|
y += 44;
|
|
|
|
ISteamMatchmaking *matchmaking = SteamMatchmaking();
|
|
int count = matchmaking->GetNumLobbyMembers(currentLobby);
|
|
for (int i = 0; i < count && i < 8; ++i)
|
|
{
|
|
BTLobbyMember member;
|
|
WCHAR name[120];
|
|
if (ReadMember(currentLobby, i, &member) == 0)
|
|
{
|
|
int n = 0;
|
|
for (const char *s = member.name; *s && n < 60; ++s) name[n++] = (WCHAR)*s;
|
|
name[n] = 0;
|
|
wsprintfW(line, L" %d. %-16s %hs, %hs%s", i + 1, name,
|
|
member.vehicle[0] ? member.vehicle : "mech",
|
|
member.color[0] ? member.color : "-",
|
|
member.isSelf ? L" (you)" : L"");
|
|
}
|
|
else
|
|
{
|
|
wsprintfW(line, L" %d. (joining...)", i + 1);
|
|
}
|
|
RECT member_row = { 24, y, client.right - 24, y + 24 };
|
|
DrawTextW(dc, line, -1, &member_row, DT_LEFT | DT_TOP | DT_SINGLELINE);
|
|
y += 26;
|
|
}
|
|
|
|
//
|
|
// Framed buttons (hit rects shared with the WndProc).
|
|
//
|
|
HBRUSH frame = CreateSolidBrush(RGB(90, 255, 90));
|
|
if (roomIsHost)
|
|
{
|
|
FrameRect(dc, &roomLaunchRect, frame);
|
|
DrawTextW(dc, L"L A U N C H M I S S I O N", -1, &roomLaunchRect,
|
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
|
}
|
|
FrameRect(dc, &roomLeaveRect, frame);
|
|
DrawTextW(dc, L"LEAVE LOBBY", -1, &roomLeaveRect,
|
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
|
DeleteObject(frame);
|
|
|
|
SelectObject(dc, old_font);
|
|
DeleteObject(font);
|
|
EndPaint(window, &paint);
|
|
}
|
|
|
|
static LRESULT CALLBACK
|
|
RoomWndProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam)
|
|
{
|
|
switch (message)
|
|
{
|
|
case WM_PAINT:
|
|
PaintRoom(window);
|
|
return 0;
|
|
case WM_TIMER:
|
|
SteamAPI_RunCallbacks();
|
|
//
|
|
// Members leave when the host signals GO.
|
|
//
|
|
if (!roomIsHost)
|
|
{
|
|
const char *go = SteamMatchmaking()->GetLobbyData(currentLobby, "btl4go");
|
|
if (go != NULL && go[0] == '1')
|
|
{
|
|
roomResult = 1;
|
|
}
|
|
}
|
|
InvalidateRect(window, NULL, FALSE);
|
|
return 0;
|
|
case WM_KEYDOWN:
|
|
if (wparam == VK_RETURN && roomIsHost)
|
|
{
|
|
roomResult = 1;
|
|
}
|
|
else if (wparam == VK_ESCAPE)
|
|
{
|
|
roomResult = -1;
|
|
}
|
|
return 0;
|
|
case WM_LBUTTONDOWN:
|
|
{
|
|
int x = (int)(short)LOWORD(lparam);
|
|
int y = (int)(short)HIWORD(lparam);
|
|
if (roomIsHost &&
|
|
x >= roomLaunchRect.left && x < roomLaunchRect.right &&
|
|
y >= roomLaunchRect.top && y < roomLaunchRect.bottom)
|
|
{
|
|
roomResult = 1;
|
|
}
|
|
else if (x >= roomLeaveRect.left && x < roomLeaveRect.right &&
|
|
y >= roomLeaveRect.top && y < roomLeaveRect.bottom)
|
|
{
|
|
roomResult = -1;
|
|
}
|
|
}
|
|
return 0;
|
|
case WM_CLOSE:
|
|
roomResult = -1;
|
|
return 0;
|
|
}
|
|
return DefWindowProcW(window, message, wparam, lparam);
|
|
}
|
|
|
|
static int
|
|
RunRoom(int is_host)
|
|
{
|
|
roomIsHost = is_host;
|
|
roomResult = 0;
|
|
|
|
WNDCLASSW window_class;
|
|
memset(&window_class, 0, sizeof(window_class));
|
|
window_class.lpfnWndProc = RoomWndProc;
|
|
window_class.hInstance = GetModuleHandleW(NULL);
|
|
window_class.hCursor = LoadCursorW(NULL, (LPCWSTR)IDC_ARROW);
|
|
window_class.lpszClassName = L"BTLobbyRoomWnd";
|
|
RegisterClassW(&window_class);
|
|
|
|
HWND window = CreateWindowW(
|
|
L"BTLobbyRoomWnd", L"BattleTech - Steam Lobby",
|
|
WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU,
|
|
160, 160, 560, 360,
|
|
NULL, NULL, GetModuleHandleW(NULL), NULL);
|
|
if (window == NULL)
|
|
{
|
|
return -1;
|
|
}
|
|
SetTimer(window, 1, 250, NULL);
|
|
ShowWindow(window, SW_SHOW);
|
|
SetForegroundWindow(window);
|
|
|
|
MSG message;
|
|
while (roomResult == 0 && GetMessageW(&message, NULL, 0, 0))
|
|
{
|
|
TranslateMessage(&message);
|
|
DispatchMessageW(&message);
|
|
}
|
|
KillTimer(window, 1);
|
|
DestroyWindow(window);
|
|
return (roomResult == 1) ? 0 : 1;
|
|
}
|
|
|
|
//###########################################################################
|
|
// Host / member entries
|
|
//###########################################################################
|
|
|
|
int
|
|
BTLobby_HostAndRoom(
|
|
const char *pilot_name, const char *vehicle, const char *color,
|
|
BTLobbyRoster *roster_out,
|
|
char *my_token_out, int my_token_capacity,
|
|
char *steam_map_out, int steam_map_capacity)
|
|
{
|
|
memset(roster_out, 0, sizeof(*roster_out));
|
|
if (BTSteamNet_Install() != 0 || !BTSteamNet_Active())
|
|
{
|
|
LobbyLog("host: Steam transport unavailable");
|
|
return -1;
|
|
}
|
|
|
|
SteamAPICall_t call = SteamMatchmaking()->CreateLobby(k_ELobbyTypePublic, 8);
|
|
LobbyCreated_t created;
|
|
memset(&created, 0, sizeof(created));
|
|
if (WaitApiCall(call, &created, sizeof(created),
|
|
LobbyCreated_t::k_iCallback, 15000) != 0 ||
|
|
created.m_eResult != k_EResultOK)
|
|
{
|
|
LobbyLog("host: CreateLobby failed");
|
|
return -1;
|
|
}
|
|
currentLobby = created.m_ulSteamIDLobby;
|
|
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4", "1");
|
|
PublishSelf(pilot_name, vehicle, color);
|
|
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
|
|
|
|
if (RunRoom(1) != 0)
|
|
{
|
|
SteamMatchmaking()->LeaveLobby(currentLobby);
|
|
return 1;
|
|
}
|
|
|
|
//
|
|
// GO: snapshot the roster SELF FIRST (the egg's pilot order defines
|
|
// the connect topology; the host must be pilot 1), MINT the tokens,
|
|
// publish the token map, then signal.
|
|
//
|
|
ISteamMatchmaking *matchmaking = SteamMatchmaking();
|
|
int count = matchmaking->GetNumLobbyMembers(currentLobby);
|
|
for (int pass = 0; pass < 2; ++pass)
|
|
{
|
|
for (int i = 0; i < count && roster_out->memberCount < 8; ++i)
|
|
{
|
|
BTLobbyMember member;
|
|
if (ReadMember(currentLobby, i, &member) != 0)
|
|
{
|
|
continue;
|
|
}
|
|
if ((pass == 0) == (member.isSelf != 0))
|
|
{
|
|
roster_out->members[roster_out->memberCount++] = member;
|
|
}
|
|
}
|
|
}
|
|
steam_map_out[0] = 0;
|
|
my_token_out[0] = 0;
|
|
for (int m = 0; m < roster_out->memberCount; ++m)
|
|
{
|
|
BTLobbyMember &member = roster_out->members[m];
|
|
sprintf(member.fakeAddress, "169.254.77.%d", m + 1);
|
|
char entry[96];
|
|
sprintf(entry, "%s%s=%llu", (m > 0) ? ";" : "",
|
|
member.fakeAddress, member.steamID);
|
|
if ((int)(strlen(steam_map_out) + strlen(entry) + 1)
|
|
< steam_map_capacity)
|
|
{
|
|
strcat(steam_map_out, entry);
|
|
}
|
|
if (member.isSelf)
|
|
{
|
|
strncpy(my_token_out, member.fakeAddress, my_token_capacity - 1);
|
|
my_token_out[my_token_capacity - 1] = 0;
|
|
}
|
|
}
|
|
matchmaking->SetLobbyData(currentLobby, "btl4map", steam_map_out);
|
|
matchmaking->SetLobbyData(currentLobby, "btl4go", "1");
|
|
SteamAPI_RunCallbacks();
|
|
LobbyLog("host: GO with %d member(s), map [%s]",
|
|
roster_out->memberCount, steam_map_out);
|
|
//
|
|
// The lobby will not survive the relaunch (documented); leave now so
|
|
// members do not see a ghost host.
|
|
//
|
|
Sleep(1500); // let the GO propagate
|
|
SteamMatchmaking()->LeaveLobby(currentLobby);
|
|
return 0;
|
|
}
|
|
|
|
int
|
|
BTLobby_JoinAndWait(
|
|
const char *pilot_name, const char *vehicle, const char *color,
|
|
char *my_token_out, int my_token_capacity,
|
|
char *steam_map_out, int steam_map_capacity)
|
|
{
|
|
if (BTSteamNet_Install() != 0 || !BTSteamNet_Active())
|
|
{
|
|
LobbyLog("join: Steam transport unavailable");
|
|
return -1;
|
|
}
|
|
|
|
ISteamMatchmaking *matchmaking = SteamMatchmaking();
|
|
matchmaking->AddRequestLobbyListStringFilter(
|
|
"btl4", "1", k_ELobbyComparisonEqual);
|
|
SteamAPICall_t call = matchmaking->RequestLobbyList();
|
|
LobbyMatchList_t match_list;
|
|
memset(&match_list, 0, sizeof(match_list));
|
|
if (WaitApiCall(call, &match_list, sizeof(match_list),
|
|
LobbyMatchList_t::k_iCallback, 15000) != 0 ||
|
|
match_list.m_nLobbiesMatching == 0)
|
|
{
|
|
LobbyLog("join: no btl4 lobby found");
|
|
return -1;
|
|
}
|
|
|
|
CSteamID lobby = matchmaking->GetLobbyByIndex(0);
|
|
call = matchmaking->JoinLobby(lobby);
|
|
LobbyEnter_t entered;
|
|
memset(&entered, 0, sizeof(entered));
|
|
if (WaitApiCall(call, &entered, sizeof(entered),
|
|
LobbyEnter_t::k_iCallback, 15000) != 0 ||
|
|
entered.m_EChatRoomEnterResponse != k_EChatRoomEnterResponseSuccess)
|
|
{
|
|
LobbyLog("join: JoinLobby failed");
|
|
return -1;
|
|
}
|
|
currentLobby = lobby;
|
|
PublishSelf(pilot_name, vehicle, color);
|
|
LobbyLog("join: in lobby, waiting for GO");
|
|
|
|
int result = RunRoom(0);
|
|
if (result == 0)
|
|
{
|
|
//
|
|
// GO: read the token map, find my own token by my SteamID.
|
|
//
|
|
const char *map_text = matchmaking->GetLobbyData(currentLobby, "btl4map");
|
|
steam_map_out[0] = 0;
|
|
my_token_out[0] = 0;
|
|
if (map_text != NULL)
|
|
{
|
|
strncpy(steam_map_out, map_text, steam_map_capacity - 1);
|
|
steam_map_out[steam_map_capacity - 1] = 0;
|
|
char needle[32];
|
|
sprintf(needle, "=%llu", BTSteamNet_MySteamID());
|
|
char map_copy[512];
|
|
strncpy(map_copy, map_text, sizeof(map_copy) - 1);
|
|
map_copy[sizeof(map_copy) - 1] = 0;
|
|
char *cursor = strtok(map_copy, ";");
|
|
while (cursor != NULL)
|
|
{
|
|
char *hit = strstr(cursor, needle);
|
|
if (hit != NULL && hit[strlen(needle)] == 0)
|
|
{
|
|
*hit = 0;
|
|
strncpy(my_token_out, cursor, my_token_capacity - 1);
|
|
my_token_out[my_token_capacity - 1] = 0;
|
|
}
|
|
cursor = strtok(NULL, ";");
|
|
}
|
|
}
|
|
LobbyLog("join: my token [%s], map [%s]", my_token_out, steam_map_out);
|
|
if (my_token_out[0] == 0)
|
|
{
|
|
result = -1; // the host's map is missing us
|
|
}
|
|
}
|
|
SteamMatchmaking()->LeaveLobby(currentLobby);
|
|
return result;
|
|
}
|