ParadoxRuntime/dllmain.cpp
Rama main · réplica de lectura del repositorio original.
//0503
/*
* Original work Copyright (C) 2026 gwog :3 (SyST3MDeV/Undaunted)
* Modified work Copyright (C) 2026 MysticFox / Pranav Karande (pranav158/Mystic-Paradox)
*
* Licensed under the GNU Affero General Public License v3.0.
* You may obtain a copy of the License at the root of this repository.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Additional terms under AGPLv3 Section 7 apply. See ADDITIONAL_TERMS.md.
*/
#include <windows.h>
#include <shellapi.h>
#include <tlhelp32.h>
#include <psapi.h> // [2026-10-10] GetProcessMemoryInfo -> K32GetProcessMemoryInfo in kernel32 (PSAPI_VERSION 2); no psapi.lib
#include <string>
#include <set>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdlib> // atof — used by BleedoutDurationFallback()
#include <deque>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <thread>
#include <iostream>
#include <ranges>
#include "framework.h"
#include "SDK.hpp"
#include "MinHook/MinHook.h"
#include "constants.h"
#include "Networking.h"
#include "RuntimeShared.h"
#include "P2PHooks.h"
#include "deployment_config.generated.h"
#include "BackendUrl.h"
#include "resource.h"
#include "SDK/GameplayAbilities_parameters.hpp"
#include "SDK/Archon_parameters.hpp"
#include "SDK/lantern_equipped_ab_parameters.hpp"
#include "SDK/base_gameobject_bp_parameters.hpp"
#include <cwchar>
#include <fstream>
#include <iomanip>
#include <intrin.h>
#include <malloc.h>
#include <mutex>
#include <sstream>
using namespace SDK;
// [1.14.7 2026-10-04] The watchdog's availability probe binds our own UDP port to test whether the hub is
// still listening. Networking.cpp pulls Winsock in through its own translation unit, so this one needs the
// import library explicitly.
#pragma comment(lib, "ws2_32.lib")
// [1.14.7 loader state dump] defined later in this file; used by the watchdog thread.
static void DumpLoadManagerEntries(const char* Tag);
// [2026-10-13 realms S1] defined with the URL redirect below; Init resolves the backend address, MainThread logs it.
static void MpResolveBackendAddress();
static void MpLogBackendAddress();
// [1.14.7 active hunt pass] defined later; used by the readiness predicate hook.
static void* g_archonGameInstanceForHuntPass = nullptr;
static void CallFindActiveHuntPassRow(const char* Why);
namespace Globals {
enum class ServerAuthMode {
GlobalGameserver,
HuntCapability,
Invalid
};
bool AmServer = false;
uintptr_t BaseAddress = 0x0;
bool Listening = false;
bool DoListen = false;
// [1.14.7 2026-10-03] Set when the map URL carries ?listen (MAP_URL_LISTEN.flag): the engine
// builds the GameNetDriver during map load, so the runtime waits for UWorld::NetDriver
// (UWorld+0x38) instead of creating one by hand - the manual path is what leaves the world
// context invalid on 1.14.7.
bool WaitingForEngineListener = false;
const wchar_t* ServerAPIKey = nullptr;
const wchar_t* MapPath = nullptr;
const wchar_t* BehemothPath = nullptr;
const wchar_t* MatchmakerHuntId = nullptr;
const wchar_t* ExpectedPlayerString = nullptr;
int Port = 0;
const wchar_t* MyIpAndPort = nullptr;
bool EnableLogging = true;
// HuntCapability is the optional player-hosted mode (P2PHooks.h); only the module can select it.
static ServerAuthMode AuthMode = ServerAuthMode::GlobalGameserver;
// Boot-patch status recorded during InitServerHooks (where InitLog is a loader-lock no-op) and emitted
// later from MainThread via MpLog so it is actually visible. (Named after the removed 1.12 Move10 patches;
// today it carries the WARP force status.)
static std::string Move10Status;
static std::wstring ServerAPIKeyStorage;
static std::wstring MapPathStorage;
static std::wstring BehemothPathStorage;
static std::wstring MatchmakerHuntIdStorage;
static std::wstring ExpectedPlayerStringStorage;
static std::wstring MyIpAndPortStorage;
}
// Loader-lock safe no-op: DllMain/Init must not do logging or file I/O.
static void InitLog(const std::string& Msg) {
(void)Msg;
}
// [2026-10-09] Set by DllMain on DLL_PROCESS_DETACH, which the loader calls before this module's C runtime
// shuts down. From then on MpLog writes nothing and the exit/exception hooks only forward. Process exit
// runs RtlExitUserProcess -> NtTerminateProcess(NULL) -> DLL detach -> NtTerminateProcess(self); the last
// call went through NtTerminateProcessHook, whose MpLog opened a std::ofstream after the CRT had freed its
// stream table, and every client exit ended in 0xC0000005 (MSVCP140 +0x4c950 under /MD, this DLL's
// stream-slot search reading 0+0x18 under /MT).
static volatile LONG g_MpProcessDetaching = 0;
static bool MpProcessDetaching() {
return InterlockedCompareExchange(&g_MpProcessDetaching, 0, 0) != 0;
}
// Log directory derived from the game .exe path so diagnostics also appear on the VPS
// (the previous hardcoded dev-box debug path only existed on one PC, so the DLL
// wrote NOTHING on the VPS). File-scope POD, filled once - no lazy constructor, so it is
// safe to touch under the loader lock (see the MpLogUrlObservation crash note below).
static char g_MpLogDir[MAX_PATH] = { 0 };
static volatile LONG g_MpLogDirReady = 0;
static const char* MpLogDir() {
if (g_MpLogDirReady == 0) {
char ExePath[MAX_PATH];
DWORD n = GetModuleFileNameA(nullptr, ExePath, MAX_PATH);
if (n > 0 && n < MAX_PATH) {
int slash = -1;
for (DWORD i = 0; i < n; ++i) { if (ExePath[i] == '\\' || ExePath[i] == '/') { slash = (int)i; } }
if (slash >= 0) {
for (int i = 0; i <= slash; ++i) { g_MpLogDir[i] = ExePath[i]; }
g_MpLogDir[slash + 1] = '\0';
}
}
InterlockedExchange(&g_MpLogDirReady, 1);
}
return g_MpLogDir;
}
// [A/B 2026-07-16] Generic exe-relative sentinel-flag check (same convention as
// MP_FORCE_WARP.flag): true iff <game-exe-dir>\<FileName> exists. Exe-relative so it works on
// the VPS.
// [2026-10-10 1.14.7 CPU] Cached per name for 4 s. It used to call GetModuleFileNameW, build a std::wstring and
// probe the file system on EVERY call, and about four call sites run every server frame (plus one per replicated
// actor added to the graph). Now:
// * the exe directory is resolved once (MpResolveExeDirW, first thing in Init; never with a lock held) into POD
// storage published through an interlocked ready state;
// * each name's answer is kept in a fixed POD table (128 names x 64 chars) behind an SRWLOCK (constant-initialised,
// loader-lock safe); the lock covers only the table scan, never GetModuleFileNameW or the file probe;
// * a name is re-probed at most once every 4000 ms, so creating or deleting a live A/B flag (for example
// DISABLE_PLAYER_ROLE_GLOBAL_ROUTE.flag) takes effect within 4 s, and both of that flag's read sites see the
// same answer. A flag created and deleted again within 4 s may be missed. Call sites that keep the answer in a
// static (read-once flags) still read once and keep it for the process lifetime;
// * no heap and no CRT state, so it stays usable late in shutdown.
// A name of 64+ chars, a full table, or UNCACHED_FLAG_FILES.flag (probed directly, once) probes on every call, as
// before.
static wchar_t g_MpExeDirW[MAX_PATH] = { 0 };
static size_t g_MpExeDirLenW = 0;
static volatile LONG g_MpExeDirState = 0; // 0 = not resolved, 1 = being published, 2 = ready
// Writes the exe path to Out and returns the length of its directory part (with the trailing separator); 0 on failure.
static size_t MpExeDirFromModuleW(wchar_t (&Out)[MAX_PATH]) {
const DWORD n = GetModuleFileNameW(nullptr, Out, MAX_PATH);
if (n == 0 || n >= MAX_PATH) return 0;
size_t Len = 0;
for (DWORD i = 0; i < n; ++i) { if (Out[i] == L'\\' || Out[i] == L'/') Len = static_cast<size_t>(i) + 1; }
return Len;
}
static void MpResolveExeDirW() {
if (InterlockedCompareExchange(&g_MpExeDirState, 0, 0) == 2) return;
wchar_t Local[MAX_PATH];
const size_t Len = MpExeDirFromModuleW(Local);
if (Len == 0) return; // stays unresolved: every probe looks the path up itself (the old behaviour)
if (InterlockedCompareExchange(&g_MpExeDirState, 1, 0) != 0) return; // another thread publishes the same path
for (size_t i = 0; i < Len; ++i) g_MpExeDirW[i] = Local[i];
g_MpExeDirW[Len] = L'\0';
g_MpExeDirLenW = Len;
InterlockedExchange(&g_MpExeDirState, 2);
}
static bool MpProbeExeRelativeFile(const wchar_t* Name, size_t NameLen) {
wchar_t Path[MAX_PATH];
size_t DirLen = 0;
if (InterlockedCompareExchange(&g_MpExeDirState, 0, 0) == 2) {
DirLen = g_MpExeDirLenW;
for (size_t i = 0; i < DirLen; ++i) Path[i] = g_MpExeDirW[i];
} else {
DirLen = MpExeDirFromModuleW(Path);
}
if (DirLen == 0 || DirLen + NameLen + 1 > MAX_PATH) return false;
for (size_t i = 0; i < NameLen; ++i) Path[DirLen + i] = Name[i];
Path[DirLen + NameLen] = L'\0';
return GetFileAttributesW(Path) != INVALID_FILE_ATTRIBUTES;
}
struct MpFlagCacheEntry { wchar_t Name[64]; size_t Len; bool Present; ULONGLONG CheckedMs; };
static MpFlagCacheEntry g_MpFlagCache[128] = {};
static int g_MpFlagCacheCount = 0;
static SRWLOCK g_MpFlagCacheLock = SRWLOCK_INIT;
static volatile LONG g_MpFlagCacheMode = -1; // -1 = not decided, 1 = cached, 0 = UNCACHED_FLAG_FILES.flag
static constexpr ULONGLONG kMpFlagRecheckMs = 4000;
// Decided once: UNCACHED_FLAG_FILES.flag is probed directly (never cached) on the first call.
static LONG MpDecideFlagCacheMode() {
const LONG Mode = InterlockedCompareExchange(&g_MpFlagCacheMode, 0, 0);
if (Mode >= 0) return Mode;
static const wchar_t kOff[] = L"UNCACHED_FLAG_FILES.flag";
const LONG Decided = MpProbeExeRelativeFile(kOff, (sizeof(kOff) / sizeof(kOff[0])) - 1) ? 0 : 1;
InterlockedCompareExchange(&g_MpFlagCacheMode, Decided, -1);
return InterlockedCompareExchange(&g_MpFlagCacheMode, 0, 0);
}
bool MpExeRelativeFlagPresent(const wchar_t* FileName) {
if (!FileName) { return false; }
size_t Len = 0;
while (FileName[Len] && Len < MAX_PATH) ++Len;
MpResolveExeDirW();
if (MpDecideFlagCacheMode() != 1 || Len >= 64) return MpProbeExeRelativeFile(FileName, Len);
const ULONGLONG Now = GetTickCount64();
int Slot = -1;
AcquireSRWLockExclusive(&g_MpFlagCacheLock);
for (int i = 0; i < g_MpFlagCacheCount; ++i) {
const MpFlagCacheEntry& E = g_MpFlagCache[i];
if (E.Len != Len) continue;
size_t k = 0;
while (k < Len && E.Name[k] == FileName[k]) ++k;
if (k == Len) { Slot = i; break; }
}
if (Slot < 0 && g_MpFlagCacheCount < static_cast<int>(sizeof(g_MpFlagCache) / sizeof(g_MpFlagCache[0]))) {
Slot = g_MpFlagCacheCount++;
MpFlagCacheEntry& E = g_MpFlagCache[Slot];
for (size_t k = 0; k < Len; ++k) E.Name[k] = FileName[k];
E.Name[Len] = L'\0';
E.Len = Len;
E.Present = false;
E.CheckedMs = 0;
}
if (Slot >= 0 && g_MpFlagCache[Slot].CheckedMs != 0 && Now - g_MpFlagCache[Slot].CheckedMs < kMpFlagRecheckMs) {
const bool Present = g_MpFlagCache[Slot].Present;
ReleaseSRWLockExclusive(&g_MpFlagCacheLock);
return Present;
}
ReleaseSRWLockExclusive(&g_MpFlagCacheLock);
const bool Present = MpProbeExeRelativeFile(FileName, Len); // outside the lock
if (Slot >= 0) {
AcquireSRWLockExclusive(&g_MpFlagCacheLock);
g_MpFlagCache[Slot].Present = Present;
g_MpFlagCache[Slot].CheckedMs = Now ? Now : 1;
ReleaseSRWLockExclusive(&g_MpFlagCacheLock);
}
return Present;
}
static const char* MpFlagCacheModeText() {
const LONG Mode = InterlockedCompareExchange(&g_MpFlagCacheMode, 0, 0);
return Mode == 1 ? "ON (each flag file re-checked at most every 4 s)"
: Mode == 0 ? "OFF (UNCACHED_FLAG_FILES.flag: every check probes the file)"
: "not decided yet";
}
/*
* [2026-07-19] VERBOSE_DIAG.flag — master switch for high-volume per-tick / startup tracing.
*
* WHY: production DLL logs were reaching 24-32 MB per hunt. That is not just disk — every line is a
* formatted write from the game thread, and a blocked write is what deadlocked the server (see the
* stdout-pipe stall in Progress/13_HUNT_SERVERS.md). Less log volume is less exposure to that class
* of failure, and it makes the logs that remain actually readable.
*
* WHAT STAYS ON UNCONDITIONALLY (deliberately — these are rare, load-bearing, or diagnose the
* intermittent bugs we cannot reproduce on demand):
* [Bleedout*] bleedout domain — the permanent-downed bug is intermittent; no second chance
* [Watchdog/thread][HangSuspect][HangTrace] hang + reap diagnosis (raw current PE pointers)
* [ExitTrace][ExceptionTrace] crash forensics
* [Lifecycle:*][NetConnEdge][PossessionRPC] low-volume, edge-triggered
* ([2026-10-11] PRODUCTION_LOG_PROFILE.flag turns [Lifecycle:*] and [PossessionRPC] off on a
* server, with the other per-event traces listed at MpProductionLogProfile; [NetConnEdge] stays)
* [HuntTableGap][BleedoutBUG][BleedoutStuck] one-shot problem markers
*
* WHAT THIS FLAG NOW GATES:
* [NetTickTrace] per-tick net trace (budgeted, but still noise every launch)
* [GetGameDefaultMap] fires on every map-name query
* recentPE name ring copies every ProcessEvent name; enable only for targeted hang captures
*
* ALREADY GATED ELSEWHERE (unchanged, listed so nobody re-gates them):
* [RepFreq] REPGRAPH_DIAG.flag — the hook is only installed when set
* [GraphState] [ActorsNoConn] REPGRAPH_DIAG.flag via RepGraphDiagNet() in Networking.cpp
* [SRA enter]/[SRA exit] REPGRAPH_DIAG.flag
* If a log is huge and full of [RepFreq]/[GraphState], REPGRAPH_DIAG.flag is present on the box —
* remove it rather than editing code.
*/
bool DevelopmentRuntimeProfile() {
static int Cached = -1;
if (Cached >= 0) return Cached == 1;
char Profile[32]{};
const DWORD Length = GetEnvironmentVariableA(
"MYSTICPARADOX_RUNTIME_PROFILE", Profile, static_cast<DWORD>(std::size(Profile)));
Cached = (Length == 11 && strcmp(Profile, "development") == 0) ? 1 : 0;
return Cached == 1;
}
static bool VerboseDiag() {
static int Cached = -1;
if (Cached < 0) {
// A hand-created flag on an untrusted player machine is never sufficient. The launcher
// must also inject the centrally authorized development runtime profile for this process.
Cached = DevelopmentRuntimeProfile() && MpExeRelativeFlagPresent(L"VERBOSE_DIAG.flag") ? 1 : 0;
}
return Cached == 1;
}
/*
* [2026-10-11 1.14.7 O3 PRODUCTION LOG PROFILE] PRODUCTION_LOG_PROFILE.flag (default OFF, read once, servers only)
* silences the per-event diagnostic traces that a populated hub repeats for every player, join and RPC:
* [HuntPassState] [ArrivalTrigger] (server lines) [EconTrace] [PlayerRoleChannel] [PostLoginPE] [PossessionRPC]
* [Lifecycle:*] [EscalationFlow][Server] [SendPath] [FrankDroneDiag][Server] [PlayerRoleDirectChannel]
* [WeaponXP][ServerKill]/[ServerGrant]/[ServerPlayerGrant] [TempestPerfectDodge][Server] [GrantTrace]
* [CooldownTrace] [PAWN #] [TickDispatchInner] (periodic heartbeat)
* Only log lines are skipped: every write, guard, retry and fix that sits next to them still runs. Clients ignore the
* flag (it lives next to the shared game exe on a test box), so the client log stays complete.
*/
bool MpProductionLogProfile() {
static volatile LONG s_mode = -1; // -1 = not decided, 0 = off, 1 = on
LONG Mode = s_mode; // plain aligned read once decided
if (Mode < 0) {
Mode = (Globals::AmServer && MpExeRelativeFlagPresent(L"PRODUCTION_LOG_PROFILE.flag")) ? 1 : 0;
InterlockedExchange(&s_mode, Mode);
}
return Mode == 1;
}
/*
* [1.12.0] Persistent, flushed diagnostic log from MainThread/hooks only.
* One file per server port so concurrently-spawned gameservers don't clobber each other.
* Writes next to the game .exe (see MpLogDir) so logs are visible on the VPS too.
*
* [2026-10-11 1.14.7 O3 log writer] Every line used to construct a std::ofstream: open, format, flush, close - a
* CreateFile + WriteFile + CloseHandle round trip plus CRT stream setup per line, on whatever thread logged. That is
* expensive on Windows and much worse under Wine (each open is a wineserver path walk). The file now stays open:
* one handle with FILE_APPEND_DATA, shared read/write/delete (tail -f, Networking.cpp's writer and rotation keep
* working), and one WriteFile per line straight into the OS cache - no CRT buffering, so a crash loses nothing that
* MpLog returned from. Same text format. Kept rules: nothing after DLL_PROCESS_DETACH (g_MpProcessDetaching) and no
* CRT stream object on this path. The handle follows Globals::Port (port 0 until Init parses it) and is reopened when
* the file was deleted or renamed away (file id compared at most every 5 s). The lock is only TRIED: a thread that
* cannot take it within a few ms (its owner killed by process exit, or a VEH line raised inside a write) writes its line
* through a private open/append/close instead, so logging can never deadlock. MPLOG_OPEN_PER_LINE.flag (read once)
* restores the old per-line std::ofstream path.
*/
static SRWLOCK g_MpLogLock = SRWLOCK_INIT;
static HANDLE g_MpLogHandle = INVALID_HANDLE_VALUE; // guarded by g_MpLogLock
static int g_MpLogHandlePort = -1;
static ULONGLONG g_MpLogIdCheckMs = 0;
static volatile LONG g_MpLogMode = -1; // -1 = not decided, 1 = one open handle, 0 = MPLOG_OPEN_PER_LINE.flag
static LONG MpLogMode() {
LONG Mode = InterlockedCompareExchange(&g_MpLogMode, 0, 0);
if (Mode < 0) {
Mode = MpExeRelativeFlagPresent(L"MPLOG_OPEN_PER_LINE.flag") ? 0 : 1;
InterlockedExchange(&g_MpLogMode, Mode);
}
return Mode;
}
static HANDLE MpLogOpenAppend(const char* Path) {
return CreateFileA(Path, FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
}
static void MpLogWriteOnce(const char* Path, const char* Data, DWORD Len) {
const HANDLE H = MpLogOpenAppend(Path);
if (H == INVALID_HANDLE_VALUE) return;
DWORD Written = 0;
WriteFile(H, Data, Len, &Written, nullptr);
CloseHandle(H);
}
// True while the open handle still names the file at Path: not deleted, same volume and file index.
static bool MpLogHandleStillCurrent(HANDLE H, const char* Path) {
BY_HANDLE_FILE_INFORMATION Mine{};
if (!GetFileInformationByHandle(H, &Mine) || Mine.nNumberOfLinks == 0) return false;
const HANDLE P = CreateFileA(Path, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (P == INVALID_HANDLE_VALUE) return false;
BY_HANDLE_FILE_INFORMATION Theirs{};
const bool Ok = GetFileInformationByHandle(P, &Theirs) != FALSE;
CloseHandle(P);
return Ok && Theirs.dwVolumeSerialNumber == Mine.dwVolumeSerialNumber
&& Theirs.nFileIndexHigh == Mine.nFileIndexHigh && Theirs.nFileIndexLow == Mine.nFileIndexLow;
}
// One complete line (already terminated) into the per-port file. Port is the one Path was built for.
static void MpLogWriteLine(const char* Path, int Port, const char* Data, DWORD Len) {
bool Locked = false;
for (int Try = 0; Try < 52 && !Locked; ++Try) {
Locked = TryAcquireSRWLockExclusive(&g_MpLogLock) != FALSE;
if (Locked) break;
if (Try < 32) YieldProcessor();
else if (Try < 48) SwitchToThread();
else Sleep(1);
}
if (!Locked) {
MpLogWriteOnce(Path, Data, Len);
return;
}
const ULONGLONG NowMs = GetTickCount64();
if (g_MpLogHandle != INVALID_HANDLE_VALUE && g_MpLogHandlePort != Port) {
CloseHandle(g_MpLogHandle);
g_MpLogHandle = INVALID_HANDLE_VALUE;
}
if (g_MpLogHandle != INVALID_HANDLE_VALUE && NowMs - g_MpLogIdCheckMs >= 5000) {
g_MpLogIdCheckMs = NowMs;
if (!MpLogHandleStillCurrent(g_MpLogHandle, Path)) {
CloseHandle(g_MpLogHandle);
g_MpLogHandle = INVALID_HANDLE_VALUE;
}
}
if (g_MpLogHandle == INVALID_HANDLE_VALUE) {
g_MpLogHandle = MpLogOpenAppend(Path);
g_MpLogHandlePort = Port;
g_MpLogIdCheckMs = NowMs;
}
if (g_MpLogHandle != INVALID_HANDLE_VALUE) {
DWORD Written = 0;
if (!WriteFile(g_MpLogHandle, Data, Len, &Written, nullptr)) {
CloseHandle(g_MpLogHandle); // reopened by the next line
g_MpLogHandle = INVALID_HANDLE_VALUE;
}
}
ReleaseSRWLockExclusive(&g_MpLogLock);
}
// Prefix + body + "\n" in one buffer (stack for ordinary lines), then one write.
static void MpLogEmit(const char* Path, int Port, const char* Prefix, size_t PrefixLen, const std::string& Msg) {
const size_t Total = PrefixLen + Msg.size() + 1;
if (Total > 0x7FFFFFFFu) return;
char Stack[2048];
std::string Heap;
char* Buf = Stack;
if (Total > sizeof(Stack)) {
Heap.resize(Total);
Buf = &Heap[0];
}
if (PrefixLen) memcpy(Buf, Prefix, PrefixLen);
if (!Msg.empty()) memcpy(Buf + PrefixLen, Msg.data(), Msg.size());
Buf[Total - 1] = '\n';
MpLogWriteLine(Path, Port, Buf, static_cast<DWORD>(Total));
}
void MpLog(const std::string& Msg) {
if (MpProcessDetaching()) {
return;
}
char Path[MAX_PATH];
const int Port = Globals::Port;
sprintf_s(Path, "%smysticparadox_dll_port%d.log", MpLogDir(), Port);
if (MpLogMode() == 0) {
// MPLOG_OPEN_PER_LINE.flag: the pre-11-Oct writer, unchanged.
std::ofstream File(Path, std::ios::app);
if (File.is_open()) {
SYSTEMTIME Now{};
GetLocalTime(&Now);
File << "["
<< std::setfill('0') << std::setw(2) << Now.wHour << ":"
<< std::setfill('0') << std::setw(2) << Now.wMinute << ":"
<< std::setfill('0') << std::setw(2) << Now.wSecond << "."
<< std::setfill('0') << std::setw(3) << Now.wMilliseconds << "] "
<< "pid=" << GetCurrentProcessId() << " "
<< Msg << "\n";
File.flush();
}
return;
}
SYSTEMTIME Now{};
GetLocalTime(&Now);
char Prefix[64];
const int PrefixLen = sprintf_s(Prefix, "[%02d:%02d:%02d.%03d] pid=%lu ", static_cast<int>(Now.wHour),
static_cast<int>(Now.wMinute), static_cast<int>(Now.wSecond), static_cast<int>(Now.wMilliseconds),
static_cast<unsigned long>(GetCurrentProcessId()));
MpLogEmit(Path, Port, Prefix, PrefixLen > 0 ? static_cast<size_t>(PrefixLen) : 0, Msg);
}
// [2026-10-11 1.14.7 O3] Networking.cpp's NetLog (no timestamp prefix, its own port argument) through the same writer.
// A line for another port than the open handle's is written with a private open so the shared handle never thrashes.
void MpLogRawPort(int Port, const std::string& Msg) {
if (MpProcessDetaching()) return;
char Path[MAX_PATH];
sprintf_s(Path, "%smysticparadox_dll_port%d.log", MpLogDir(), Port);
if (MpLogMode() == 0) {
std::ofstream File(Path, std::ios::app);
if (File.is_open()) {
File << Msg << "\n";
File.flush();
}
return;
}
if (Port != Globals::Port) {
std::string Line = Msg;
Line.push_back('\n');
if (Line.size() <= 0x7FFFFFFFu) MpLogWriteOnce(Path, Line.data(), static_cast<DWORD>(Line.size()));
return;
}
MpLogEmit(Path, Port, nullptr, 0, Msg);
}
bool MpReadEnvironment(const char* Name, std::string& Value, size_t Maximum) {
Value.clear();
char Buffer[512]{};
const DWORD Length = GetEnvironmentVariableA(Name, Buffer, static_cast<DWORD>(sizeof(Buffer)));
if (Length == 0 || Length >= sizeof(Buffer) || Length > Maximum) return false;
Value.assign(Buffer, Length);
return Value.find('\0') == std::string::npos && Value.find('\r') == std::string::npos &&
Value.find('\n') == std::string::npos;
}
bool IsReadablePointer(const void* Ptr, size_t Size) {
if (!Ptr || ((uintptr_t)Ptr & 0x7) != 0) {
return false;
}
MEMORY_BASIC_INFORMATION Info{};
if (!VirtualQuery(Ptr, &Info, sizeof(Info))) {
return false;
}
if (Info.State != MEM_COMMIT || (Info.Protect & (PAGE_GUARD | PAGE_NOACCESS))) {
return false;
}
uintptr_t Start = reinterpret_cast<uintptr_t>(Ptr);
uintptr_t End = Start + Size;
uintptr_t RegionEnd = reinterpret_cast<uintptr_t>(Info.BaseAddress) + Info.RegionSize;
return End >= Start && End <= RegionEnd;
}
/*
* [2026-10-11 1.14.7 O3 VirtualQuery] IsReadablePointer is a VirtualQuery system call (a wineserver round trip under
* Wine), and the server's net path asked it about the same few pointers ~20 + 4N times per frame: the NetDriver and
* every client connection, in SanitizeNetDriverClientConnections (three times a frame: the engine's TickDispatch hook
* and before the manual dispatch and flush), the tick hook's two connection loops, the graph drive and the player-role
* route checks. MpReadableThisFrame remembers a POSITIVE answer for one segment of the server game thread's frame and
* answers repeats from a 256-slot table; a negative answer is never cached. The segment ("epoch") ends at the top of
* GameEngineTickHook and on both sides of the native engine tick, which is where GC frees objects, so a remembered
* answer never spans a GC. Other threads, and the client, always call IsReadablePointer. Only a few call sites (the
* net driver / connection / graph checks listed above) use it; it is not a liveness test and was never meant to be
* one. NO_FRAME_POINTER_CACHE.flag (read once) sends every call to IsReadablePointer.
*/
struct MpReadableCacheSlot { const void* Ptr; size_t Size; uint32_t Epoch; };
static MpReadableCacheSlot g_MpReadableCache[256] = {};
static uint32_t g_MpReadableEpoch = 1;
static DWORD g_MpReadableCacheThread = 0; // the server game thread, set by GameEngineTickHook; 0 = cache unused
static int g_MpReadableCacheMode = -1; // -1 = not decided, 1 = on, 0 = NO_FRAME_POINTER_CACHE.flag
// Game thread only.
static void MpReadableCacheNextEpoch() {
if (++g_MpReadableEpoch == 0) {
for (MpReadableCacheSlot& Slot : g_MpReadableCache) Slot.Epoch = 0;
g_MpReadableEpoch = 1;
}
}
static bool MpReadableThisFrame(const void* Ptr, size_t Size) {
if (g_MpReadableCacheMode != 1 || g_MpReadableCacheThread == 0 || GetCurrentThreadId() != g_MpReadableCacheThread) {
return IsReadablePointer(Ptr, Size);
}
if (!Ptr || (reinterpret_cast<uintptr_t>(Ptr) & 0x7) != 0) return false;
const uintptr_t P = reinterpret_cast<uintptr_t>(Ptr);
MpReadableCacheSlot& Slot = g_MpReadableCache[((P >> 3) ^ (P >> 12)) & 0xFF];
if (Slot.Ptr == Ptr && Slot.Epoch == g_MpReadableEpoch && Slot.Size >= Size) return true;
if (!IsReadablePointer(Ptr, Size)) return false;
if (Slot.Ptr != Ptr || Slot.Epoch != g_MpReadableEpoch || Slot.Size < Size) {
Slot.Ptr = Ptr;
Slot.Size = Size;
Slot.Epoch = g_MpReadableEpoch;
}
return true;
}
// [2026-07-24 ROOT-CAUSE FIX for the Ramsgate->Training "new player" crash] Robust UObject liveness
// check. IsReadablePointer above only proves memory is currently MAPPED - it cannot distinguish a live
// object from one whose memory was freed (and possibly reused) after a level travel, because UE recycles
// freed UObject memory, so a stale pointer stays "readable" while pointing at a dead/foreign object.
// Passing such a pointer to native game code (ApplyPlayerRole, the HUD lifecycle functions) faults deep
// inside the game - and catching that fault with SEH is NOT enough: the fault already happened partway
// through native game code, corrupting the game's own state mid-call, so the process still dies a moment
// later (proven by "vps logs copied/Training pawn issue/4": the SEH guard logged FAULTED...caught, then
// the log ends - the process died right after the catch).
//
// This instead confirms the pointer is still a currently-registered live object by looking it up in the
// engine's global object array (the same GUObjectArray the server block already walks every frame). Once
// a level travel destroys the old world's loadout/PC/HUD, that object is no longer any slot's .Object, so
// this returns false and the caller SKIPS the native call entirely - the fault never happens, so there is
// nothing to corrupt. This is PREVENTION; the SEH guards elsewhere remain only as a last-resort backstop.
// SEH-guarded (no std::string locals here, so __try is legal) against a transient array-read race; the
// array lives on the game thread and GC runs on the same thread, so no concurrent-mutation risk.
bool IsRegisteredLiveObject(const void* Ptr) {
if (!Ptr) return false;
__try {
// O(1) liveness: every live UObject stores its own slot index (UObject::Index @ 0x0C) and
// GObjects.GetByIndex is bounds-safe, so a live object round-trips: GetByIndex(obj->Index)==obj.
// A destroyed object no longer owns that slot - after a level travel the slot is either nulled
// or taken by a different object (different address), so the round-trip fails and we return
// false, and the caller skips the native call. Reading Index from freed-but-recycled memory is
// harmless under __try (it just yields a non-matching index -> false). This replaces the prior
// full O(n) GObjects scan (~hundreds of thousands of entries) that ran on the game thread on
// every call from the client-side pumps below.
const int32_t Index = reinterpret_cast<const UObject*>(Ptr)->Index;
return reinterpret_cast<const void*>(UObject::GObjects->GetByIndex(Index)) == Ptr;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false; // memory momentarily unreadable -> treat as not-live (caller safely skips)
}
}
// A UObject can remain registered during the first stage of AActor destruction. That makes the
// GObjects round-trip above necessary but insufficient for replication/channel work: the object is
// still discoverable while native teardown has already invalidated fields used by SetChannelActor and
// ReplicateActor. Actor+0x5C bit 0x08 is the verified bActorIsBeingDestroyed flag in the 1.12 build
// (observed directly in RouteRemoveNetworkActorToNodes). UObject flags also reject BeginDestroyed and
// FinishDestroyed states as a second boundary. Never call native route/bootstrap APIs after this gate
// fails; purge the compatibility caches instead.
static bool IsActorSafeForReplicationLifecycle(const void* Ptr) {
if (!Ptr || !IsReadablePointer(Ptr, 0x60) || !IsRegisteredLiveObject(Ptr)) return false;
__try {
constexpr uint32_t kRF_BeginDestroyed = 0x00008000u;
constexpr uint32_t kRF_FinishDestroyed = 0x00010000u;
const uint32_t ObjectFlags = *reinterpret_cast<const uint32_t*>(
reinterpret_cast<uintptr_t>(Ptr) + 0x08);
const uint8_t ActorFlags5C = *reinterpret_cast<const uint8_t*>(
reinterpret_cast<uintptr_t>(Ptr) + 0x5C);
return (ActorFlags5C & 0x08u) == 0
&& (ObjectFlags & (kRF_BeginDestroyed | kRF_FinishDestroyed)) == 0;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
static bool IsSanePointerArray(void* Data, int32_t Num, int32_t Max, int32_t Limit) {
if (Num < 0 || Max < 0 || Num > Max || Num > Limit) {
return false;
}
if (Num == 0) {
return true;
}
return IsReadablePointer(Data, static_cast<size_t>(Num) * sizeof(void*));
}
// Narrow a wide string for logging (URLs/paths are ASCII, so a direct copy is fine).
static std::string MpNarrow(const std::wstring& W) {
std::string Out;
Out.reserve(W.size());
for (wchar_t Ch : W) {
Out.push_back(static_cast<char>(Ch));
}
return Out;
}
std::string MpPtr(const void* Ptr) {
return Ptr ? std::to_string(reinterpret_cast<uintptr_t>(Ptr)) : "null";
}
static void MpReapExit(const char* Reason); // defined with the empty-server watchdog below
// Game thread only.
// [2026-10-11 1.14.7 P2P host] Servers exit through MpReapExit. UKismetSystemLibrary::QuitGame (0x03EE3FD0) with no
// SpecificPlayer asks GetPlayerController(World, 0) (0x03E2F520) for a target and returns at once when there is none -
// always the case on a server with no client since the local player was removed (0.2.110/0.2.111) - and when it does
// find one (a remote player's controller) it runs the console command "quit" through that player, which at best
// reaches UE's exit teardown: the path that hangs 60 s and aborts on this build (see MpReapExit). So every player-host
// fence (P2PHooks::TickShutdown) ended in its 5 s TerminateProcess(0xE105) fallback. A server keeps nothing in process
// (persistence is in the Metagame), so it now ends the way the empty-server reap does: TerminateProcess(0). The client
// keeps QuitGame. SERVER_QUITGAME_EXIT.flag reverts servers to QuitGame (read once).
void MpQuitGame() {
if (Globals::AmServer) {
static const bool UseQuitGame = MpExeRelativeFlagPresent(L"SERVER_QUITGAME_EXIT.flag");
if (!UseQuitGame) {
MpLog("[ServerQuit] quit requested on a server; ending through the reap path (QuitGame does nothing "
"without a local PlayerController; SERVER_QUITGAME_EXIT.flag reverts)");
MpReapExit("server quit (MpQuitGame)");
return;
}
MpLog("[ServerQuit] SERVER_QUITGAME_EXIT.flag present: UKismetSystemLibrary::QuitGame (old path)");
}
UKismetSystemLibrary::QuitGame(UWorld::GetWorld(), nullptr, EQuitPreference::Quit, true);
}
static std::string MpHex(uintptr_t Value) {
std::ostringstream Stream;
Stream << "0x" << std::uppercase << std::hex << Value;
return Stream.str();
}
// [2026-07-24 ACCESSORY-UNLOCK DIAG] Raw byte dump for a Parms buffer whose real struct layout
// (input args + ReturnValue offset) isn't known ahead of a live capture. Reads only through
// IsReadablePointer-guarded call sites, so this itself assumes the caller already checked readability.
static std::string DumpBytesHex(const void* Ptr, size_t Count) {
std::ostringstream Stream;
const uint8_t* Bytes = reinterpret_cast<const uint8_t*>(Ptr);
Stream << std::uppercase << std::hex << std::setfill('0');
for (size_t i = 0; i < Count; ++i) {
if (i) Stream << ' ';
Stream << std::setw(2) << static_cast<unsigned>(Bytes[i]);
}
return Stream.str();
}
static std::string MpBaseName(const char* Path) {
if (!Path || !Path[0]) {
return "(unknown)";
}
const char* Base = Path;
for (const char* Cursor = Path; *Cursor; ++Cursor) {
if (*Cursor == '\\' || *Cursor == '/') {
Base = Cursor + 1;
}
}
return std::string(Base);
}
static std::string MpAddress(const void* Ptr) {
if (!Ptr) {
return "null";
}
HMODULE Module = nullptr;
if (GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCSTR>(Ptr),
&Module)) {
char ModulePath[MAX_PATH]{};
GetModuleFileNameA(Module, ModulePath, MAX_PATH);
uintptr_t Base = reinterpret_cast<uintptr_t>(Module);
uintptr_t Address = reinterpret_cast<uintptr_t>(Ptr);
return MpBaseName(ModulePath) + "+" + MpHex(Address - Base);
}
uintptr_t Base = Globals::BaseAddress ? Globals::BaseAddress : reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
uintptr_t Address = reinterpret_cast<uintptr_t>(Ptr);
if (Base && Address >= Base && Address < Base + 0x08000000) {
return "Dauntless-Win64-Shipping.exe+" + MpHex(Address - Base);
}
return MpHex(Address);
}
static void MpLogStack(const std::string& Tag, uint32_t Code) {
void* Frames[32]{};
USHORT Count = CaptureStackBackTrace(1, static_cast<DWORD>(std::size(Frames)), Frames, nullptr);
std::string Msg = "[ExitTrace] " + Tag + " code=" + std::to_string(Code) + " stack=";
for (USHORT i = 0; i < Count; ++i) {
if (i != 0) {
Msg += " ";
}
Msg += MpAddress(Frames[i]);
}
MpLog(Msg);
}
using ExitProcessFn = void(WINAPI*)(UINT);
using RtlExitUserProcessFn = void(WINAPI*)(ULONG);
using TerminateProcessFn = BOOL(WINAPI*)(HANDLE, UINT);
using NtTerminateProcessFn = LONG(NTAPI*)(HANDLE, LONG);
using RaiseExceptionFn = void(WINAPI*)(DWORD, DWORD, DWORD, const ULONG_PTR*);
using CExitFn = void(__cdecl*)(int);
using AbortFn = void(__cdecl*)();
static ExitProcessFn OrigExitProcess = nullptr;
static RtlExitUserProcessFn OrigRtlExitUserProcess = nullptr;
static TerminateProcessFn OrigTerminateProcess = nullptr;
static NtTerminateProcessFn OrigNtTerminateProcess = nullptr;
static RaiseExceptionFn OrigRaiseException = nullptr;
static CExitFn OrigUcrtExit = nullptr;
static CExitFn OrigUcrtUnderscoreExit = nullptr;
static CExitFn OrigMsvcrtExit = nullptr;
static CExitFn OrigMsvcrtUnderscoreExit = nullptr;
static AbortFn OrigUcrtAbort = nullptr;
static AbortFn OrigMsvcrtAbort = nullptr;
static void* OrigUnhandledExceptionFilter = nullptr;
static PVOID VectoredExceptionHandle = nullptr;
static volatile LONG ExceptionTraceCount = 0;
// [DIAG 2026-07-09] Captured on first GameEngineTickHook entry so we can attribute
// exit/terminate callers by thread. Populated once via InterlockedCompareExchange so
// the very first tick wins even if the hook races (in practice UE4's game thread is
// serial, but the exchange is cheap insurance). Used only for diagnostic logging.
static volatile DWORD GameTickThreadId = 0;
// Resolve which loaded module (by base name) contains a given code address. Returns
// "?" if the address isn't in any mapped module. Used by the exit-hook diagnostics
// to identify whether TerminateProcess is being called from the game exe, libcef,
// our own DLL, or some other component.
static std::string MpModuleOfAddress(void* Addr) {
if (!Addr) {
return "null";
}
HMODULE Module = nullptr;
if (!GetModuleHandleExW(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(Addr),
&Module) || !Module) {
return "?";
}
wchar_t PathW[MAX_PATH]{};
DWORD PathLen = GetModuleFileNameW(Module, PathW, static_cast<DWORD>(std::size(PathW)));
if (PathLen == 0) {
return "?";
}
std::wstring Path(PathW, PathLen);
size_t Slash = Path.find_last_of(L"\\/");
std::wstring Base = (Slash == std::wstring::npos) ? Path : Path.substr(Slash + 1);
return MpNarrow(Base);
}
// Compact diagnostic prefix logged on each exit-path hook. Captures which thread is
// terminating the process and which module holds the immediate caller — this is the
// smoking gun for "who initiated shutdown" (libcef IPC watchdog vs the game thread
// vs an unhandled AV in our own code).
static std::string MpExitDiag(void* ReturnAddress) {
DWORD Tid = GetCurrentThreadId();
DWORD GameTid = static_cast<DWORD>(InterlockedCompareExchange(
reinterpret_cast<volatile LONG*>(&GameTickThreadId), 0, 0));
std::string Msg = " tid=" + std::to_string(Tid);
Msg += " gameTid=" + std::to_string(GameTid);
Msg += " isGameThread=";
Msg += (GameTid != 0 && Tid == GameTid) ? "1" : "0";
Msg += " retAddr=" + MpAddress(ReturnAddress);
Msg += " retModule=" + MpModuleOfAddress(ReturnAddress);
return Msg;
}
void WINAPI ExitProcessHook(UINT ExitCode) {
if (MpProcessDetaching()) {
OrigExitProcess(ExitCode);
return;
}
void* Caller = _ReturnAddress();
MpLog("[ExitTrace] kernel32!ExitProcess entry code=" + std::to_string(ExitCode) + MpExitDiag(Caller));
MpLogStack("kernel32!ExitProcess", ExitCode);
OrigExitProcess(ExitCode);
}
void WINAPI RtlExitUserProcessHook(ULONG ExitCode) {
if (MpProcessDetaching()) {
OrigRtlExitUserProcess(ExitCode);
return;
}
void* Caller = _ReturnAddress();
MpLog("[ExitTrace] ntdll!RtlExitUserProcess entry code=" + std::to_string(ExitCode) + MpExitDiag(Caller));
MpLogStack("ntdll!RtlExitUserProcess", ExitCode);
OrigRtlExitUserProcess(ExitCode);
}
BOOL WINAPI TerminateProcessHook(HANDLE Process, UINT ExitCode) {
if (MpProcessDetaching()) {
return OrigTerminateProcess(Process, ExitCode);
}
void* Caller = _ReturnAddress();
MpLog("[ExitTrace] kernelbase!TerminateProcess handle=" + MpPtr(Process)
+ " code=" + std::to_string(ExitCode)
+ MpExitDiag(Caller));
MpLogStack("kernelbase!TerminateProcess", ExitCode);
return OrigTerminateProcess(Process, ExitCode);
}
LONG NTAPI NtTerminateProcessHook(HANDLE Process, LONG ExitStatus) {
if (MpProcessDetaching()) {
return OrigNtTerminateProcess(Process, ExitStatus);
}
void* Caller = _ReturnAddress();
MpLog("[ExitTrace] ntdll!NtTerminateProcess handle=" + MpPtr(Process)
+ " status=" + MpHex(static_cast<uint32_t>(ExitStatus))
+ MpExitDiag(Caller));
MpLogStack("ntdll!NtTerminateProcess", static_cast<uint32_t>(ExitStatus));
return OrigNtTerminateProcess(Process, ExitStatus);
}
static bool ShouldTraceExceptionCode(DWORD Code) {
if (Code == DBG_PRINTEXCEPTION_C ||
Code == DBG_PRINTEXCEPTION_WIDE_C ||
Code == 0x406D1388 || // MSVC thread-name exception.
Code == 0xE06D7363 || // MSVC C++ exception (CxxThrowException).
Code == 0x40010005 ||
Code == 0x40010006 ||
Code == 0x40010007 ||
Code == 0x40010008) {
return false;
}
return true;
}
static void LogExceptionRecord(const char* Tag, const EXCEPTION_RECORD* Record, const CONTEXT* Context) {
if (!ShouldTraceExceptionCode(Record ? Record->ExceptionCode : 0)) {
return;
}
LONG Count = InterlockedIncrement(&ExceptionTraceCount);
if (Count > 96) {
return;
}
std::string Msg = "[ExceptionTrace] ";
Msg += Tag;
if (Record) {
Msg += " code=" + MpHex(Record->ExceptionCode);
Msg += " flags=" + MpHex(Record->ExceptionFlags);
Msg += " address=" + MpAddress(Record->ExceptionAddress);
uintptr_t Base = Globals::BaseAddress ? Globals::BaseAddress : reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
uintptr_t ExceptionAddress = reinterpret_cast<uintptr_t>(Record->ExceptionAddress);
if (Base && ExceptionAddress >= Base && ExceptionAddress < Base + 0x08000000) {
Msg += " rva=+" + MpHex(ExceptionAddress - Base);
}
if (Record->NumberParameters > 0) {
Msg += " params=";
DWORD ParamCount = Record->NumberParameters;
if (ParamCount > EXCEPTION_MAXIMUM_PARAMETERS) {
ParamCount = EXCEPTION_MAXIMUM_PARAMETERS;
}
for (DWORD i = 0; i < ParamCount; ++i) {
if (i != 0) {
Msg += ",";
}
Msg += MpHex(static_cast<uintptr_t>(Record->ExceptionInformation[i]));
}
}
}
#if defined(_M_X64)
if (Context) {
Msg += " rip=" + MpAddress(reinterpret_cast<void*>(Context->Rip));
Msg += " rsp=" + MpHex(static_cast<uintptr_t>(Context->Rsp));
Msg += " regs{rax=" + MpHex(static_cast<uintptr_t>(Context->Rax))
+ " rcx=" + MpHex(static_cast<uintptr_t>(Context->Rcx))
+ " rdx=" + MpHex(static_cast<uintptr_t>(Context->Rdx))
+ " rbx=" + MpHex(static_cast<uintptr_t>(Context->Rbx))
+ " rsi=" + MpHex(static_cast<uintptr_t>(Context->Rsi))
+ " rdi=" + MpHex(static_cast<uintptr_t>(Context->Rdi))
+ " r8=" + MpHex(static_cast<uintptr_t>(Context->R8))
+ " r9=" + MpHex(static_cast<uintptr_t>(Context->R9)) + "}";
}
#endif
MpLog(Msg);
MpLogStack(Tag, Record ? Record->ExceptionCode : 0);
}
void WINAPI RaiseExceptionHook(DWORD Code, DWORD Flags, DWORD ArgCount, const ULONG_PTR* Args) {
if (MpProcessDetaching()) {
OrigRaiseException(Code, Flags, ArgCount, Args);
return;
}
EXCEPTION_RECORD Record{};
Record.ExceptionCode = Code;
Record.ExceptionFlags = Flags;
Record.ExceptionAddress = _ReturnAddress();
Record.NumberParameters = ArgCount > EXCEPTION_MAXIMUM_PARAMETERS ? EXCEPTION_MAXIMUM_PARAMETERS : ArgCount;
if (Args && Record.NumberParameters > 0) {
for (DWORD i = 0; i < Record.NumberParameters; ++i) {
Record.ExceptionInformation[i] = Args[i];
}
}
LogExceptionRecord("kernelbase!RaiseException", &Record, nullptr);
OrigRaiseException(Code, Flags, ArgCount, Args);
}
static void MpLogTravelCrashState(CONTEXT* Context); // [1.14.7 travel-crash probe] defined with the world census
static void MpReportZombieWorld(void* World, const char* Reason); // [1.14.7 travel-crash probe] leftover-world holders
static void MpReleaseZombieKeepFlags(void* const* Worlds, int NumWorlds, const char* Reason); // [1.14.7 travel-crash fix]
static bool MpZombieKeepFlagReleased(void* World);
LONG WINAPI VectoredExceptionTrace(EXCEPTION_POINTERS* ExceptionInfo) {
if (MpProcessDetaching()) {
return EXCEPTION_CONTINUE_SEARCH;
}
static thread_local bool InHandler = false;
if (InHandler) {
return EXCEPTION_CONTINUE_SEARCH;
}
InHandler = true;
if (IsReadablePointer(ExceptionInfo, sizeof(EXCEPTION_POINTERS))) {
EXCEPTION_RECORD* Record = ExceptionInfo->ExceptionRecord;
CONTEXT* Context = ExceptionInfo->ContextRecord;
if (IsReadablePointer(Record, sizeof(EXCEPTION_RECORD))) {
#if defined(_M_X64)
// [1.14.7 2026-10-08] Was keyed on the 1.12 site 0x024AE5B0 (mid-function on 1.14.7, so the guard
// never fired) and redirected Rdi to the 1.12 .rdata literal 0x04DF2E8C (inside .text on 1.14.7).
// tools/rva-port-audit.py maps the site to 0x02660278 (bidirectionally verified; the same
// `lea edi,[rax+rax]; add rdi,[r8+rcx*8+0x10]; movzx eax,word ptr [rdi]` sequence). The empty
// name entry is now our own zeroed buffer, so no engine data address is needed.
static const uint16_t s_EmptyNameEntry[16] = {};
if (Context && IsReadablePointer(Context, sizeof(CONTEXT)) &&
Record->ExceptionCode == EXCEPTION_ACCESS_VIOLATION &&
Globals::BaseAddress &&
Context->Rip == Globals::BaseAddress + 0x02660278) {
MpLog("[ExceptionTrace] guarding invalid FName text read at +0x2660278 rdi="
+ MpHex(static_cast<uintptr_t>(Context->Rdi))
+ " access=" + (Record->NumberParameters > 1
? MpHex(static_cast<uintptr_t>(Record->ExceptionInformation[1]))
: std::string("(unknown)")));
Context->Rdi = reinterpret_cast<uintptr_t>(s_EmptyNameEntry); // header 0 = empty, non-wide name.
InHandler = false;
return EXCEPTION_CONTINUE_EXECUTION;
}
// [2026-10-10 1.14.7 travel-crash probe, observe-only] The hunt -> Ramsgate crash: UEngine::LoadMap reads
// the new world's null PersistentLevel through GetOutermost (helper 0x0285E6F0). Neither the helper nor its
// wrapper touches r12, which still holds LoadMap's FWorldContext, so the world it used is [r12+0x280].
// Dump that world's state once; the exception then continues exactly as before.
if (!Globals::AmServer && Context && IsReadablePointer(Context, sizeof(CONTEXT))
&& Record->ExceptionCode == EXCEPTION_ACCESS_VIOLATION && Globals::BaseAddress
&& Context->Rip == Globals::BaseAddress + 0x0285E6F0) {
MpLogTravelCrashState(Context);
}
// [1.14.7 2026-10-08] The two spline-fault recoveries that sat here (+0x1091D5D evaluator AV and
// the +0x3D47B31/+0x3D47B0E null-destination store) are removed. The "spline crash" was this
// module calling the stale 1.12 CreateChannelByName RVA 0x03D47AC0, which lands mid-instruction in
// FUN_143d47a90 (see Networking.cpp BootstrapActorChannel). The evaluator recovery also unwound
// 8 bytes short (RSP += 0xB8 where the prologue needs 0xC0), which is what produced rip=0x16A.
#endif
LogExceptionRecord("VEH", Record, IsReadablePointer(Context, sizeof(CONTEXT)) ? Context : nullptr);
}
}
InHandler = false;
return EXCEPTION_CONTINUE_SEARCH;
}
void __cdecl UcrtExitHook(int ExitCode) {
if (MpProcessDetaching()) {
OrigUcrtExit(ExitCode);
return;
}
void* Caller = _ReturnAddress();
MpLog("[ExitTrace] ucrtbase!exit entry code=" + std::to_string(ExitCode) + MpExitDiag(Caller));
MpLogStack("ucrtbase!exit", static_cast<uint32_t>(ExitCode));
OrigUcrtExit(ExitCode);
}
void __cdecl UcrtUnderscoreExitHook(int ExitCode) {
if (MpProcessDetaching()) {
OrigUcrtUnderscoreExit(ExitCode);
return;
}
void* Caller = _ReturnAddress();
MpLog("[ExitTrace] ucrtbase!_exit entry code=" + std::to_string(ExitCode) + MpExitDiag(Caller));
MpLogStack("ucrtbase!_exit", static_cast<uint32_t>(ExitCode));
OrigUcrtUnderscoreExit(ExitCode);
}
void __cdecl MsvcrtExitHook(int ExitCode) {
if (MpProcessDetaching()) {
OrigMsvcrtExit(ExitCode);
return;
}
void* Caller = _ReturnAddress();
MpLog("[ExitTrace] msvcrt!exit entry code=" + std::to_string(ExitCode) + MpExitDiag(Caller));
MpLogStack("msvcrt!exit", static_cast<uint32_t>(ExitCode));
OrigMsvcrtExit(ExitCode);
}
void __cdecl MsvcrtUnderscoreExitHook(int ExitCode) {
if (MpProcessDetaching()) {
OrigMsvcrtUnderscoreExit(ExitCode);
return;
}
void* Caller = _ReturnAddress();
MpLog("[ExitTrace] msvcrt!_exit entry code=" + std::to_string(ExitCode) + MpExitDiag(Caller));
MpLogStack("msvcrt!_exit", static_cast<uint32_t>(ExitCode));
OrigMsvcrtUnderscoreExit(ExitCode);
}
void __cdecl UcrtAbortHook() {
if (MpProcessDetaching()) {
OrigUcrtAbort();
return;
}
void* Caller = _ReturnAddress();
MpLog("[ExitTrace] ucrtbase!abort entry" + MpExitDiag(Caller));
MpLogStack("ucrtbase!abort", 3);
OrigUcrtAbort();
}
void __cdecl MsvcrtAbortHook() {
if (MpProcessDetaching()) {
OrigMsvcrtAbort();
return;
}
void* Caller = _ReturnAddress();
MpLog("[ExitTrace] msvcrt!abort entry" + MpExitDiag(Caller));
MpLogStack("msvcrt!abort", 3);
OrigMsvcrtAbort();
}
LONG WINAPI UEUnhandledExceptionFilterHook(EXCEPTION_POINTERS* ExceptionInfo) {
if (MpProcessDetaching()) {
return reinterpret_cast<LONG(WINAPI*)(EXCEPTION_POINTERS*)>(OrigUnhandledExceptionFilter)(ExceptionInfo);
}
std::string Msg = "[ExitTrace] UEUnhandledExceptionFilter";
if (IsReadablePointer(ExceptionInfo, sizeof(EXCEPTION_POINTERS)) &&
IsReadablePointer(ExceptionInfo->ExceptionRecord, sizeof(EXCEPTION_RECORD))) {
EXCEPTION_RECORD* Record = ExceptionInfo->ExceptionRecord;
Msg += " code=" + MpHex(Record->ExceptionCode);
Msg += " flags=" + MpHex(Record->ExceptionFlags);
Msg += " address=" + MpAddress(Record->ExceptionAddress);
uintptr_t Base = Globals::BaseAddress ? Globals::BaseAddress : reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
uintptr_t ExceptionAddress = reinterpret_cast<uintptr_t>(Record->ExceptionAddress);
if (Base && ExceptionAddress >= Base && ExceptionAddress < Base + 0x08000000) {
Msg += " rva=+" + MpHex(ExceptionAddress - Base);
}
}
else {
Msg += " exception_info_unreadable=" + MpPtr(ExceptionInfo);
}
#if defined(_M_X64)
if (IsReadablePointer(ExceptionInfo, sizeof(EXCEPTION_POINTERS)) &&
IsReadablePointer(ExceptionInfo->ContextRecord, sizeof(CONTEXT))) {
Msg += " rip=" + MpAddress(reinterpret_cast<void*>(ExceptionInfo->ContextRecord->Rip));
Msg += " rsp=" + MpHex(static_cast<uintptr_t>(ExceptionInfo->ContextRecord->Rsp));
}
#endif
MpLog(Msg);
MpLogStack("UEUnhandledExceptionFilter", 3);
return reinterpret_cast<LONG(WINAPI*)(EXCEPTION_POINTERS*)>(OrigUnhandledExceptionFilter)(ExceptionInfo);
}
static void InstallApiHook(LPCWSTR Module, LPCSTR ProcName, LPVOID Detour, LPVOID* Original, const char* Tag) {
LPVOID Target = nullptr;
MH_STATUS CreateStatus = MH_CreateHookApiEx(Module, ProcName, Detour, Original, &Target);
MH_STATUS EnableStatus = CreateStatus == MH_OK ? MH_EnableHook(Target) : CreateStatus;
MpLog("[ExitTrace] hook " + std::string(Tag)
+ " create=" + MH_StatusToString(CreateStatus)
+ " enable=" + MH_StatusToString(EnableStatus)
+ " target=" + MpPtr(Target));
}
// ============================================================================
// [1.14.7 EOS Suppression] Force -DisableEOS on client so OnlineSubsystem falls back
// to MCP (routes/eos.ts) instead of attempting standalone EOS SDK auth which fails.
// ============================================================================
using GetCommandLineWFn = LPCWSTR(WINAPI*)();
using GetCommandLineAFn = LPCSTR(WINAPI*)();
static GetCommandLineWFn OrigGetCommandLineW = nullptr;
static GetCommandLineAFn OrigGetCommandLineA = nullptr;
static std::wstring g_ModifiedCmdLineW;
static std::string g_ModifiedCmdLineA;
static bool g_CmdLineWInited = false;
static bool g_CmdLineAInited = false;
// [1.14.7 EAC flags] Recorded booleans only - never the raw arguments (declared here because
// HookFCommandLineSet below is defined before the EAC block that logs them).
static bool g_engineCmdLineSawNoEAC = false;
static bool g_engineCmdLineSawDisableEOS = false;
static LPCWSTR WINAPI HookGetCommandLineW() {
if (!g_CmdLineWInited) {
LPCWSTR orig = OrigGetCommandLineW ? OrigGetCommandLineW() : GetCommandLineW();
if (!orig) orig = L"";
g_ModifiedCmdLineW = orig;
if (g_ModifiedCmdLineW.find(L"-DisableEOS") == std::wstring::npos) {
g_ModifiedCmdLineW += L" -DisableEOS";
MpLog("[CommandLine] Appended -DisableEOS to GetCommandLineW");
}
// [1.14.7 EAC FIX] Same reasoning as FCommandLine::Set below: make sure the OS-visible line also
// carries -NoEAC regardless of how the client was started.
if (g_ModifiedCmdLineW.find(L"-NoEAC") == std::wstring::npos) {
g_ModifiedCmdLineW += L" -NoEAC";
MpLog("[CommandLine] Appended -NoEAC to GetCommandLineW");
}
g_CmdLineWInited = true;
}
return g_ModifiedCmdLineW.c_str();
}
static LPCSTR WINAPI HookGetCommandLineA() {
if (!g_CmdLineAInited) {
LPCSTR orig = OrigGetCommandLineA ? OrigGetCommandLineA() : GetCommandLineA();
if (!orig) orig = "";
g_ModifiedCmdLineA = orig;
if (g_ModifiedCmdLineA.find("-DisableEOS") == std::string::npos) {
g_ModifiedCmdLineA += " -DisableEOS";
MpLog("[CommandLine] Appended -DisableEOS to GetCommandLineA");
}
if (g_ModifiedCmdLineA.find("-NoEAC") == std::string::npos) {
g_ModifiedCmdLineA += " -NoEAC";
MpLog("[CommandLine] Appended -NoEAC to GetCommandLineA");
}
g_CmdLineAInited = true;
}
return g_ModifiedCmdLineA.c_str();
}
// [1.14.7 EAC ROOT FIX 2026-10-04] StartupModule (RVA 0x01474ED0) decides whether to load EAC with:
// uVar4 = FUN_1425e8f70(); // FCommandLine::Get() <- RVA 0x025E8F70
// cVar3 = FUN_142634de0(uVar4, L"NoEAC"); // FParse::Param
// if (cVar3 == '\0') { ...initialise EAC... }
// Measured on a real client run: os_NoEAC=1 but engine_FCommandLine_NoEAC=0 - the Launcher passes -NoEAC on
// the OS command line, while the engine's own FCommandLine does not carry it, so this check fails, EAC
// initialises, IGameClient creation fails (0x01477DD0) and HandleError (0x01476F20) reports it. Absorbing
// that dialog only hides the symptom (the client then sits at the login screen and never travels).
//
// This hooks FCommandLine::Get() itself - the exact function the check calls - and returns the engine's own
// string with -NoEAC appended when it is missing. The OS-line hooks above cannot help here: the engine builds
// its FCommandLine independently of GetCommandLineW/A.
using FCommandLineGetFn = const wchar_t*(*)(void);
static FCommandLineGetFn OrigFCommandLineGet = nullptr;
static std::wstring g_engineCmdLineServed;
static bool g_engineCmdLineLoggedOnce = false;
// [1.14.7 NULLRHI ROOT FIX 2026-10-04] The OS command line still carries the switches the deploy
// launcher requested even when the engine's own FCommandLine does not - the same gap already measured
// and documented for -NoEAC/-DisableEOS immediately below. Booleans only; no raw arguments are logged.
static bool MpOsCommandLineHas(const wchar_t* Param) {
LPCWSTR Os = OrigGetCommandLineW ? OrigGetCommandLineW() : GetCommandLineW();
return Os != nullptr && Param != nullptr && wcsstr(Os, Param) != nullptr;
}
/*
* [2026-10-11 1.14.7 P2P host MULTIHOME] The engine chooses the address its net driver binds in
* ISocketSubsystem::GetMultihomeAddress (0x032AE4B0, a vtable slot; GetLocalBindAddr 0x032AE2C0 calls it and falls
* back to every interface when it answers false):
* FParse::Value(FCommandLine::Get(), L"MULTIHOME=", Home, 256) // 0x02640610 / 0x025E8F70
* read on every bind from the string HookFCommandLineGet serves. OS-only switches never reach that string (every hub
* logs engineOriginalNullRHI=0 osNullRHI=1), so the -MULTIHOME=127.0.0.1 the launcher gives the player-hosted server
* - to bind loopback only, reachable through the relay alone - was ignored and the host bound every interface,
* bypassing relay admission. HookFCommandLineGet now carries it like -nullrhi: server role only, only when the OS line
* has it and the served line has none, and only a strict IPv4 or IPv6 literal (an invalid value would make the
* engine log "Invalid multihome IP address" and bind every interface anyway, so it is refused and logged instead).
* DeployServer launches pass no MULTIHOME: dedicated hubs and hunts are unchanged.
* The effective value (carried or the engine's own) is published once as POD for the watchdog thread's port probe
* (MpOurUdpPortIsFree) and for the listen check in Networking::Listen (MpEngineCommandLineMultihome). No revert flag:
* reverting would reopen the all-interfaces bind on the player host.
*/
static char g_MpEngineMultihomeA[64] = { 0 }; // effective MULTIHOME of the served engine line, "" = none
static volatile LONG g_MpEngineMultihomeState = 0; // 0 = served line not built yet, 1 = published
// Strict dotted quad ("127.0.0.1"): four decimal parts 0..255, no sign, no empty part, no leading zero (octal-looking
// parts are read differently by different parsers), at most 3 digits each.
static bool MpParseIpv4Literal(const char* Text, uint32_t& OutHostOrder) {
if (!Text) return false;
uint32_t Result = 0;
const char* P = Text;
for (int Part = 0; Part < 4; ++Part) {
if (Part > 0) { if (*P != '.') return false; ++P; }
if (P[0] == '0' && P[1] >= '0' && P[1] <= '9') return false;
int Digits = 0;
uint32_t Value = 0;
while (*P >= '0' && *P <= '9') {
Value = Value * 10 + static_cast<uint32_t>(*P - '0');
++P;
if (++Digits > 3) return false;
}
if (Digits == 0 || Value > 255) return false;
Result = (Result << 8) | Value;
}
if (*P != '\0') return false;
OutHostOrder = Result;
return true;
}
// IPv4 dotted quad, or an IPv6 text form (hex digits, ':' and an optional embedded dotted quad; at least two ':').
static bool MpIsIpLiteralW(const std::wstring& Value) {
if (Value.empty() || Value.size() > 45) return false;
std::string Narrow;
for (wchar_t C : Value) {
if (C > 0x7F) return false;
Narrow.push_back(static_cast<char>(C));
}
uint32_t Ignored = 0;
if (MpParseIpv4Literal(Narrow.c_str(), Ignored)) return true;
int Colons = 0;
for (char C : Narrow) {
if (C == ':') { ++Colons; continue; }
if (!((C >= '0' && C <= '9') || (C >= 'a' && C <= 'f') || (C >= 'A' && C <= 'F') || C == '.')) return false;
}
return Colons >= 2;
}
// MULTIHOME value in an engine command line as FParse::Value (0x02640610) finds it: first case-insensitive match not
// preceded by a letter or digit and outside double quotes; the value runs to the next blank, comma, quote or ')'.
static std::wstring MpEngineLineMultihome(const wchar_t* Line) {
if (!Line) return {};
bool InQuotes = false;
wchar_t Prev = 0;
for (const wchar_t* P = Line; *P; ++P) {
if (*P == L'"') { InQuotes = !InQuotes; Prev = *P; continue; }
const bool PrevAlnum = (Prev >= L'0' && Prev <= L'9') || (Prev >= L'A' && Prev <= L'Z') || (Prev >= L'a' && Prev <= L'z');
if (!InQuotes && !PrevAlnum && _wcsnicmp(P, L"MULTIHOME=", 10) == 0) {
const wchar_t* V = P + 10;
const wchar_t* E = V;
while (*E && *E != L' ' && *E != L'\t' && *E != L',' && *E != L'"' && *E != L')') ++E;
return std::wstring(V, E);
}
Prev = *P;
}
return {};
}
// MULTIHOME value on the OS command line: the first argument -MULTIHOME=<v> (case-insensitive), split the way the
// player-host module's launch check (CommandLineToArgvW) reads it. Found=false when there is none.
static std::wstring MpOsCommandLineMultihome(bool& Found) {
Found = false;
LPCWSTR Os = OrigGetCommandLineW ? OrigGetCommandLineW() : GetCommandLineW();
if (!Os) return {};
int NumArgs = 0;
wchar_t** Args = CommandLineToArgvW(Os, &NumArgs);
if (!Args) return {};
std::wstring Value;
for (int Index = 1; Index < NumArgs; ++Index) {
if (Args[Index] && _wcsnicmp(Args[Index], L"-MULTIHOME=", 11) == 0) {
Value = Args[Index] + 11;
Found = true;
break;
}
}
LocalFree(Args);
return Value;
}
// Effective MULTIHOME of the served engine line (wide copy of the published POD), empty when there is none or the
// line was never served. Used by Networking::Listen (declared there).
std::wstring MpEngineCommandLineMultihome() {
if (InterlockedCompareExchange(&g_MpEngineMultihomeState, 0, 0) == 0) return {};
std::wstring Out;
for (const char* P = g_MpEngineMultihomeA; *P; ++P) Out.push_back(static_cast<wchar_t>(*P));
return Out;
}
// Inside HookFCommandLineGet's one-time build: carry the OS -MULTIHOME= on server roles, then publish the effective value.
static void MpCarryMultihomeIntoEngineLine(std::wstring& Served) {
std::wstring Effective = MpEngineLineMultihome(Served.c_str());
if (Globals::AmServer) {
bool OsFound = false;
const std::wstring OsValue = MpOsCommandLineMultihome(OsFound);
if (OsFound && Effective.empty()) {
if (MpIsIpLiteralW(OsValue)) {
Served += L" -MULTIHOME=" + OsValue;
Effective = OsValue;
MpLog("[CommandLine] MULTIHOME=" + MpNarrow(OsValue) + " carried from the OS command line into the engine "
"FCommandLine (server role; GetMultihomeAddress 0x032AE4B0 binds the net driver to it)");
} else {
MpLog("[CommandLine] MULTIHOME on the OS command line is not an IP literal (" + std::to_string(OsValue.size())
+ " chars); NOT carried - the engine binds every interface (a player-hosted server then refuses to listen)");
}
} else if (OsFound && _wcsicmp(Effective.c_str(), OsValue.c_str()) != 0) {
MpLog("[CommandLine] WARNING: the engine FCommandLine already has MULTIHOME=" + MpNarrow(Effective)
+ ", which differs from the OS command line's; the engine's value is kept");
}
}
// Publish once. Only a validated literal fits the POD buffer; anything else is published as "" plus the
// warning, which makes the port probe fall back and the player-host listen check refuse.
if (!Effective.empty() && (!MpIsIpLiteralW(Effective) || Effective.size() >= sizeof(g_MpEngineMultihomeA))) {
MpLog("[CommandLine] WARNING: the engine FCommandLine's MULTIHOME is not an IP literal (" + std::to_string(Effective.size())
+ " chars); the engine rejects it and binds every interface");
Effective.clear();
}
size_t Length = 0;
for (wchar_t C : Effective) g_MpEngineMultihomeA[Length++] = static_cast<char>(C);
g_MpEngineMultihomeA[Length] = '\0';
InterlockedExchange(&g_MpEngineMultihomeState, 1);
}
static const wchar_t* HookFCommandLineGet() {
const wchar_t* Orig = OrigFCommandLineGet ? OrigFCommandLineGet() : nullptr;
if (!Orig) return Orig;
// Record what the ENGINE actually had (booleans only, no raw arguments) - this is the authoritative
// "engine's effective FCommandLine" for the [EACFlags] line.
if (wcsstr(Orig, L"-NoEAC") != nullptr) g_engineCmdLineSawNoEAC = true;
if (wcsstr(Orig, L"-DisableEOS") != nullptr) g_engineCmdLineSawDisableEOS = true;
const bool EngineOriginalNullRhi = wcsstr(Orig, L"-nullrhi") != nullptr;
if (g_engineCmdLineServed.empty()) {
g_engineCmdLineServed = Orig;
if (g_engineCmdLineServed.find(L"-NoEAC") == std::wstring::npos) {
g_engineCmdLineServed += L" -NoEAC";
MpLog("[CommandLine] appended -NoEAC to the engine FCommandLine (FCommandLine::Get, RVA 0x025E8F70)");
}
if (g_engineCmdLineServed.find(L"-DisableEOS") == std::wstring::npos) {
g_engineCmdLineServed += L" -DisableEOS";
MpLog("[CommandLine] appended -DisableEOS to the engine FCommandLine (FCommandLine::Get)");
}
// [1.14.7 NULLRHI ROOT FIX 2026-10-04] The RHI selector reads
// FParse::Param(FCommandLine::Get(), L"nullrhi") // RVA 0x02634DE0 / 0x025E8F70
// and caches the result in the function-local static at RVA 0x06CD7878; a nonzero cache loads the
// null driver instead of a D3D11 device (FUN_14349a760). The engine's own FCommandLine is built
// independently of the OS command line, so a dedicated server launched with -nullrhi still cached
// 0 and brought up a real hardware device - SW_HIDE hid the window but the GPU load remained
// (measured on one controlled hub: 37-41% of the GPU 3D engine). Carry the requested switch into
// the string this hook serves, so the engine's decision is the requested one. Server role only.
if (Globals::AmServer && g_engineCmdLineServed.find(L"-nullrhi") == std::wstring::npos
&& MpOsCommandLineHas(L"-nullrhi")) {
g_engineCmdLineServed += L" -nullrhi";
MpLog("[CommandLine] appended -nullrhi to the engine FCommandLine (server role)");
}
// [2026-10-11 1.14.7 P2P host MULTIHOME] The bind address, the same gap (see MpCarryMultihomeIntoEngineLine).
MpCarryMultihomeIntoEngineLine(g_engineCmdLineServed);
}
if (!g_engineCmdLineLoggedOnce) {
g_engineCmdLineLoggedOnce = true;
MpLog("[CommandLine] engine FCommandLine served with NoEAC="
+ std::to_string(g_engineCmdLineServed.find(L"-NoEAC") != std::wstring::npos ? 1 : 0)
+ " NullRHI=" + std::to_string(g_engineCmdLineServed.find(L"-nullrhi") != std::wstring::npos ? 1 : 0)
+ " engineOriginalNullRHI=" + std::to_string(EngineOriginalNullRhi ? 1 : 0)
+ " osNullRHI=" + std::to_string(MpOsCommandLineHas(L"-nullrhi") ? 1 : 0)
+ " Multihome=" + (g_MpEngineMultihomeA[0] ? std::string(g_MpEngineMultihomeA) : std::string("none")));
}
return g_engineCmdLineServed.c_str();
}
// ============================================================================
// [1.14.7 QOS HOST RESOLUTION FIX 2026-10-04]
//
// The client measures regions by pinging every regionUrl from /candidate/regions over PLAIN HTTP on port 80,
// expecting exactly <!DOCTYPE html><html><body>pong</body></html>
// (DauntlessEndpointDocumentation/Matchmaking/Candidate/GetRegions.md). The real service lists seven
// *.gce.latency-test.steelyard.ca hosts, which no longer resolve. Measured consequence in the client log:
//
// LogSockets [Warning] GetAddressInfo failed to resolve host ... SE_HOST_NOT_FOUND [11001] x4
// LogQos [Warning] Unable to set a good region!
// LogQos [Warning] Wanted to set NONE, failed to fall back to
// LogQos [Display] Current: <- EMPTY
// LogQos [Display] Datacenter: euw11 9999ms (0.00%) Invalid (and 6 more, all Invalid)
//
// so it travelled with no region at all. The Metagame now answers those pings on port 80; the names still
// have to resolve. Editing the machine's hosts file needs elevation, so resolve them here to loopback - the
// same in-process rewrite pattern already used for HTTP URLs. Both the wide and ANSI entry points are hooked
// because UE's socket subsystem reaches them through different paths.
// Signatures are declared with void* deliberately: the Winsock headers cannot be included here (winsock2.h
// must precede windows.h, which the project's other headers already pull in) and these hooks only forward
// the pointers untouched. WSAAPI is __stdcall; the return is INT (0 on success, 11001 = WSAHOST_NOT_FOUND).
using GetAddrInfoWFn = int (__stdcall*)(const wchar_t*, const wchar_t*, const void*, void**);
using GetAddrInfoAFn = int (__stdcall*)(const char*, const char*, const void*, void**);
static const int MP_WSAHOST_NOT_FOUND = 11001;
static GetAddrInfoWFn OrigGetAddrInfoW = nullptr;
static GetAddrInfoAFn OrigGetAddrInfoA = nullptr;
static bool MpIsQosLatencyTestHostW(PCWSTR NodeName) {
if (!NodeName) return false;
return std::wstring(NodeName).find(L"gce.latency-test.steelyard.ca") != std::wstring::npos;
}
static bool MpIsQosLatencyTestHostA(PCSTR NodeName) {
if (!NodeName) return false;
return std::string(NodeName).find("gce.latency-test.steelyard.ca") != std::string::npos;
}
static void MpLogQosResolveOnce() {
static std::atomic<int> s_logged{ 0 };
if (s_logged.fetch_add(1, std::memory_order_relaxed) < 3) {
MpLog("[QoSFix] resolved a latency-test host to 127.0.0.1 (the Metagame answers pong on port 80)");
}
}
static int __stdcall HookGetAddrInfoW(const wchar_t* NodeName, const wchar_t* ServiceName, const void* Hints, void** Result) {
if (MpIsQosLatencyTestHostW(NodeName)) {
MpLogQosResolveOnce();
if (OrigGetAddrInfoW) return OrigGetAddrInfoW(L"127.0.0.1", ServiceName, Hints, Result);
}
return OrigGetAddrInfoW ? OrigGetAddrInfoW(NodeName, ServiceName, Hints, Result) : MP_WSAHOST_NOT_FOUND;
}
static int __stdcall HookGetAddrInfoA(const char* NodeName, const char* ServiceName, const void* Hints, void** Result) {
if (MpIsQosLatencyTestHostA(NodeName)) {
MpLogQosResolveOnce();
if (OrigGetAddrInfoA) return OrigGetAddrInfoA("127.0.0.1", ServiceName, Hints, Result);
}
return OrigGetAddrInfoA ? OrigGetAddrInfoA(NodeName, ServiceName, Hints, Result) : MP_WSAHOST_NOT_FOUND;
}
static void InstallQosHostResolutionHook() {
HMODULE Ws2 = GetModuleHandleW(L"ws2_32.dll");
if (!Ws2) {
MpLog("[QoSFix] ws2_32.dll not loaded; latency-test host hook skipped");
return;
}
void* TargetW = reinterpret_cast<void*>(GetProcAddress(Ws2, "GetAddrInfoW"));
if (TargetW) {
MH_STATUS Create = MH_CreateHook(TargetW, HookGetAddrInfoW, reinterpret_cast<LPVOID*>(&OrigGetAddrInfoW));
MH_STATUS Enable = Create == MH_OK ? MH_EnableHook(TargetW) : Create;
MpLog(std::string("[QoSFix] GetAddrInfoW hook create=") + MH_StatusToString(Create)
+ " enable=" + MH_StatusToString(Enable));
}
void* TargetA = reinterpret_cast<void*>(GetProcAddress(Ws2, "getaddrinfo"));
if (TargetA) {
MH_STATUS Create = MH_CreateHook(TargetA, HookGetAddrInfoA, reinterpret_cast<LPVOID*>(&OrigGetAddrInfoA));
MH_STATUS Enable = Create == MH_OK ? MH_EnableHook(TargetA) : Create;
MpLog(std::string("[QoSFix] getaddrinfo hook create=") + MH_StatusToString(Create)
+ " enable=" + MH_StatusToString(Enable));
}
}
// ============================================================================
// [1.14.7 SOCKET DIAGNOSTICS 2026-10-04]
//
// The travel times out after 20s with the hub continuously available, its UDP port bound on 0.0.0.0 and the
// client told 127.0.0.1:8790 (verified=1). Nothing appears in the hub's own net log, so the open question is
// whether the client's datagrams reach the hub's socket at all. These hooks answer it directly: the client's
// sendto destinations and the hub's recvfrom sources, both logged with the raw sockaddr bytes.
//
// Winsock types are deliberately avoided (winsock2.h cannot be included here - it must precede windows.h and
// the project's other headers already pull that in). AF_INET is 2, the port is big-endian at offset 2 and the
// address bytes follow at offset 4.
using MpSendToFn = int (__stdcall*)(uintptr_t, const char*, int, int, const void*, int);
using MpRecvFromFn = int (__stdcall*)(uintptr_t, char*, int, int, void*, int*);
static MpSendToFn OrigSendTo = nullptr;
static MpRecvFromFn OrigRecvFrom = nullptr;
static std::atomic<int> g_sockDiagLines{ 0 };
static void MpLogSockAddr(const char* Tag, const void* Sa, int SaLen, int Len) {
if (!Sa || SaLen < 8) return;
const unsigned char* p = reinterpret_cast<const unsigned char*>(Sa);
const uint16_t Family = *reinterpret_cast<const uint16_t*>(p);
if (Family != 2) return; // AF_INET only
const uint16_t Port = static_cast<uint16_t>((p[2] << 8) | p[3]);
if (g_sockDiagLines.fetch_add(1, std::memory_order_relaxed) >= 30) return;
MpLog(std::string("[SocketDiag] ") + Tag + " " + std::to_string(p[4]) + "." + std::to_string(p[5]) + "."
+ std::to_string(p[6]) + "." + std::to_string(p[7]) + ":" + std::to_string(Port)
+ " len=" + std::to_string(Len) + " role=" + (Globals::AmServer ? "server" : "client"));
}
/*
* [2026-10-11 1.14.7 O0 wire bytes] Bytes the server actually puts on the wire, per destination address, for the
* once-a-minute [PerfNet] line (scaling one Ramsgate toward 30-40 players needs the per-connection budget). The
* sendto hook below now always runs on servers (NO_PERF_DETAIL.flag turns the counting and the hook off) and only
* counts: relaxed atomics into a fixed 64-slot table keyed by address:port, no allocation, no lock, no logging, no CRT.
* The verbose per-packet lines stay opt-in (SOCKET_DIAG.flag). UE's IpNetDriver sends with ws2_32!sendto (SOCKET_DIAG
* captured "sendto -> 127.0.0.1:8790"); WSASendTo is not hooked because nothing in the net driver uses it.
* A slot whose peer sent nothing for a whole window is released by the reader, so a long-lived hub keeps room for new
* joiners; a send racing that release can land in a released slot and is then reported as "unattributed". Once all 64
* slots are busy, further peers count into "other".
*/
struct MpWireSlot {
std::atomic<uint64_t> Key{ 0 };
std::atomic<uint64_t> Bytes{ 0 };
std::atomic<uint64_t> Packets{ 0 };
};
static MpWireSlot g_wireSlots[64];
static std::atomic<uint64_t> g_wireBytesTotal{ 0 };
static std::atomic<uint64_t> g_wirePacketsTotal{ 0 };
static std::atomic<uint64_t> g_wireBytesOther{ 0 };
static volatile LONG g_wireCountOn = 0; // set once by InstallSocketDiagnostics (server, !NO_PERF_DETAIL.flag)
// Key from a sockaddr: tag(8) | IPv4 address(32) | port(16); IPv4-mapped IPv6 maps to the IPv4 key; other IPv6 uses a
// 40-bit FNV-1a of the address. 0 = not countable.
static uint64_t MpWireKeyFromSockAddr(const void* Sa, int SaLen) {
if (!Sa || SaLen < 8) return 0;
const unsigned char* p = reinterpret_cast<const unsigned char*>(Sa);
const uint16_t Family = static_cast<uint16_t>(p[0] | (p[1] << 8));
const uint64_t Port = static_cast<uint64_t>((p[2] << 8) | p[3]);
if (Family == 2 && SaLen >= 8) {
const uint64_t Addr = (static_cast<uint64_t>(p[4]) << 24) | (static_cast<uint64_t>(p[5]) << 16)
| (static_cast<uint64_t>(p[6]) << 8) | static_cast<uint64_t>(p[7]);
return (1ull << 56) | (Addr << 16) | Port;
}
if (Family == 23 && SaLen >= 24) { // sockaddr_in6: family, port, flowinfo(4), address(16) at +8
const unsigned char* A = p + 8;
bool Mapped = A[10] == 0xFF && A[11] == 0xFF;
for (int i = 0; i < 10 && Mapped; ++i) Mapped = A[i] == 0;
if (Mapped) {
const uint64_t Addr = (static_cast<uint64_t>(A[12]) << 24) | (static_cast<uint64_t>(A[13]) << 16)
| (static_cast<uint64_t>(A[14]) << 8) | static_cast<uint64_t>(A[15]);
return (1ull << 56) | (Addr << 16) | Port;
}
uint64_t H = 1469598103934665603ull;
for (int i = 0; i < 16; ++i) { H ^= A[i]; H *= 1099511628211ull; }
return (2ull << 56) | ((H & 0xFFFFFFFFFFull) << 16) | Port;
}
return (3ull << 56) | (static_cast<uint64_t>(Family) << 16);
}
static std::string MpWireKeyText(uint64_t Key) {
const unsigned Tag = static_cast<unsigned>(Key >> 56);
const unsigned Port = static_cast<unsigned>(Key & 0xFFFF);
char Buf[64];
if (Tag == 1) {
const uint32_t A = static_cast<uint32_t>((Key >> 16) & 0xFFFFFFFFull);
sprintf_s(Buf, "%u.%u.%u.%u:%u", (A >> 24) & 0xFF, (A >> 16) & 0xFF, (A >> 8) & 0xFF, A & 0xFF, Port);
} else if (Tag == 2) {
sprintf_s(Buf, "v6#%010llX:%u", static_cast<unsigned long long>((Key >> 16) & 0xFFFFFFFFFFull), Port);
} else {
sprintf_s(Buf, "family%u", static_cast<unsigned>((Key >> 16) & 0xFFFF));
}
return Buf;
}
static void MpWireCount(uint64_t Key, uint64_t Bytes) {
g_wireBytesTotal.fetch_add(Bytes, std::memory_order_relaxed);
g_wirePacketsTotal.fetch_add(1, std::memory_order_relaxed);
if (Key == 0) {
g_wireBytesOther.fetch_add(Bytes, std::memory_order_relaxed);
return;
}
const unsigned Start = static_cast<unsigned>(((Key ^ (Key >> 23) ^ (Key >> 41)) * 0x9E3779B97F4A7C15ull) >> 58);
for (unsigned n = 0; n < 64; ++n) {
MpWireSlot& Slot = g_wireSlots[(Start + n) & 63];
uint64_t K = Slot.Key.load(std::memory_order_relaxed);
if (K == 0) {
uint64_t Expected = 0;
if (Slot.Key.compare_exchange_strong(Expected, Key, std::memory_order_relaxed)) K = Key;
else K = Expected;
}
if (K == Key) {
Slot.Bytes.fetch_add(Bytes, std::memory_order_relaxed);
Slot.Packets.fetch_add(1, std::memory_order_relaxed);
return;
}
}
g_wireBytesOther.fetch_add(Bytes, std::memory_order_relaxed);
}
static int __stdcall HookSendTo(uintptr_t S, const char* Buf, int Len, int Flags, const void* To, int ToLen) {
static const bool s_verbose = MpExeRelativeFlagPresent(L"SOCKET_DIAG.flag"); // [2026-10-11] read once
if (s_verbose) MpLogSockAddr("sendto ->", To, ToLen, Len);
const int R = OrigSendTo ? OrigSendTo(S, Buf, Len, Flags, To, ToLen) : -1;
if (R > 0 && g_wireCountOn) MpWireCount(MpWireKeyFromSockAddr(To, ToLen), static_cast<uint64_t>(R));
return R;
}
static int __stdcall HookRecvFrom(uintptr_t S, char* Buf, int Len, int Flags, void* From, int* FromLen) {
const int R = OrigRecvFrom ? OrigRecvFrom(S, Buf, Len, Flags, From, FromLen) : -1;
if (R > 0) MpLogSockAddr("recvfrom <-", From, FromLen ? *FromLen : 0, R);
return R;
}
// The first receive hook covered only recvfrom. UE's net driver uses the OVERLAPPED receive path, so an
// arrival would be invisible to that hook - which would look exactly like "nothing arrived" when packets are
// actually being delivered. These cover WSARecvFrom (which reports the source address) and WSARecv.
using MpWSARecvFromFn = int (__stdcall*)(uintptr_t, void*, unsigned long, unsigned long*, unsigned long*, void*, int*, void*, void*);
using MpWSARecvFn = int (__stdcall*)(uintptr_t, void*, unsigned long, unsigned long*, unsigned long*, void*, void*);
static MpWSARecvFromFn OrigWSARecvFrom = nullptr;
static MpWSARecvFn OrigWSARecv = nullptr;
static std::atomic<int> g_wsarrivals{ 0 };
static int __stdcall HookWSARecvFrom(uintptr_t S, void* Buffers, unsigned long BufferCount,
unsigned long* BytesRecvd, unsigned long* Flags, void* From, int* FromLen, void* Overlapped, void* Completion) {
const int R = OrigWSARecvFrom
? OrigWSARecvFrom(S, Buffers, BufferCount, BytesRecvd, Flags, From, FromLen, Overlapped, Completion)
: -1;
// With an OVERLAPPED operation the result is pending (SOCKET_ERROR) and the count is filled in later, so
// log the call itself as well - an arrival is then visible either way.
if (R == 0 && BytesRecvd && *BytesRecvd > 0) {
MpLogSockAddr("WSARecvFrom <-", From, FromLen ? *FromLen : 0, static_cast<int>(*BytesRecvd));
}
else if (Overlapped != nullptr) {
if (g_wsarrivals.fetch_add(1, std::memory_order_relaxed) < 12) {
MpLog(std::string("[SocketDiag] WSARecvFrom issued (overlapped, result pending) role=")
+ (Globals::AmServer ? "server" : "client"));
}
}
return R;
}
static int __stdcall HookWSARecv(uintptr_t S, void* Buffers, unsigned long BufferCount,
unsigned long* BytesRecvd, unsigned long* Flags, void* Overlapped, void* Completion) {
const int R = OrigWSARecv
? OrigWSARecv(S, Buffers, BufferCount, BytesRecvd, Flags, Overlapped, Completion)
: -1;
if (R == 0 && BytesRecvd && *BytesRecvd > 0) {
MpLog(std::string("[SocketDiag] WSARecv <- len=") + std::to_string(*BytesRecvd)
+ " role=" + (Globals::AmServer ? "server" : "client"));
}
return R;
}
static void InstallSocketDiagnostics() {
// [1.14.7 2026-10-04] GATED OFF by default. These hooks log from inside the recvfrom/sendto path, i.e. on
// the net tick's stack, and build std::strings through the CRT - whose frames are large (the fault below
// is a CRT routine writing [rbp+0x3F0]). The measurements they were added for are complete (the client
// sends 29-byte packets to 127.0.0.1:8790 and the hub receives them), and a diagnostic must not perturb
// the path it measures. SOCKET_DIAG.flag beside the executable re-enables them.
// [2026-10-11 1.14.7 O0] Exception: on servers the sendto hook is installed anyway, counting only (no logging, see
// MpWireCount), for the [PerfNet] bytes-per-peer line. NO_PERF_DETAIL.flag leaves sendto unhooked again.
const bool Verbose = MpExeRelativeFlagPresent(L"SOCKET_DIAG.flag");
const bool CountWire = Globals::AmServer && !MpExeRelativeFlagPresent(L"NO_PERF_DETAIL.flag");
if (!Verbose && !CountWire) {
return;
}
HMODULE Ws2 = GetModuleHandleW(L"ws2_32.dll");
if (!Ws2) {
MpLog("[SocketDiag] ws2_32.dll not loaded; socket diagnostics skipped");
return;
}
void* T1 = reinterpret_cast<void*>(GetProcAddress(Ws2, "sendto"));
if (T1) {
MH_STATUS C = MH_CreateHook(T1, HookSendTo, reinterpret_cast<LPVOID*>(&OrigSendTo));
MH_STATUS E = C == MH_OK ? MH_EnableHook(T1) : C;
if (CountWire && C == MH_OK && E == MH_OK) InterlockedExchange(&g_wireCountOn, 1);
MpLog(std::string("[SocketDiag] sendto hook create=") + MH_StatusToString(C) + " enable=" + MH_StatusToString(E)
+ " counting=" + (g_wireCountOn ? "1 ([PerfNet] bytes per peer)" : "0")
+ " verbose=" + (Verbose ? "1 (SOCKET_DIAG.flag)" : "0"));
}
if (!Verbose) {
return; // counting only: the receive hooks stay opt-in
}
void* T2 = reinterpret_cast<void*>(GetProcAddress(Ws2, "recvfrom"));
if (T2) {
MH_STATUS C = MH_CreateHook(T2, HookRecvFrom, reinterpret_cast<LPVOID*>(&OrigRecvFrom));
MH_STATUS E = C == MH_OK ? MH_EnableHook(T2) : C;
MpLog(std::string("[SocketDiag] recvfrom hook create=") + MH_StatusToString(C) + " enable=" + MH_StatusToString(E));
}
void* T3 = reinterpret_cast<void*>(GetProcAddress(Ws2, "WSARecvFrom"));
if (T3) {
MH_STATUS C = MH_CreateHook(T3, HookWSARecvFrom, reinterpret_cast<LPVOID*>(&OrigWSARecvFrom));
MH_STATUS E = C == MH_OK ? MH_EnableHook(T3) : C;
MpLog(std::string("[SocketDiag] WSARecvFrom hook create=") + MH_StatusToString(C) + " enable=" + MH_StatusToString(E));
}
void* T4 = reinterpret_cast<void*>(GetProcAddress(Ws2, "WSARecv"));
if (T4) {
MH_STATUS C = MH_CreateHook(T4, HookWSARecv, reinterpret_cast<LPVOID*>(&OrigWSARecv));
MH_STATUS E = C == MH_OK ? MH_EnableHook(T4) : C;
MpLog(std::string("[SocketDiag] WSARecv hook create=") + MH_StatusToString(C) + " enable=" + MH_StatusToString(E));
}
}
// ============================================================================
// [1.14.7 REMOVED 2026-10-04] The Ce8B6 guard hook was WRONG and has been deleted.
//
// I installed a detour at RVA 0x42CE8B6, having read those bytes from the live process and seen
// "sub rsp, 0x40 / mov rax, [rcx+0x10] / cmp qword ptr [rax+0x88], 0". But 0x42CE8B6 is NOT the function
// entry. The executable's unwind metadata puts the real start at RVA 0x42CE8B0:
//
// 0x42CE8B0 mov [rsp+0x18], rbx
// 0x42CE8B5 push rdi
// 0x42CE8B6 sub rsp, 0x40 <- the hook went HERE, after "push rdi" had already run
//
// A C++ detour expects the return address on top of the stack; here it finds the saved RDI. That single
// mistake produced BOTH symptoms I then misdiagnosed:
// * wrong stack alignment -> the CRT's aligned "movdqa [rbp+0x3F0], xmm0" faults (0xC0000005), and
// * wrong return address -> execution jumps to a heap-looking address (the logged RIP was RDI, which I
// wrongly read as evidence of a freed allocation).
// DbgHelp symbolization of the fault chain names it plainly: HookCe8B6 -> MpLog -> sprintf_s<260>.
//
// Lesson recorded: never install a detour from disassembled bytes alone. Confirm the function start from the
// unwind metadata (.pdata / RUNTIME_FUNCTION) or a symbol, not from the first plausible prologue bytes seen
// at an arbitrary offset.
//
// The guard is deleted rather than relocated: a replacement at this address needs verified function semantics
// and ABI first.
static void InstallEngineCommandLineHook() {
MH_STATUS Create = MH_CreateHook((void*)(Globals::BaseAddress + 0x025E8F70),
HookFCommandLineGet, reinterpret_cast<LPVOID*>(&OrigFCommandLineGet));
MH_STATUS Enable = Create == MH_OK ? MH_EnableHook((void*)(Globals::BaseAddress + 0x025E8F70)) : Create;
MpLog(std::string("[CommandLine] engine FCommandLine::Get hook (+0x025E8F70) create=")
+ MH_StatusToString(Create) + " enable=" + MH_StatusToString(Enable));
}
using FCommandLineSetFn = bool(*)(const wchar_t*);
static FCommandLineSetFn OrigFCommandLineSet = nullptr;
static bool HookFCommandLineSet(const wchar_t* CmdLine) {
if (CmdLine) {
std::wstring modified = CmdLine;
bool changed = false;
// [1.14.7 EAC flags] record what the ENGINE is actually given, as booleans only.
if (wcsstr(CmdLine, L"-NoEAC") != nullptr) g_engineCmdLineSawNoEAC = true;
if (wcsstr(CmdLine, L"-DisableEOS") != nullptr) g_engineCmdLineSawDisableEOS = true;
if (wcsstr(CmdLine, L"-DisableEOS") == nullptr) {
modified += L" -DisableEOS";
changed = true;
}
// [1.14.7 EAC FIX 2026-10-04] The measured flag booleans from a real Launcher-equivalent client run:
// [EACFlags] os_NoEAC=1 engine_FCommandLine_NoEAC=0 os_DisableEOS=1 engine_FCommandLine_DisableEOS=0
// The OS command line carries -NoEAC (the Launcher passes it) but the engine's own FCommandLine does
// NOT, so StartupModule's NoEAC check (RVA 0x01474ED0) fails, EAC initialises, the IGameClient
// creation fails (0x01477DD0) and HandleError (0x01476F20) reports it. Absorbing that dialog is not a
// fix - the client then just sits at the login screen with "Online Play may not be available".
// Adding the flag here is the root fix, exactly as -DisableEOS is already handled.
if (wcsstr(CmdLine, L"-NoEAC") == nullptr) {
modified += L" -NoEAC";
changed = true;
}
if (changed) {
MpLog("[CommandLine] Added -DisableEOS/-NoEAC to FCommandLine::Set (engine was missing them)");
if (OrigFCommandLineSet) {
return OrigFCommandLineSet(modified.c_str());
}
return true;
}
}
if (OrigFCommandLineSet) {
return OrigFCommandLineSet(CmdLine);
}
return true;
}
// [1.14.7 EOS/Login Hooks]
using IsEOSEnabledFn = char(*)();
static IsEOSEnabledFn OrigIsEOSEnabled = nullptr;
// [1.14.7 login fix 2026-10-03] Ghidra decompiles +0x01C30030 as
// undefined1 FUN_141c30030(void) {
// if (<TLS guard at +0x06D3417C>) {
// c = FParse::Param(FCommandLine::Get(), L"DisableEOS"); // FUN_142634de0
// DAT_146d34178 = (c == 0); // cached result
// }
// return DAT_146d34178;
// }
// i.e. IsEOSEnabled() == !<command line contains -DisableEOS>, cached on first call.
// When it returns 1 the client authenticates through the EOS SDK, whose own HTTP stack
// bypasses FCurlHttpRequest::ProcessRequest -> the request goes to the real
// api.epicgames.dev instead of paradox.mysticfox.dev and fails with
// EOS_Auth_ExchangeCodeNotFound, so the login never reaches this backend.
// Forcing 0 selects MCP mode, where the same login is issued through UE's HTTP stack and
// IS rewritten to this backend (proven: the 0.1.87 build logs
// "[IsEOSEnabled] intercepted -> forcing 0 (MCP mode)" and completes
// POST /__origin/account-public-service-prod03.ol.epicgames.com/account/api/oauth/token).
// This is the private-server requirement, not a diagnostic suppression: no failure is hidden.
static char HookIsEOSEnabled() {
static bool s_logged = false;
if (!s_logged) {
s_logged = true;
MpLog("[IsEOSEnabled] intercepted -> forcing 0 (MCP mode)");
}
return 0;
}
static std::string SafeFTextToString(const void* TextPtr) {
if (!TextPtr || !IsReadablePointer(TextPtr, 8)) return "<null>";
void* TextData = *reinterpret_cast<void**>(const_cast<void*>(TextPtr));
if (!TextData || !IsReadablePointer(TextData, 0x38)) return "<null-data>";
wchar_t* Data = *reinterpret_cast<wchar_t**>(reinterpret_cast<uintptr_t>(TextData) + 0x28);
int32_t Num = *reinterpret_cast<int32_t*>(reinterpret_cast<uintptr_t>(TextData) + 0x30);
if (!Data || Num <= 0 || !IsReadablePointer(Data, 2)) return "<empty>";
return MpNarrow(std::wstring(Data, Num > 512 ? 512 : Num));
}
using LoginOperationFailedFn = void(*)(void*, void*, void*, char);
static LoginOperationFailedFn OrigLoginOperationFailed = nullptr;
static void HookLoginOperationFailed(void* thisPtr, void* ErrorStringPtr, void* ErrorTextPtr, char bTrigger) {
std::string className = "unknown";
if (thisPtr && IsReadablePointer(thisPtr, 0x20)) {
UObject* obj = reinterpret_cast<UObject*>(thisPtr);
if (obj->Class && IsReadablePointer(obj->Class, 0x20)) {
className = obj->Class->GetName();
}
}
MpLog("[LoginOpFailed] class=" + className + " bTrigger=" + std::to_string((int)bTrigger)
+ " errorStringPresent=" + std::to_string(ErrorStringPtr != nullptr)
+ " errorTextPresent=" + std::to_string(ErrorTextPtr != nullptr));
// Preserve the game's failure cleanup and delegate notifications. This hook is diagnostic only.
if (OrigLoginOperationFailed) {
OrigLoginOperationFailed(thisPtr, ErrorStringPtr, ErrorTextPtr, bTrigger);
}
}
using LoginOperationSuccessFn = void(*)(void*);
static LoginOperationSuccessFn OrigLoginOperationSuccess = nullptr;
static void HookLoginOperationSuccess(void* thisPtr) {
std::string className = "unknown";
if (thisPtr && IsReadablePointer(thisPtr, 0x20)) {
UObject* obj = reinterpret_cast<UObject*>(thisPtr);
if (obj->Class && IsReadablePointer(obj->Class, 0x20)) {
className = obj->Class->GetName();
}
}
MpLog("[LoginOpSuccess] this=" + MpPtr(thisPtr) + " class=" + className + " -> login operation SUCCEEDED!");
if (OrigLoginOperationSuccess) {
OrigLoginOperationSuccess(thisPtr);
}
}
using HasDisconnectErrorFn = void(*)(void*, void*, void*);
static HasDisconnectErrorFn OrigHasDisconnectError = nullptr;
static void HookHasDisconnectError(void* Context, void* Stack, void* Result) {
if (OrigHasDisconnectError) {
OrigHasDisconnectError(Context, Stack, Result);
}
if (Result) MpLog(std::string("[HasDisconnectError] observed result=")
+ ((*reinterpret_cast<uint8_t*>(Result) != 0) ? "true" : "false"));
}
using EnqueueDisconnectErrorFn = void(*)(void*, void*);
static EnqueueDisconnectErrorFn OrigEnqueueDisconnectError = nullptr;
// [1.14.7 2026-10-04] This hook used to log only errorTextPresent, which lost the reason and the caller and
// made the client's disconnect unactionable. It now reads the validated FText safely (SafeFTextToString
// already bounds the read), records a bounded REDACTED reason plus the caller RVA, and forwards the pointer
// unchanged - the original keeps ownership, nothing is copied or destroyed here.
static std::string RedactForLog(const std::string& Text, size_t MaxLen) {
std::string Out;
Out.reserve(Text.size() < MaxLen ? Text.size() : MaxLen);
size_t Run = 0;
for (size_t i = 0; i < Text.size() && Out.size() < MaxLen; ++i) {
const unsigned char C = static_cast<unsigned char>(Text[i]);
const bool Tokenish = (C >= '0' && C <= '9') || (C >= 'a' && C <= 'z') || (C >= 'A' && C <= 'Z') || C == '-' || C == '_';
if (Tokenish) {
++Run;
if (Run > 24) { // long unbroken token-like run: mask it
if (Out.size() < 12 || Out.compare(Out.size() - 12, 12, "<redacted>") != 0) Out += "<redacted>";
while (i + 1 < Text.size() && Out.size() < MaxLen) {
const unsigned char N = static_cast<unsigned char>(Text[i + 1]);
const bool NextTokenish = (N >= '0' && N <= '9') || (N >= 'a' && N <= 'z') || (N >= 'A' && N <= 'Z') || N == '-' || N == '_';
if (!NextTokenish) break;
++i;
}
Run = 0;
continue;
}
} else {
Run = 0;
}
Out += static_cast<char>(C);
}
if (Text.size() > Out.size()) Out += "...";
return Out;
}
static void HookEnqueueDisconnectError(void* Context, void* ErrorTextPtr) {
const std::string Reason = RedactForLog(SafeFTextToString(ErrorTextPtr), 160);
void* Caller = _ReturnAddress();
const uintptr_t CallerRva = (Globals::BaseAddress && reinterpret_cast<uintptr_t>(Caller) >= Globals::BaseAddress)
? reinterpret_cast<uintptr_t>(Caller) - Globals::BaseAddress
: 0;
MpLog(std::string("[EnqueueDisconnectError] reason='") + Reason + "' callerRva=+0x"
+ MpHex(CallerRva) + " this=" + MpPtr(Context)
+ " (forwarded unchanged; original keeps ownership)");
if (OrigEnqueueDisconnectError) {
OrigEnqueueDisconnectError(Context, ErrorTextPtr);
}
}
using EasyAntiCheatHandleErrorFn = uint64_t(*)(void*, void*);
static EasyAntiCheatHandleErrorFn OrigEasyAntiCheatHandleError = nullptr;
// [1.14.7 2026-10-04] The previous version of this hook suppressed EVERY EAC error unconditionally.
// It is replaced by the scoped version defined with the EAC block further down, which absorbs only the
// expected private-client initialisation failure and forwards everything else to the original.
// The typedef and OrigEasyAntiCheatHandleError above are kept for that hook.
// ============================================================================
// [1.14.7 EAC DIAGNOSIS + SCOPED ERROR HANDLING 2026-10-04]
//
// Verified in the 1.14.7 binary:
// StartupModule 0x01474ED0 checks NoEAC via FUN_142634de0(cmdline, L"NoEAC") and also has the path
// "EnableEasyAntiCheatFeatures is false, EAC client will not load"
// client creation 0x01477DD0 produces the IGameClient creation error
// FEasyAntiCheatClient::HandleError 0x01476F20
// undefined8 FUN_141476f20(this, FString* message)
// - if a game callback is bound (this+8, vtable +0x30 != 0) it DISPATCHES there: vtable +0x48
// - otherwise it falls back to the native dialog: "EasyAntiCheat Error: %s"
// So the return is 64-bit (undefined8), and the caller reads AL. The old uint64_t declaration was
// therefore correct; EAC suppression stopped because commit e320e022 REMOVED the hooks, not because of
// the type. This restores it narrowly.
//
// Scope: only the expected private-client initialisation failure is absorbed. Every other EAC error is
// forwarded to the original unchanged, so ordinary error reporting and Central Guard checks still run.
static std::atomic<int> g_eacSuppressedCount{ 0 };
static std::atomic<int> g_eacForwardedCount{ 0 };
// The flag booleans are declared with the command-line hooks above; this records whether we logged them.
static bool g_eacFlagsLogged = false;
static std::wstring ReadFStringSafe(const void* StringPtr, size_t MaxChars) {
if (!StringPtr || !IsReadablePointer(const_cast<void*>(StringPtr), 0x10)) return std::wstring();
const wchar_t* Data = *reinterpret_cast<const wchar_t* const*>(StringPtr);
const int32_t Num = *reinterpret_cast<const int32_t*>(reinterpret_cast<uintptr_t>(StringPtr) + 0x08);
if (!Data || Num <= 0) return std::wstring();
const size_t Count = static_cast<size_t>(Num) > MaxChars ? MaxChars : static_cast<size_t>(Num);
if (!IsReadablePointer(const_cast<wchar_t*>(Data), Count * sizeof(wchar_t))) return std::wstring();
return std::wstring(Data, Count > 0 ? Count - 1 : 0);
}
static uint64_t HookEasyAntiCheatHandleError(void* thisPtr, void* ErrorMessagePtr) {
const std::wstring Message = ReadFStringSafe(ErrorMessagePtr, 512);
// The expected private-client initialisation failure: the game could not create its IGameClient.
// Nothing else is absorbed.
const bool IsExpectedClientInitFailure =
Message.find(L"IGameClient") != std::wstring::npos ||
Message.find(L"Failed to create") != std::wstring::npos;
if (IsExpectedClientInitFailure) {
const int Seq = g_eacSuppressedCount.fetch_add(1, std::memory_order_relaxed);
if (Seq < 4) {
MpLog("[EACPopup] absorbed expected client-init failure (len=" + std::to_string(Message.size())
+ " chars, contains IGameClient=" + std::to_string(Message.find(L"IGameClient") != std::wstring::npos ? 1 : 0)
+ ") this=" + MpPtr(thisPtr));
}
return 1; // AL=1: handled, no dialog
}
const int Seq = g_eacForwardedCount.fetch_add(1, std::memory_order_relaxed);
if (Seq < 4) {
MpLog("[EACPopup] forwarding a non-client-init EAC error to the original (len="
+ std::to_string(Message.size()) + " chars) this=" + MpPtr(thisPtr));
}
return OrigEasyAntiCheatHandleError
? OrigEasyAntiCheatHandleError(thisPtr, ErrorMessagePtr)
: 0;
}
static void LogEacStartupFlags(const char* Where) {
if (g_eacFlagsLogged) return;
g_eacFlagsLogged = true;
const wchar_t* OsCmd = GetCommandLineW();
const std::wstring OsLine = OsCmd ? std::wstring(OsCmd) : std::wstring();
const bool OsNoEAC = OsLine.find(L"-NoEAC") != std::wstring::npos;
MpLog(std::string("[EACFlags] ") + Where
+ " os_NoEAC=" + std::to_string(OsNoEAC ? 1 : 0)
+ " engine_FCommandLine_NoEAC=" + std::to_string(g_engineCmdLineSawNoEAC ? 1 : 0)
+ " os_DisableEOS=" + std::to_string(OsLine.find(L"-DisableEOS") != std::wstring::npos ? 1 : 0)
+ " engine_FCommandLine_DisableEOS=" + std::to_string(g_engineCmdLineSawDisableEOS ? 1 : 0)
+ " (booleans only; no raw arguments recorded)");
}
using FMessageDialogOpenFn = int(*)(int, void*, void*);
static FMessageDialogOpenFn OrigFMessageDialogOpen = nullptr;
static int HookFMessageDialogOpen(int MsgType, void* MessageText, void* TitleText) {
MpLog("[FMessageDialog] Open intercepted (MsgType=" + std::to_string(MsgType) + ") -> suppressed native OS message dialog");
return (MsgType == 0) ? 5 : 1;
}
using ArchonEasyAntiCheatErrorProcFn = bool(*)(void*, void*);
static ArchonEasyAntiCheatErrorProcFn OrigArchonEasyAntiCheatErrorProc = nullptr;
static bool HookArchonEasyAntiCheatErrorProc(void* thisPtr, void* Message) {
std::wstring msg = L"<null>";
if (Message && IsReadablePointer(Message, 16)) {
wchar_t* data = *reinterpret_cast<wchar_t**>(Message);
int32_t num = *reinterpret_cast<int32_t*>(reinterpret_cast<uintptr_t>(Message) + 8);
if (data && num > 0 && IsReadablePointer(data, 2)) {
msg = std::wstring(data, num > 256 ? 256 : num);
}
}
MpLog("[EACPopup] HookArchonEasyAntiCheatErrorProc suppressed modal (Message='" + MpNarrow(msg) + "')");
return false;
}
// Resolve a virtual through a LIVE instance's vtable and MinHook it.
// Immune to .text RVA drift; only the vtable INDEX must be correct.
static bool HookVirtual(void* instance, size_t index, void* detour, void** ppOrig, const char* tag) {
if (!instance) return false;
void** vtable = *reinterpret_cast<void***>(instance);
void* target = vtable[index];
if (MH_CreateHook(target, detour, ppOrig) != MH_OK) return false;
if (MH_EnableHook(target) != MH_OK) return false;
if (Globals::EnableLogging)
std::cout << "[vhook] " << tag << " idx=" << index << " -> +0x" << std::hex
<< ((uintptr_t)target - Globals::BaseAddress) << std::dec << std::endl;
return true;
}
// One-time runtime INDEX discovery. Log vtable[0..count) as module-relative RVAs,
// then identify each target (see "identifying" notes) and fill the IDX_ constants.
static void DumpVtable(void* instance, size_t count, const char* tag) {
if (!instance) return;
void** vt = *reinterpret_cast<void***>(instance);
std::cout << "[vtable] " << tag << " @ +0x" << std::hex
<< ((uintptr_t)vt - Globals::BaseAddress) << std::dec << std::endl;
for (size_t i = 0; i < count; ++i)
std::cout << " [" << i << "] +0x" << std::hex
<< ((uintptr_t)vt[i] - Globals::BaseAddress) << std::dec << std::endl;
}
// 1.12.0 (UE 4.26.2). These are kept for optional diagnostics only. The critical
// virtuals below are hooked by runtime-confirmed concrete RVAs for this frozen build.
constexpr size_t IDX_UGAMEENGINE_TICK = 0;
constexpr size_t IDX_INTERNAL_GET_NETMODE = 0;
constexpr size_t IDX_UNETCONNECTION_ISNETREADY = 0;
constexpr size_t IDX_UNETDRIVER_ISLEVELINIT = 0;
constexpr size_t IDX_AACTOR_GETFUNCTIONCALLSPACE = 69; // Confirmed from HOOK_TARGETS_1.12.0.md
constexpr size_t IDX_AGAMEMODEBASE_CHOOSEPLAYERSTART = 207; // Confirmed from HOOK_TARGETS_1.12.0.md
__declspec(dllexport) const char* DummyLinkFunc() {
return "mrow :3";
}
// Shared with tools/CatalogExporter/StoreRuntimeExporter.cpp. The runtime project compiles that same
// read-only source so an opted-in client capture does not depend on manually injecting a second DLL.
extern int RunStoreRuntimeExportWhenReady(DWORD timeoutMs);
static DWORD WINAPI RewardCacheCaptureThread(LPVOID) {
MpLog("[RewardCacheCapture] waiting up to 15 minutes for the season_store view model");
const int result = RunStoreRuntimeExportWhenReady(15u * 60u * 1000u);
MpLog("[RewardCacheCapture] worker finished result=" + std::to_string(result));
return 0;
}
void MainThread() {
InitLog("[MainThread] Entry");
// Build log string piece by piece to isolate crash point
std::string logMsg = "\n=== ParadoxRuntime session start ===\n[MainThread] started. AmServer=";
logMsg += (Globals::AmServer ? "1" : "0");
InitLog("[MainThread] Built AmServer part");
logMsg += " port=";
logMsg += std::to_string(Globals::Port);
InitLog("[MainThread] Built port part");
logMsg += " map=";
if (Globals::MapPath) {
logMsg += MpNarrow(std::wstring(Globals::MapPath));
} else {
logMsg += "(null)";
}
logMsg += " base=";
logMsg += MpHex(Globals::BaseAddress);
// Never persist the process command line. Client launches contain the one-time
// exchange code plus account/display identifiers, while server launches contain
// the deploy key and hunt routing details. None of those values are needed in
// the session banner.
InitLog("[MainThread] Built map part, calling MpLog...");
MpLog(logMsg);
MpLog(std::string("[FlagCache] flag-file cache ") + MpFlagCacheModeText());
MpLogBackendAddress(); // [2026-10-13 realms S1] source=env|built-in, resolved in Init
InitLog("[MainThread] MpLog returned, entering UWorld wait loop");
if (Globals::AmServer && Globals::AuthMode == Globals::ServerAuthMode::Invalid) {
MpLog("[ServerAuth] unknown MYSTICPARADOX_AUTH_MODE; terminating before listen");
TerminateProcess(GetCurrentProcess(), 0xE102);
return;
}
// The optional player-hosted mode starts its own workers before listen (P2PHooks.h).
if (Globals::AmServer && Globals::AuthMode == Globals::ServerAuthMode::HuntCapability
&& !P2PHooks::StartServer()) {
return;
}
// Opt-in Reward Cache contract capture. This runs only in the normal client role and only when
// REWARD_CACHE_CAPTURE.flag exists next to Dauntless-Win64-Shipping.exe. The worker waits for the
// exact live season_store view model before writing, so launching with the flag cannot overwrite
// the previous capture with empty startup output. Dedicated servers never create this thread.
if (!Globals::AmServer && MpExeRelativeFlagPresent(L"REWARD_CACHE_CAPTURE.flag")) {
HANDLE captureThread = CreateThread(nullptr, 0, RewardCacheCaptureThread, nullptr, 0, nullptr);
if (captureThread) {
CloseHandle(captureThread);
MpLog("[RewardCacheCapture] worker started (REWARD_CACHE_CAPTURE.flag present)");
}
else {
MpLog("[RewardCacheCapture] CreateThread failed error=" + std::to_string(GetLastError()));
}
}
// Emit boot-patch status here (InitServerHooks recorded it under loader lock where InitLog is a no-op).
if (Globals::AmServer && !Globals::Move10Status.empty()) {
MpLog(std::string("[BootPatch] status:") + Globals::Move10Status);
}
int waitCount = 0;
while (true) {
InitLog(std::string("[MainThread] Loop iteration ") + std::to_string(waitCount) + ", calling UWorld::GetWorld()...");
UWorld* world = UWorld::GetWorld();
UWorld* directGWorld = nullptr;
if (Globals::BaseAddress) {
directGWorld = *reinterpret_cast<UWorld**>(Globals::BaseAddress + 0x06FC7F58);
}
if (Globals::AmServer) {
MpLog("[MainThread] wait=" + std::to_string(waitCount)
+ " SDKWorld=" + MpPtr(world)
+ " DirectGWorld=" + MpPtr(directGWorld));
}
InitLog(std::string("[MainThread] UWorld::GetWorld() returned: ") + (world ? "non-null" : "null"));
if (world || directGWorld) break;
if (Globals::AmServer) {
InitLog("[MainThread] About to Sleep(1000)...");
Sleep(1000);
InitLog("[MainThread] Sleep complete, incrementing waitCount");
waitCount++;
InitLog(std::string("[MainThread] waitCount now: ") + std::to_string(waitCount));
if (waitCount >= 30) {
InitLog("[MainThread] Waited 30s for UWorld, giving up");
break;
}
}
else {
Sleep(1);
}
}
InitLog("[MainThread] Exited UWorld wait loop");
if (Globals::AmServer) {
MpLog("[MainThread] UWorld is live; requesting listen immediately");
Globals::DoListen = true;
return;
}
Sleep(3 * 1000);
UEngine* Engine = UEngine::GetEngine();
UInputSettings::GetDefaultObj()->ConsoleKeys[0].KeyName = UKismetStringLibrary::Conv_StringToName(L"F2");
UObject* NewObject = UGameplayStatics::SpawnObject(Engine->ConsoleClass, Engine->GameViewport);
Engine->GameViewport->ViewportConsole = static_cast<UConsole*>(NewObject);
if (Globals::EnableLogging)
std::cout << "Spawned UConsole!" << std::endl;
}
// [1.12.0] Engine allocator bridge.
// The modern Dumper-7 SDK's FString has NO allocating operator=: FString(const wchar_t*) only
// ALIASES the pointer (Data = const_cast<wchar_t*>(Str)) and TArray::operator= is a defaulted
// shallow copy. So `*Ret = someStdWString.c_str()` left the engine's map FString pointing at a
// std::wstring buffer that is freed (CRT) when the local goes out of scope. When the engine then
// grew/append'd that URL it called FMemory::Realloc on the dangling, non-engine pointer and
// FMallocBinned2 aborted with "Attempt to realloc an unrecognized block ... canary == 0x0 != 0xe3"
// (Fatal), so the dedicated server exited during boot -- upstream of Networking::Listen.
// (The 1.4.4 build worked because its older SDK wired FString allocation to the engine via
// UC::FMemory::Init; the modern SDK has no such namespace, so we bridge to the engine allocator
// manually here.)
//
// We call FMemory::Realloc, the free-function wrapper (Ghidra FUN_1423bb590, RVA 0x023BB590):
// void* FMemory::Realloc(void* Original, size_t Count, uint32 Alignment)
// It lazily initializes GMalloc and dispatches through GMalloc's vtable (Realloc == vtable+0x20),
// so it is correct even though GMalloc is a PROXY/wrapper rather than a raw FMallocBinned2.
// (Calling FMallocBinned2::Realloc 0x003930D0 directly with GMalloc as `this` crashed with
// EXCEPTION_ACCESS_VIOLATION writing 0x2b, proving GMalloc's dynamic type is not FMallocBinned2.)
// Verified in Ghidra: FMemory::Malloc=GMalloc->vt[+0x10] (FUN_1423b8de0), FMemory::Realloc=vt[+0x20]
// (FUN_1423bb590), FMemory::Free=vt[+0x30] (FUN_1423aaed0); GMalloc=DAT_146b532d0 (RVA 0x06B532D0).
// FMemory::Realloc(nullptr, size, 0) degrades to a plain malloc, so a null Data is safe.
static void* EngineRealloc(void* Ptr, size_t NewSize) {
InitLog(std::string("[EngineRealloc] Entry: Ptr=") + (Ptr ? "0x" + std::to_string((uintptr_t)Ptr) : "NULL") + " NewSize=" + std::to_string(NewSize));
uintptr_t Base = Globals::BaseAddress ? Globals::BaseAddress : reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
using ReallocFn = void* (*)(void*, size_t, uint32_t);
void* Result = reinterpret_cast<ReallocFn>(Base + 0x02564AB0)(Ptr, NewSize, 0);
InitLog(std::string("[EngineRealloc] FMemory::Realloc returned: ") + (Result ? "0x" + std::to_string((uintptr_t)Result) : "NULL"));
return Result;
}
// [2026-07-16] Late-join lifecycle snapshot (read-only, no behaviour change). Walks GObjects for
// the live AArchonGameMode + AArchonGameState and logs match/session/capacity state so we can see
// exactly how it differs at each connection edge (P1 join / P2 join / P1 leave) and pinpoint the
// native event that only enables P2 after P1 leaves. Offsets from SDK: GameMode GameSession +0x278,
// MatchState +0x2C0, NumSpectators +0x2CC, NumPlayers +0x2D0, NumTravellingPlayers +0x2DC,
// ExpectedPlayerCount +0x498, MaxPlayers +0x49C; GameState MatchState +0x270; GameSession MaxPlayers +0x224.
//
// [2026-10-11 1.14.7 O2 join/leave hitch] The walk below visited GUObjectArray (~208k objects on Ramsgate, an IsA per
// object) at EVERY connection-count change, match-state change and PostLogin-named ProcessEvent - several walks per
// join or leave, the 277 ms frames seen on joins. The two objects now come from the world itself:
// GWorld->AuthorityGameMode (+0x118) and GWorld->GameState (+0x120), the same reads the ExpectedPlayerCount sync and
// the AFK timeout already use (Offsets::GWorld 0x06FC7F58), each checked as a live non-CDO object of the Archon class.
// The walk's "first instance found" could even be a leftover from an earlier world; the world's pointers are the live
// ones. Same line format. LEGACY_LIFECYCLE_WALK.flag (read once) restores the walks here, in the match-state tracker,
// the progression-config resume and the arrival-trigger probe.
static bool MpLegacyLifecycleWalk() {
static int s_mode = -1;
if (s_mode < 0) {
s_mode = MpExeRelativeFlagPresent(L"LEGACY_LIFECYCLE_WALK.flag") ? 1 : 0;
MpLog(std::string("[Lifecycle] GameMode/GameState lookup = ")
+ (s_mode ? "full GObjects walk (LEGACY_LIFECYCLE_WALK.flag present)"
: "world pointers GWorld+0x118 / +0x120 (default)"));
}
return s_mode == 1;
}
// Live non-CDO object of Cls, or nullptr. Game thread.
static SDK::UObject* MpLiveObjectOfClass(SDK::UObject* Obj, SDK::UClass* Cls) {
if (!Obj || !Cls || !IsRegisteredLiveObject(Obj) || Obj->IsDefaultObject() || !Obj->Class
|| !IsRegisteredLiveObject(Obj->Class) || !Obj->IsA(Cls)) {
return nullptr;
}
return Obj;
}
// The world's AArchonGameMode (+0x118) and AArchonGameState (+0x120); either may come back null. Game thread.
static void MpWorldGameModeAndState(SDK::UObject*& OutGameMode, SDK::UObject*& OutGameState) {
OutGameMode = nullptr;
OutGameState = nullptr;
SDK::UWorld* World = SDK::UWorld::GetWorld();
if (!World || !IsReadablePointer(World, 0x128)) return;
const uintptr_t W = reinterpret_cast<uintptr_t>(World);
OutGameMode = MpLiveObjectOfClass(*reinterpret_cast<SDK::UObject**>(W + 0x118), SDK::AArchonGameMode::StaticClass());
OutGameState = MpLiveObjectOfClass(*reinterpret_cast<SDK::UObject**>(W + 0x120), SDK::AArchonGameState::StaticClass());
}
static void LogArchonLifecycle(const char* Tag) {
if (!SDK::UObject::GObjects) { return; }
SDK::UObject* GmObj = nullptr;
SDK::UObject* GsObj = nullptr;
if (!MpLegacyLifecycleWalk()) {
MpWorldGameModeAndState(GmObj, GsObj);
} else {
const int Count = SDK::UObject::GObjects->Num();
for (int i = 0; i < Count; i++) {
SDK::UObject* Obj = SDK::UObject::GObjects->GetByIndex(i);
if (!Obj || Obj->IsDefaultObject()) { continue; }
if (!GmObj && Obj->IsA(SDK::AArchonGameMode::StaticClass())) { GmObj = Obj; }
else if (!GsObj && Obj->IsA(SDK::AArchonGameState::StaticClass())) { GsObj = Obj; }
if (GmObj && GsObj) { break; }
}
}
std::string Msg = std::string("[Lifecycle:") + Tag + "]";
if (GmObj && IsReadablePointer(GmObj, 0x4A0)) {
uintptr_t Gm = reinterpret_cast<uintptr_t>(GmObj);
std::string MatchState = "?";
if (IsReadablePointer(reinterpret_cast<void*>(Gm + 0x02C0), 8)) {
MatchState = reinterpret_cast<SDK::FName*>(Gm + 0x02C0)->ToString();
}
void* GameSession = *reinterpret_cast<void**>(Gm + 0x0278);
int32_t GsMax = -1;
if (IsReadablePointer(GameSession, 0x228)) {
GsMax = *reinterpret_cast<int32_t*>(reinterpret_cast<uintptr_t>(GameSession) + 0x0224);
}
Msg += " GM=" + MpPtr(GmObj) + " class=" + GmObj->Class->GetName()
+ " match=" + MatchState
+ " NumPlayers=" + std::to_string(*reinterpret_cast<int32_t*>(Gm + 0x02D0))
+ " NumSpectators=" + std::to_string(*reinterpret_cast<int32_t*>(Gm + 0x02CC))
+ " NumTravelling=" + std::to_string(*reinterpret_cast<int32_t*>(Gm + 0x02DC))
+ " ExpectedPlayerCount=" + std::to_string(*reinterpret_cast<int32_t*>(Gm + 0x0498))
+ " MaxPlayers=" + std::to_string(*reinterpret_cast<int32_t*>(Gm + 0x049C))
+ " GameSession=" + MpPtr(GameSession) + " GS.MaxPlayers=" + std::to_string(GsMax);
} else {
Msg += " GM=null";
}
if (GsObj && IsReadablePointer(GsObj, 0x278)) {
uintptr_t Gs = reinterpret_cast<uintptr_t>(GsObj);
std::string GsMatch = "?";
if (IsReadablePointer(reinterpret_cast<void*>(Gs + 0x0270), 8)) {
GsMatch = reinterpret_cast<SDK::FName*>(Gs + 0x0270)->ToString();
}
Msg += " | GState=" + MpPtr(GsObj) + " match=" + GsMatch;
} else {
Msg += " | GState=null";
}
MpLog(Msg);
}
// [2026-07-17 empty-server watchdog robustness] Authoritative "is this instance empty?" using
// AGameMode::NumPlayers (+0x2D0) and NumTravellingPlayers (+0x2DC) instead of scanning UNetConnections.
// The connection scan's HasConnection stays stuck TRUE when a player's connection lingers/zombies after
// they travel out, so the 50s empty-exit never fired and empty hunts leaked RAM (reported). NumPlayers is
// decremented on Logout; NumTravellingPlayers keeps a server whose players are still LOADING IN (big maps)
// from being counted as empty. Cached + throttled so we never walk GObjects every tick.
// Returns: 1 = empty (no players, none incoming), 0 = occupied/incoming, -1 = GameMode not found yet.
static SDK::UObject* g_CachedGameMode = nullptr;
static uint64_t g_LastGameModeSearchMs = 0;
// [2026-07-18 crash fix] Published by the GAME THREAD only (from GameModeEmptyState, called in the tick).
// The background empty-watchdog thread must NEVER walk GObjects or call IsA/GetName itself — doing that
// off the game thread races the engine spawning/destroying actors and crashed with an access violation
// inside the game's AppendString/IsA (all-Dauntless callstack, reading 0x248). The AArchonGameMode object
// persists for the whole process, so once published its pointer stays valid and NumPlayers/NumTravelling
// are safe plain int reads from any thread.
static std::atomic<void*> g_wdGameModePtr{ nullptr };
// [1.14.7 2026-10-10] Set when this server booted without the phantom local player (every server role since 0.2.110;
// false only if the byte guard in InitServerHooks failed); the first login then starts the match (MpStartMatchWithoutPhantom).
static std::atomic<bool> g_NoLocalPlayerApplied{ false };
static std::atomic<uint64_t> g_MpLastPostLoginMs{ 0 }; // [2026-10-10 arrival-trigger probe] last server K2_PostLogin
// [2026-10-10 GrantTrace] The reflected (Blueprint/UFunction) call chain on this thread, written by the server
// ProcessEventHook's depth guard and read by the inventory-transaction hook to name the Blueprint behind a grant.
static thread_local SDK::UFunction* g_MpPeChain[64] = {};
static thread_local int g_MpPeChainDepth = 0;
// [1.14.7 WATCHDOG HEALTH FIX 2026-10-04] The watchdog thread used to dereference g_wdGameModePtr to read the
// player counts. That pointer is only refreshed while the game thread keeps calling GameModeEmptyState(), so
// after a map change (the Training AFK LeavingMap) it can outlive the GameMode and the thread then read
// garbage - observed as "numPlayers=344066816", an impossible count that cannot establish world health and
// that the reap decision was being made from. The counts are now read on the GAME thread and published as
// plain values, with a timestamp, so the watchdog does no UObject dereference at all (which is what its own
// comment always intended). A stale or implausible sample is treated as UNKNOWN and never reaps.
// [1.14.7 AVAILABILITY TEST 2026-10-04] The definitive "am I still serving?" check: try to bind our own UDP
// port. While the hub holds it the bind fails; after the AFK idle-out the socket is gone and the bind
// succeeds. Measured on the Training hub: at t+150s the match state was LeavingMap and UDP 8789 was unbound,
// yet the published counts stayed fresh (countsUsable=1, numPlayers=1) because the GameMode survives the map
// exit - so a counts-based trigger never fires. The port is the honest signal.
// [2026-10-11 1.14.7 P2P host] Probe the address the server is actually bound to. Windows lets a wildcard bind
// (0.0.0.0:port) succeed while another socket holds a specific address on the same port (127.0.0.1:port) unless that
// socket used SO_EXCLUSIVEADDRUSE, so once the player host binds loopback only (MULTIHOME, see
// MpCarryMultihomeIntoEngineLine) an INADDR_ANY probe would report the port free while the host is serving, and the
// 45 s rule in the watchdog thread would reap it about 45 s after its first GameMode sample. The engine binds the MULTIHOME address when its command line has one, else
// every interface: the probe now binds the same address (a same-address bind fails while the server holds it, the
// measured hub behaviour). This keeps the alive-but-unavailable exit for the host and for any server given a
// MULTIHOME, which skipping the check for the player host would have lost. An address this IPv4 probe cannot
// express (IPv6) means "cannot test": never act.
static bool MpOurUdpPortIsFree() {
const int Port = static_cast<int>(Globals::Port);
if (Port <= 0 || Port > 65535) return false; // unknown port: never act
uint32_t BindHostOrder = INADDR_ANY;
if (InterlockedCompareExchange(&g_MpEngineMultihomeState, 0, 0) != 0 && g_MpEngineMultihomeA[0] != '\0'
&& !MpParseIpv4Literal(g_MpEngineMultihomeA, BindHostOrder)) {
static std::atomic<bool> s_loggedUntestable{ false };
if (!s_loggedUntestable.exchange(true, std::memory_order_relaxed)) {
MpLog(std::string("[Watchdog/thread] port probe: MULTIHOME=") + g_MpEngineMultihomeA
+ " is not IPv4; the port-free check is off for this server (never acts)");
}
return false;
}
SOCKET Probe = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (Probe == INVALID_SOCKET) return false; // cannot test: never act
sockaddr_in Addr{};
Addr.sin_family = AF_INET;
Addr.sin_port = htons(static_cast<u_short>(Port));
Addr.sin_addr.s_addr = htonl(BindHostOrder);
const bool Bound = (bind(Probe, reinterpret_cast<sockaddr*>(&Addr), sizeof(Addr)) == 0);
closesocket(Probe);
return Bound;
}
static std::atomic<bool> g_wdEverHadCounts{ false }; // has this hub ever been in a map?
static std::atomic<int32_t> g_wdNumPlayers{ -1 };
static std::atomic<int32_t> g_wdNumTravelling{ -1 };
static std::atomic<uint64_t> g_wdCountsStampMs{ 0 };
static constexpr int32_t MP_WD_MAX_PLAUSIBLE_PLAYERS = 64;
static constexpr uint64_t MP_WD_COUNTS_MAX_AGE_MS = 10000;
static std::atomic<int> g_wdIsHub{ -1 }; // -1 unknown, 0 disposable (reapable), 1 persistent hub
/*
* [1.14.7 EMPTY-HUNT FIX 2026-10-09] Remote client connections, published by the game thread from
* NetDriver->ClientConnections.Num (the [NetConnEdge] count).
*
* Until runtime 0.2.110 a 1.14.7 server owned one local (phantom) player (38_ §12), so GameMode NumPlayers never dropped
* below 1 and an emptied hunt never looked empty: every hunt stayed alive and the third one filled
* DeployServer's cap ("Hunt server capacity reached (3/3)", 38_ §18.4). Measured on three hunts on 9 Oct:
* [NetConnEdge] connCount 1 -> 0 when the player left, with NumPlayers=1 before and after. The connection
* count is therefore the emptiness signal; NumPlayers is still logged.
*/
static std::atomic<int32_t> g_wdConnCount{ -1 };
static std::atomic<uint64_t> g_wdConnStampMs{ 0 };
static std::atomic<bool> g_wdHadRemoteClient{ false }; // a client has connected at least once
// [2026-10-15 0.2.139] 50 -> 60 s (owner's call: every hunt is player-hosted now; the backend drains it meanwhile).
static constexpr float MP_WD_EMPTY_REAP_SEC = 60.0f; // after the last client left
static constexpr float MP_WD_NEVER_JOINED_REAP_SEC = 300.0f; // spawned, but nobody ever arrived
/*
* [2026-07-18 STALE-TICK REAP] Game-thread tick heartbeat.
*
* THE DEADLOCK THIS BREAKS (observed on port 8787, pid 6920, BPGM_ArchonEscalation_C):
* 22:02:08 [NetConnEdge] connCount 2 -> 1 <- second player leaves
* ...no 1 -> 0 edge is EVER logged...
* 22:12:42 last [SRA enter] ClientConns=1 <- engine tick winds down, one connection still open
* 22:41:26 [Watchdog/thread] numPlayers=1 empty=0 emptySec=0
* The instance sat occupied-by-a-ghost for 39+ minutes and kept holding a concurrent-server slot until
* DeployServer refused new hunts.
*
* Why the existing emptiness test cannot catch it: a player who travels back to Ramsgate without a clean
* close leaves a stale UNetConnection. UE reaps stale connections from UNetDriver::TickDispatch — but the
* tick is exactly what stops once players are gone. So the connection is never timed out, GameMode::Logout
* never fires, NumPlayers stays 1, and the watchdog's `empty` is never true. The cleanup depends on the
* very tick whose absence is the problem.
*
* The fix: publish a heartbeat from the game thread. A hunt with real players in it always ticks, so a
* disposable instance whose tick has been silent for minutes is finished by definition — reap it
* regardless of what NumPlayers claims. This is a plain timestamp store (no GObjects/FName work), so it
* is safe on the game thread and a plain read on the watchdog thread.
*/
static std::atomic<uint64_t> g_wdLastTickMs{ 0 };
// Set once the instance has actually ticked with a player present. Prevents reaping a freshly spawned
// server that has not reached its first tick yet (its heartbeat would look "stale" from birth).
static std::atomic<bool> g_wdSawPlayer{ false };
// Game thread id, published from the game thread. Needed to capture WHERE it is stuck (see below).
static std::atomic<uint32_t> g_wdGameThreadId{ 0 };
// [2026-10-10 1.14.7 RAM] Last GetTickCount64() at which the game thread saw at least one remote client connection
// (stored every frame while ClientConnections.Num > 0). The hub working-set trim uses it so a client that connects and
// leaves between two 2 s watchdog polls still breaks the idle window and re-arms the trim.
static std::atomic<uint64_t> g_wdLastRemoteConnMs{ 0 };
// [2026-10-10 1.14.7 Perf] Frame counters for the once-a-minute [Perf] line. Written by the game thread at the top of
// GameEngineTickHook (servers only) with relaxed atomics; drained with exchange(0) by the watchdog thread. Reset on the
// game thread at listen (StartEmptyWatchdogThread), so boot and map-load frames never reach the first window.
// Read-only timing: nothing here changes the frame or net tick cadence.
static std::atomic<uint64_t> g_perfFrames{ 0 }; // frame intervals measured (entry to entry)
static std::atomic<uint64_t> g_perfFrameTicksSum{ 0 }; // QPC ticks summed over those intervals
static std::atomic<uint64_t> g_perfFrameTicksMax{ 0 };
static std::atomic<uint64_t> g_perfHitches{ 0 }; // intervals > 100 ms
static std::atomic<uint64_t> g_perfEngineCalls{ 0 }; // OrigGameEngineTick calls timed
static std::atomic<uint64_t> g_perfEngineTicksSum{ 0 }; // QPC ticks inside OrigGameEngineTick
static std::atomic<int64_t> g_perfLastFrameQpc{ 0 }; // 0 = start a new interval on the next frame
static std::atomic<int64_t> g_perfQpcFreq{ 0 };
/*
* [2026-10-11 1.14.7 O0 measurement] The [Perf] timer covers only OrigGameEngineTick, but the replication-graph drive
* and the manual TickDispatch/TickFlush run after it, outside the timer, and an idle hub burns ~1.5 logical CPUs that
* the 0.5-1 ms engine tick does not explain. [PerfDetail] (same 60 s cadence, servers only) adds avg/max per call for
* the engine tick, graph drive, manual dispatch and manual flush; ReplicateActor calls and replicated bits per frame
* (and per connection); replication-graph sizes; [PerfThreads] the top threads by CPU with their start module+RVA;
* [PerfNet] wire bytes per peer (MpWireCount). Everything is counters and QPC reads on the game thread, read and
* logged by the watchdog thread; nothing changes a tick, rate or replication setting. NO_PERF_DETAIL.flag (read once)
* turns all of it off, including the sendto hook.
*/
struct MpPerfPhase {
std::atomic<uint64_t> Calls{ 0 };
std::atomic<uint64_t> Sum{ 0 };
std::atomic<uint64_t> Max{ 0 };
};
static MpPerfPhase g_perfPhaseEngine; // OrigGameEngineTick (same calls as the [Perf] engine counters, plus max)
static MpPerfPhase g_perfPhaseGraph; // MpDriveReplicationGraph
static MpPerfPhase g_perfPhaseDispatch; // SafeManualTickDispatch
static MpPerfPhase g_perfPhaseFlush; // SafeManualTickFlush
static std::atomic<uint64_t> g_perfRepActorCalls{ 0 }; // ReplicateActorFreqHook entries
static std::atomic<uint64_t> g_perfRepActorBits{ 0 }; // sum of the native ReplicateActor return (bits written)
// Replication-graph sizes, sampled by the game thread every 2 s (MpPerfSampleGraphCounts); -1 = not read.
static std::atomic<int32_t> g_perfGraphConns{ -1 };
static std::atomic<int32_t> g_perfGraphGlobalActors{ -1 };
static std::atomic<int32_t> g_perfGraphActorInfoSum{ -1 };
static std::atomic<int32_t> g_perfGraphActorInfoMax{ -1 };
static std::atomic<int32_t> g_perfClientConns{ -1 };
static std::atomic<int32_t> g_perfOpenChannelsSum{ -1 };
static std::atomic<int32_t> g_perfOpenChannelsMax{ -1 };
static std::atomic<uint64_t> g_perfGraphSampleMs{ 0 };
// Replicated bits per connection (UActorChannel+0x28), game thread writes, watchdog drains. The reader asks for a reset
// after each window; the game thread then clears the keys, so the table holds the connections of one window.
struct MpRepBitsSlot {
std::atomic<void*> Conn{ nullptr };
std::atomic<uint64_t> Bits{ 0 };
std::atomic<uint64_t> Calls{ 0 };
};
static MpRepBitsSlot g_repBitsSlots[64];
static std::atomic<uint64_t> g_repBitsOverflow{ 0 };
static std::atomic<bool> g_repBitsResetRequested{ false };
static void* g_repBitsLastConn = nullptr; // game thread only
static int g_repBitsLastSlot = -1;
static bool MpPerfDetailEnabled() {
static volatile LONG s_mode = -1; // -1 = not decided, 1 = on, 0 = off (client or NO_PERF_DETAIL.flag)
LONG Mode = s_mode; // plain aligned read: this runs for every replicated channel
if (Mode < 0) {
Mode = (Globals::AmServer && !MpExeRelativeFlagPresent(L"NO_PERF_DETAIL.flag")) ? 1 : 0;
InterlockedExchange(&s_mode, Mode);
}
return Mode == 1;
}
static void MpPerfPhaseAdd(MpPerfPhase& Phase, int64_t T0, int64_t T1) {
if (T1 <= T0) return;
const uint64_t D = static_cast<uint64_t>(T1 - T0);
Phase.Calls.fetch_add(1, std::memory_order_relaxed);
Phase.Sum.fetch_add(D, std::memory_order_relaxed);
uint64_t Max = Phase.Max.load(std::memory_order_relaxed);
while (D > Max && !Phase.Max.compare_exchange_weak(Max, D, std::memory_order_relaxed)) {}
}
static int64_t MpQpcNow() {
LARGE_INTEGER T{};
QueryPerformanceCounter(&T);
return T.QuadPart;
}
// Game thread (ReplicateActorFreqHook). Bits = the native ReplicateActor return; Conn = the channel's connection.
static void MpRepBitsAdd(void* Conn, int64_t Bits) {
if (g_repBitsResetRequested.load(std::memory_order_relaxed)) {
for (MpRepBitsSlot& S : g_repBitsSlots) {
S.Conn.store(nullptr, std::memory_order_relaxed);
S.Bits.store(0, std::memory_order_relaxed);
S.Calls.store(0, std::memory_order_relaxed);
}
g_repBitsLastConn = nullptr;
g_repBitsLastSlot = -1;
g_repBitsResetRequested.store(false, std::memory_order_relaxed);
}
const uint64_t Add = Bits > 0 ? static_cast<uint64_t>(Bits) : 0;
int Slot = -1;
if (Conn && Conn == g_repBitsLastConn && g_repBitsLastSlot >= 0) {
Slot = g_repBitsLastSlot;
} else if (Conn) {
int Free = -1;
for (int i = 0; i < 64; ++i) {
void* K = g_repBitsSlots[i].Conn.load(std::memory_order_relaxed);
if (K == Conn) { Slot = i; break; }
if (!K && Free < 0) Free = i;
}
if (Slot < 0 && Free >= 0) {
g_repBitsSlots[Free].Conn.store(Conn, std::memory_order_relaxed);
Slot = Free;
}
g_repBitsLastConn = Slot >= 0 ? Conn : nullptr;
g_repBitsLastSlot = Slot;
}
if (Slot < 0) {
g_repBitsOverflow.fetch_add(Add, std::memory_order_relaxed);
return;
}
g_repBitsSlots[Slot].Bits.fetch_add(Add, std::memory_order_relaxed);
g_repBitsSlots[Slot].Calls.fetch_add(1, std::memory_order_relaxed);
}
static void MpPerfFrameSample(int64_t NowQpc) {
int64_t Freq = g_perfQpcFreq.load(std::memory_order_relaxed);
if (Freq == 0) {
LARGE_INTEGER F{};
QueryPerformanceFrequency(&F);
Freq = F.QuadPart;
g_perfQpcFreq.store(Freq, std::memory_order_relaxed);
}
const int64_t Last = g_perfLastFrameQpc.exchange(NowQpc, std::memory_order_relaxed);
if (Last <= 0 || NowQpc <= Last) return;
const uint64_t D = static_cast<uint64_t>(NowQpc - Last);
g_perfFrames.fetch_add(1, std::memory_order_relaxed);
g_perfFrameTicksSum.fetch_add(D, std::memory_order_relaxed);
uint64_t Max = g_perfFrameTicksMax.load(std::memory_order_relaxed);
while (D > Max && !g_perfFrameTicksMax.compare_exchange_weak(Max, D, std::memory_order_relaxed)) {}
if (Freq > 0 && D * 10 > static_cast<uint64_t>(Freq)) g_perfHitches.fetch_add(1, std::memory_order_relaxed);
}
static void MpPerfResetCounters() {
g_perfFrames.store(0, std::memory_order_relaxed);
g_perfFrameTicksSum.store(0, std::memory_order_relaxed);
g_perfFrameTicksMax.store(0, std::memory_order_relaxed);
g_perfHitches.store(0, std::memory_order_relaxed);
g_perfEngineCalls.store(0, std::memory_order_relaxed);
g_perfEngineTicksSum.store(0, std::memory_order_relaxed);
g_perfLastFrameQpc.store(0, std::memory_order_relaxed);
// [2026-10-11 1.14.7 O0] [PerfDetail] counters start at listen too.
for (MpPerfPhase* P : { &g_perfPhaseEngine, &g_perfPhaseGraph, &g_perfPhaseDispatch, &g_perfPhaseFlush }) {
P->Calls.store(0, std::memory_order_relaxed);
P->Sum.store(0, std::memory_order_relaxed);
P->Max.store(0, std::memory_order_relaxed);
}
g_perfRepActorCalls.store(0, std::memory_order_relaxed);
g_perfRepActorBits.store(0, std::memory_order_relaxed);
g_repBitsResetRequested.store(true, std::memory_order_relaxed);
}
// [2026-07-19 HANG DIAG] Breadcrumb ring of the last UFunction names the GAME THREAD dispatched through
// ProcessEvent. Written on the game thread (ProcessEventHook already has the cached full name of every call, so
// we just strncpy that into a fixed slot — NO extra name resolution, NO heap). The watchdog thread reads
// these fixed char buffers when a hang is detected, so it never has to resolve an FName off-thread (which
// previously crashed). When the game thread freezes inside a UFunction, ring[pos-1] IS that function.
static constexpr int kPeRing = 32;
static char g_gtPeNameRing[kPeRing][160] = {};
static std::atomic<uint32_t> g_gtPeRingPos{ 0 };
// Innermost currently-dispatching UFunction*/object pointers (raw, for Ghidra) — plain diagnostic stores.
static std::atomic<void*> g_gtPeCurFunc{ nullptr };
static std::atomic<void*> g_gtPeCurObj{ nullptr };
/*
* [2026-07-18] Capture the game thread's instruction pointer while it is hung.
*
* WHY: the observed failure is a HANG, not a crash — replication runs healthily at 60Hz and then stops
* dead mid-hunt with no exception, no exit, and no UE log line (port 8788, pid 7332: last [SRA] and
* [RepFreq] at 22:39:57, then only watchdog output). The process stays alive, so the client just times
* out and gets bounced to login. Nothing in any existing log says where it stopped.
*
* MpLogStack cannot answer this: CaptureStackBackTrace walks the CALLING thread, and the caller here is
* the watchdog thread, which is fine. We need the frozen thread's RIP, which requires suspending it and
* reading its context.
*
* The logged address is module-relative (via MpAddress), so it can be pasted straight into Ghidra to
* identify the blocking call. That converts "the server hangs sometimes" into a specific function.
*
* Safety: the context is captured and the thread resumed IMMEDIATELY, before any logging — logging while
* holding a suspended thread risks deadlock if that thread owns the log's lock. Only ever called from
* the stale-tick path, i.e. when the instance is already condemned.
*/
/*
* [2026-07-18] Reap by TerminateProcess, NOT exit().
*
* WHY: exit() runs UE's static destructors and atexit handlers, and that teardown is broken on this
* build. Measured across four reaps in one evening:
* pid 6640: reap 21:07:31.720 -> ucrtbase!abort 21:08:32.076 = 60.4s
* pid 7004: reap 21:58:59.964 -> ucrtbase!abort 22:00:00.094 = 60.1s
* Exactly 60 seconds each — a shutdown timeout giving up and calling abort(), which the DeployServer
* then reports as `exited code=3221226505` (0xC0000409). That alarming code is NOT a gameplay crash;
* it is our own clean reap dying badly.
*
* It gets worse: during that teardown an access violation fires, and UE's unhandled-exception filter
* (FUN_1424a7da0) crashes *itself* trying to report it —
* 1424a7dda CALL 0x1423ab2f0 ; Meyers-singleton getter
* 1424a7ddf MOV RCX,RAX
* 1424a7de2 MOV RDX,qword ptr [RAX] ; <-- RAX == 0, reads [0x0]
* 1424a7de5 CALL qword ptr [RDX+0x10]
* The getter caches its instance and registers an atexit destructor; once that destructor has run, the
* magic-static guard is already set, so the getter returns the destroyed (null) pointer instead of
* re-creating it. Classic static-destruction-order bug — and it means UE can never write a crash report
* during shutdown, which is exactly why "the logs don't say it crashed".
*
* A disposable hunt server has nothing to flush: all persistence lives in the metagame/Mongo, not in
* process. So skip the whole broken path — terminate immediately. This frees the port and the
* concurrency slot ~60s sooner and stops manufacturing scary exit codes.
*
* NOTE: this does not FIX UE's teardown or its self-crashing handler; it avoids them. A real crash
* during gameplay still routes through the same broken filter, so crash reports remain unreliable —
* our own VEH/ExitTrace logging is the source of truth there.
*/
static void MpReapExit(const char* Reason) {
MpLog(std::string("[Watchdog/thread] ") + Reason
+ " -> TerminateProcess(0) (skipping UE static teardown; see MpReapExit comment)");
// Give the log a moment to flush before the process vanishes.
Sleep(50);
TerminateProcess(GetCurrentProcess(), 0);
// Unreachable in practice; retained so the function cannot fall through if Terminate is hooked.
exit(0);
}
// Object-free stack scan: read the suspended thread's stack upward from RSP and collect values that point
// back into our module's code range (candidate return addresses = a poor-man's call stack). No unwinding
// objects here so it stays SEH-safe; IsReadablePointer guards every read. Kept only as a fallback for the
// accurate RtlVirtualUnwind chain below.
static int ScanStackReturns(uint64_t rsp, uint64_t codeLo, uint64_t codeHi, uint64_t* out, int maxOut) {
int n = 0;
for (uint64_t p = rsp; p < rsp + 0x1400 && n < maxOut; p += 8) {
if (!IsReadablePointer(reinterpret_cast<void*>(p), 8)) break;
uint64_t v = *reinterpret_cast<uint64_t*>(p);
if (v >= codeLo && v < codeHi) out[n++] = v;
}
return n;
}
// [2026-07-19] ACCURATE x64 stack unwind of a (hung) thread using its captured CONTEXT + the loaded .pdata
// (RtlLookupFunctionEntry / RtlVirtualUnwind — both in ntdll, no DbgHelp dependency). Because the game
// thread is parked in an OS wait, its stack is stable, so unwinding a COPY of the context after resuming is
// safe. This replaces the heuristic retchain with the true caller chain — resolve each frame in Ghidra.
static void MpLogRealStack(CONTEXT ctx) { // by value: RtlVirtualUnwind mutates it
for (int i = 0; i < 30 && ctx.Rip; ++i) {
MpLog("[HangTrace] frame[" + std::to_string(i) + "] " + MpAddress(reinterpret_cast<void*>(ctx.Rip)));
DWORD64 imgBase = 0;
PRUNTIME_FUNCTION rf = RtlLookupFunctionEntry(ctx.Rip, &imgBase, nullptr);
if (rf) {
PVOID handlerData = nullptr; DWORD64 establisher = 0;
RtlVirtualUnwind(UNW_FLAG_NHANDLER, imgBase, ctx.Rip, rf, &ctx, &handlerData, &establisher, nullptr);
} else {
// Leaf function (no unwind info): return address sits at [RSP].
if (!IsReadablePointer(reinterpret_cast<void*>(ctx.Rsp), 8)) break;
ctx.Rip = *reinterpret_cast<DWORD64*>(ctx.Rsp);
ctx.Rsp += 8;
}
}
}
// [2026-07-19] Snapshot the RIP of EVERY thread in this process. In a lock deadlock the game thread's own
// RIP only says "waiting" — the culprit is whichever OTHER thread holds the lock. This names the
// counterparty (resolve its RIP in Ghidra). Each thread is suspended only momentarily.
static void MpLogAllThreadRips(uint32_t gameTid) {
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (snap == INVALID_HANDLE_VALUE) { MpLog("[HangTrace] thread snapshot failed"); return; }
THREADENTRY32 te{}; te.dwSize = sizeof(te);
const DWORD myPid = GetCurrentProcessId();
const DWORD selfTid = GetCurrentThreadId();
int logged = 0;
if (Thread32First(snap, &te)) {
do {
if (te.th32OwnerProcessID != myPid || te.th32ThreadID == selfTid) continue;
HANDLE th = OpenThread(THREAD_GET_CONTEXT | THREAD_SUSPEND_RESUME | THREAD_QUERY_INFORMATION, FALSE, te.th32ThreadID);
if (!th) continue;
CONTEXT c{}; c.ContextFlags = CONTEXT_CONTROL;
uint64_t rip = 0;
if (SuspendThread(th) != (DWORD)-1) { if (GetThreadContext(th, &c)) rip = c.Rip; ResumeThread(th); }
CloseHandle(th);
if (rip) {
MpLog("[HangTrace] thread tid=" + std::to_string(te.th32ThreadID)
+ (te.th32ThreadID == gameTid ? " (GAME)" : "")
+ " RIP=" + MpAddress(reinterpret_cast<void*>(rip)));
++logged;
}
} while (Thread32Next(snap, &te) && logged < 96);
}
CloseHandle(snap);
MpLog("[HangTrace] all-thread snapshot: " + std::to_string(logged) + " threads sampled");
}
static void MpLogHungGameThread() {
uint32_t Tid = g_wdGameThreadId.load(std::memory_order_relaxed);
if (Tid == 0) { MpLog("[HangTrace] game thread id unknown; cannot sample"); return; }
HANDLE Th = OpenThread(THREAD_GET_CONTEXT | THREAD_QUERY_INFORMATION | THREAD_SUSPEND_RESUME, FALSE, Tid);
if (!Th) { MpLog("[HangTrace] OpenThread failed for tid=" + std::to_string(Tid)); return; }
// Sample the RIP 3x over ~0.5s: identical RIP => truly blocked (deadlock/wait); moving RIP => spinning
// in a loop. Keep the FULL context of the first sample for an accurate unwind.
uint64_t rip[3] = { 0,0,0 };
uint64_t rsp0 = 0;
uint64_t retChain[12] = {};
int retN = 0;
CONTEXT fullCtx0{};
bool haveCtx0 = false;
// [1.14.7 2026-10-08] The hard-coded 1.12 bound 0x04E00000 cut off the top of the 1.14.7 .text (ends
// 0x04F850AF); read the section from the PE header instead.
uint64_t codeLo = Globals::BaseAddress + 0x1000;
uint64_t codeHi = Globals::BaseAddress + 0x05000000;
if (Globals::BaseAddress) {
const auto* Dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(Globals::BaseAddress);
const auto* Nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(Globals::BaseAddress + Dos->e_lfanew);
const auto* Sec = IMAGE_FIRST_SECTION(Nt);
for (int i = 0; i < Nt->FileHeader.NumberOfSections; ++i) {
if (std::memcmp(Sec[i].Name, ".text", 5) == 0) {
codeLo = Globals::BaseAddress + Sec[i].VirtualAddress;
codeHi = codeLo + Sec[i].Misc.VirtualSize;
break;
}
}
}
for (int s = 0; s < 3; ++s) {
CONTEXT Ctx{};
Ctx.ContextFlags = CONTEXT_FULL;
if (SuspendThread(Th) != (DWORD)-1) {
if (GetThreadContext(Th, &Ctx)) {
rip[s] = Ctx.Rip;
if (s == 0) {
rsp0 = Ctx.Rsp;
fullCtx0 = Ctx; haveCtx0 = true;
retN = ScanStackReturns(Ctx.Rsp, codeLo, codeHi, retChain, 12);
}
}
ResumeThread(Th); // always resume before we log / sleep
}
if (s < 2) Sleep(250);
}
CloseHandle(Th);
if (rip[0] == 0) { MpLog("[HangTrace] GetThreadContext failed for tid=" + std::to_string(Tid)); return; }
bool moving = (rip[0] != rip[1]) || (rip[1] != rip[2]);
MpLog("[HangTrace] game thread tid=" + std::to_string(Tid)
+ (moving ? " SPINNING (RIP moves - loop)" : " BLOCKED (RIP fixed - wait/deadlock)")
+ " RIP=" + MpAddress(reinterpret_cast<void*>(rip[0]))
+ " / " + MpAddress(reinterpret_cast<void*>(rip[1]))
+ " / " + MpAddress(reinterpret_cast<void*>(rip[2]))
+ " RSP=" + MpAddress(reinterpret_cast<void*>(rsp0)));
// ACCURATE call chain via .pdata unwind (primary).
if (haveCtx0) { MpLog("[HangTrace] REAL unwind (game thread):"); MpLogRealStack(fullCtx0); }
// Heuristic scan (fallback / cross-check).
std::string chain;
for (int i = 0; i < retN; ++i) { chain += (i ? " <- " : "") + MpAddress(reinterpret_cast<void*>(retChain[i])); }
MpLog("[HangTrace] heuristic retchain(" + std::to_string(retN) + "): " + (chain.empty() ? "(none in module)" : chain));
// What UFunction was the game thread dispatching? Pointers for Ghidra + the last names it ran.
MpLog("[HangTrace] curFunc=" + MpPtr(g_gtPeCurFunc.load(std::memory_order_relaxed))
+ " curObj=" + MpPtr(g_gtPeCurObj.load(std::memory_order_relaxed)));
uint32_t pos = g_gtPeRingPos.load(std::memory_order_relaxed);
for (int i = 1; i <= 10; ++i) {
uint32_t idx = (pos - static_cast<uint32_t>(i)) % kPeRing;
// Fixed char buffer copied on the game thread — safe to read here without FName resolution.
char buf[160]; memcpy(buf, g_gtPeNameRing[idx], sizeof(buf)); buf[sizeof(buf) - 1] = '\0';
if (buf[0]) MpLog("[HangTrace] recentPE[-" + std::to_string(i) + "] = " + std::string(buf));
}
// Who holds the lock? Snapshot every other thread's RIP — the counterparty is the real bug.
MpLogAllThreadRips(Tid);
}
/*
* [2026-07-19 PERMANENT-BLEEDOUT / "INVINCIBILITY" FIX]
*
* SYMPTOM: a downed player sits at 0 health forever — takes no further damage, never dies, can still
* attack. Reported as "I became invincible". Random only in the sense that you must actually go down
* to hit it; once you do, it is deterministic.
*
* SERVER LOG (34 occurrences across both players in one 30-minute session):
* Script Msg: BP_PlayerCharacter_C_... BleedOutElasped
* SetTimer passed a negative or zero time. The associated timer may fail to be created!
* plus 8x UReviveInteractionAbility::CanUserStartInteraction: UArchonInteractionUserComponent is
* nullptr — so being revived out of it is broken too. Both exits from bleedout are dead ends.
*
* GHIDRA — UArchonBleedoutComponent::TryStartBleedout (FUN_141bb08f0) ends with:
* lVar4 = *(longlong *)(lVar4 + 0x118); // island GameMode
* if (lVar4 == 0) -> "Can't enter bleedout in non-island game modes."
* *(undefined4 *)(param_1 + 0xb4) = *(undefined4 *)(lVar4 + 0x540);
* // UArchonBleedoutComponent::CurrentBleedoutDuration (+0xB4)
* // <- AArchonGameMode_Island::BleedoutDuration (+0x540)
* The Blueprint then feeds CurrentBleedoutDuration to SetTimer. Duration 0 => timer never created
* => bleedout never elapses => permanently downed.
*
* BleedoutDuration is an Edit/BlueprintVisible designer value on the GameMode. It is arriving as 0 on
* our BPGM_ArchonExploration_C, so we supply a sane floor. This is a RUNTIME PATCH, not a root fix —
* the real question (why the Blueprint CDO default is not applied) is unresolved, and the original
* value is logged so we can see whether it is 0 or garbage.
*
* SAFETY: BleedoutDuration exists on AArchonGameMode_Island. Ramsgate (BPGM_City_C) is NOT an island
* mode, and +0x540 there is an unrelated field — so this is hard-gated on IsA(AArchonGameMode_Island)
* and must never be relaxed to the AArchonGameMode base. Game thread only (IsA/GObjects work).
*/
static float BleedoutDurationFallback() {
// Override with MYSTICPARADOX_BLEEDOUT_SECONDS if the default proves wrong in play.
static float Cached = -1.0f;
if (Cached < 0.0f) {
Cached = 30.0f;
char Buf[32]{};
if (GetEnvironmentVariableA("MYSTICPARADOX_BLEEDOUT_SECONDS", Buf, sizeof(Buf)) > 0) {
float Parsed = static_cast<float>(atof(Buf));
if (Parsed > 0.0f && Parsed < 600.0f) Cached = Parsed;
}
}
return Cached;
}
static void EnsureBleedoutDuration(SDK::UObject* GameMode) {
static bool s_done = false;
if (s_done || !GameMode) return;
if (!IsReadablePointer(GameMode, 0x548)) return;
// Hard gate: only the island GameMode has BleedoutDuration at +0x540.
if (!GameMode->IsA(SDK::AArchonGameMode_Island::StaticClass())) { s_done = true; return; }
float* Duration = reinterpret_cast<float*>(reinterpret_cast<uintptr_t>(GameMode) + 0x540);
const float Original = *Duration;
if (Original > 0.0f) {
MpLog("[Bleedout] GameMode BleedoutDuration=" + std::to_string(Original)
+ "s (valid; no patch needed)");
s_done = true;
return;
}
*Duration = BleedoutDurationFallback();
MpLog("[Bleedout] GameMode BleedoutDuration was " + std::to_string(Original)
+ " (<=0) — SetTimer would fail and the player would be stuck downed forever. Patched to "
+ std::to_string(*Duration) + "s.");
s_done = true;
}
/*
* ---------------------------------------------------------------------------------------------
* BLEEDOUT DOMAIN DIAGNOSTICS — ALWAYS ON, NOT FLAG-GATED.
*
* WHY ALWAYS ON: the permanent-downed bug ("invincible at 0 HP, still able to attack") does not
* reproduce on demand. The BleedoutDuration patch above may or may not have cured it, and a stuck
* bleedout *replacement* effect produces a visually identical symptom via a completely different code
* path. If either recurs we get exactly one chance to see it, so the evidence has to already be in the
* log — there is no flag to have remembered to set.
*
* COST: a handful of UFunction-name comparisons on already-hooked ProcessEvent calls, and a few plain
* field reads. Bleedout events fire a few times per player per hunt, so the volume is negligible.
* Deliberately NOT capped by a global "log first N" counter like the other diagnostics — a cap is
* exactly how you miss the one occurrence that mattered. It is instead deduped per state-change.
*
* Field offsets verified against SDK/Archon_classes.hpp (UArchonBleedoutComponent):
* +0x0B0 EArchonBleedoutState CurrentBleedoutState (0 = None, 1 = Bleedout)
* +0x0B1 EArchonBleedoutState PreviousBleedoutState
* +0x0B4 float CurrentBleedoutDuration <- 0 here is THE bug
* +0x130 bool bIsInFinishingHit
* +0x148 TArray ReplacementEffects (data ptr; Num at +0x150)
* +0x160 FActiveGameplayEffectHandle NoHealthGameplayEffectHandle
* +0x170 bool bIsReadyForBleedoutEvents
* ---------------------------------------------------------------------------------------------
*/
static std::string BleedoutSnapshot(void* Comp) {
if (!Comp || !IsReadablePointer(Comp, 0x178)) return "comp=unreadable";
const uintptr_t C = reinterpret_cast<uintptr_t>(Comp);
const uint8_t State = *reinterpret_cast<uint8_t*>(C + 0x0B0);
const uint8_t PrevState = *reinterpret_cast<uint8_t*>(C + 0x0B1);
const float Duration = *reinterpret_cast<float*>(C + 0x0B4);
const uint8_t Finishing = *reinterpret_cast<uint8_t*>(C + 0x130);
const int32_t RepNum = *reinterpret_cast<int32_t*>(C + 0x150);
const int32_t NoHealth = *reinterpret_cast<int32_t*>(C + 0x160);
const uint8_t Ready = *reinterpret_cast<uint8_t*>(C + 0x170);
return "comp=" + MpPtr(Comp)
+ " state=" + std::to_string(State) + (State == 1 ? "(Bleedout)" : "(None)")
+ " prev=" + std::to_string(PrevState)
+ " duration=" + std::to_string(Duration) + (Duration <= 0.0f ? " <== ZERO/NEG: SetTimer WILL FAIL" : "")
+ " replacementEffects=" + std::to_string(RepNum) + (RepNum > 0 ? " <== REPLACEMENT PATH" : "")
+ " finishingHit=" + std::to_string(Finishing)
+ " noHealthEffectHandle=" + std::to_string(NoHealth)
+ " readyForEvents=" + std::to_string(Ready);
}
// Tracks components currently in bleedout so a never-resolving one can be reported. Small fixed array:
// this is per-player, and a hunt caps at a handful. No allocation, no locking — game thread only.
struct BleedoutWatchEntry {
void* Comp;
uint64_t EnteredMs;
float DurationAtEntry;
bool Warned;
};
// [2026-10-11 1.14.7 O1] 8 -> 64 slots: a 30-40 player hub can have more than eight players downed at once (the old
// size was "a hunt caps at a handful"). Overwriting the oldest now logs once.
static BleedoutWatchEntry g_BleedWatch[64]{};
static void BleedoutWatchEnter(void* Comp, float Duration) {
uint64_t Now = GetTickCount64();
for (auto& E : g_BleedWatch) if (E.Comp == Comp) { E.EnteredMs = Now; E.DurationAtEntry = Duration; E.Warned = false; return; }
for (auto& E : g_BleedWatch) if (E.Comp == nullptr) { E = { Comp, Now, Duration, false }; return; }
// All slots busy: overwrite the oldest rather than silently dropping the new one.
BleedoutWatchEntry* Oldest = &g_BleedWatch[0];
for (auto& E : g_BleedWatch) if (E.EnteredMs < Oldest->EnteredMs) Oldest = &E;
static bool s_fullLogged = false;
if (!s_fullLogged) {
s_fullLogged = true;
MpLog("[BleedoutWatch] TABLE_FULL (" + std::to_string(std::size(g_BleedWatch))
+ " players in bleedout at once); overwriting the oldest entry (logged once)");
}
*Oldest = { Comp, Now, Duration, false };
}
static void BleedoutWatchExit(void* Comp) {
for (auto& E : g_BleedWatch) if (E.Comp == Comp) { E = {}; return; }
}
// Called once per tick from GameModeEmptyState. Reports a bleedout that has outlived any plausible
// window — the signature of the stuck/half-downed state, whichever path caused it.
static void BleedoutWatchTick() {
const uint64_t Now = GetTickCount64();
for (auto& E : g_BleedWatch) {
if (E.Comp == nullptr || E.Warned) continue;
const uint64_t HeldMs = Now - E.EnteredMs;
// Generous: longer than any legitimate bleedout window, so a normal downed player never trips it.
if (HeldMs < 120000) continue;
if (!IsReadablePointer(E.Comp, 0x178)) { E = {}; continue; }
if (*reinterpret_cast<uint8_t*>(reinterpret_cast<uintptr_t>(E.Comp) + 0x0B0) != 1) { E = {}; continue; }
MpLog("[BleedoutStuck] STILL in bleedout after " + std::to_string(HeldMs / 1000)
+ "s (durationAtEntry=" + std::to_string(E.DurationAtEntry)
+ ") — player is likely stuck downed/invincible. " + BleedoutSnapshot(E.Comp));
E.Warned = true;
}
}
/*
* Called from the ProcessEvent hook for every bleedout-domain UFunction. `Obj` is the component for
* component functions; for others it is whatever owned the call, and the snapshot simply reports
* unreadable — still useful, because the sequence of function names is itself the evidence.
*/
static void BleedoutNoteEvent(const std::string& FunctionName, void* Obj) {
// ProcessEvent is extremely hot during map load. Reject almost every call with at most three
// searches before walking the complete diagnostic-name list below. Every tracked name contains
// one of these tokens, so this preserves the rare bleedout evidence without doing 17 searches
// for every unrelated Blueprint event.
if (FunctionName.find("Bleedout") == std::string::npos
&& FunctionName.find("FinishingHit") == std::string::npos
&& FunctionName.find("NoHealth") == std::string::npos) {
return;
}
// Only the bleedout-domain functions; every other matching token returns immediately.
static const char* kNames[] = {
"ServerTryStartBleedout", "TryStartBleedout", "AuthSetBleedoutState",
"AuthOnBleedoutReplaced", "OnRep_CurrentBleedoutState",
"ReceiveOnEnteredBleedoutState", "ReceiveOnExitedBleedoutState",
"BroadcastPlayerStartedBleedoutDelegate", "ApplyNoHealthGameplayEffectToSelf",
"DoFinishingHit", "FailsafeDoFinishingHit", "ClientEndFinishingHit",
"RegisterBleedoutReplacementEffect", "UnregisterBleedoutReplacementEffect",
"CanReplaceBleedout", "ReplaceBleedout", "OnPlayerEnterBleedout"
};
const char* Hit = nullptr;
for (const char* N : kNames) {
if (FunctionName.find(N) != std::string::npos) { Hit = N; break; }
}
if (!Hit) return;
/*
* Only read component offsets when the object IS the bleedout component. Several of the names
* above (CanReplaceBleedout / ReplaceBleedout) are invoked on the *replacement effect* object,
* and blindly reading +0xB0..+0x170 there would log plausible-looking garbage — the worst
* possible outcome for a diagnostic whose only job is to be trusted after a rare event.
* We are on the game thread inside ProcessEvent, so IsA is safe here.
*/
const bool IsComponent = Obj != nullptr
&& reinterpret_cast<UObject*>(Obj)->IsA(SDK::UArchonBleedoutComponent::StaticClass());
const std::string Snap = IsComponent
? BleedoutSnapshot(Obj)
: ("obj=" + MpPtr(Obj) + " (not the bleedout component — fields not read)");
MpLog(std::string("[Bleedout] ") + Hit + " | " + Snap);
// Maintain the stuck-watch off the authoritative state field.
if (IsComponent && IsReadablePointer(Obj, 0x178)) {
const uintptr_t C = reinterpret_cast<uintptr_t>(Obj);
const uint8_t State = *reinterpret_cast<uint8_t*>(C + 0x0B0);
const float Duration = *reinterpret_cast<float*>(C + 0x0B4);
if (State == 1) BleedoutWatchEnter(Obj, Duration);
else BleedoutWatchExit(Obj);
// Loudest possible marker for the exact known failure, at the moment it happens.
if (State == 1 && Duration <= 0.0f) {
MpLog("[BleedoutBUG] entered bleedout with duration=" + std::to_string(Duration)
+ " — BleedOutElasped SetTimer will fail and the player will be stuck downed. " + Snap);
}
}
}
// [1.14.7 AFK EXEMPTION 2026-10-04] The managed hubs are listen servers whose only participant is the
// synthetic local controller. Nothing generates input for it, so the game's own AFK timer eventually fires:
//
// LogOnline [Warning] Player Returned to Main Menu Reason: AFK
// ArchonLog [Warning] Disconnect Error Message: You have been away from the keyboard for too long...
// LogGameMode [Display] Match State Changed from InProgress to LeavingMap
// LogNet [Error] LoadMap: failed to Listen(/Game/Maps/ramsgate/ramsgate_01_persistent?closed?MaxPlayers=4)
//
// The hub then holds no UDP socket while the process stays alive, so a client already in matchmaking is
// handed an address nothing is listening on and times out after 20s. Measured: the client named
// "RemoteAddr: 127.0.0.1:8790" at 12:54:04 while the replacement hub only reported
// "Networking::Listen returned OK" at 12:54:21 - 17 seconds too late.
//
// The SDK exposes AArchonPlayerController::bNoAFK at +0xC2C (SDK/Archon_classes.hpp:1097) and the shipped
// AFK routine at RVA 0x01CB8E50 gates on it:
// if (timeoutExceeded && !controller->bNoAFK) { ...AFK message and return to menu... }
// so setting the field exempts a controller without adding a native detour.
//
// Identification is strict, per the audit: SERVER ROLE + the CURRENT world + a ULocalPlayer
// (APlayerController::Player at +0x298, Engine_classes.hpp). Remote players have Player == nullptr, but a
// null network connection alone is NOT accepted as proof. Real players are never touched.
// [2026-10-10 1.14.7 AFK] Verified mechanism (static, 1.14.7 exe): the AFK check 0x01CB8E50 runs only for a LOCAL
// player controller (AController+0x294 bit 1, then vtable +0x6D8), i.e. on a client for its own controller. It
// kicks when 0 < T < PC+0xC28 (idle seconds) && !PC+0xC2C (bNoAFK), with T = GS+0x410 ? GS+0x3FC : GS+0x3F8
// (AArchonGameState::AFKTimeoutFriendly / AFKTimeout). Both GS floats replicate COND_InitialOnly
// (GetLifetimeReplicatedProps 0x01901D60); InitGameState 0x019086E0 copies GM+0x468/+0x46C into them once.
// Native constructor values: 600/600 (base), 180/600 (0x01A95040, Island by layout); live Ramsgate 600 s (two
// client AFK kicks ~600 s after input). bNoAFK is not replicated, so setting it on a remote player's server-side
// controller never had any effect. Since 0.2.111 no server role has a local controller, so the per-controller
// exemption below is a fallback only (MpApplyAfkTimeout writes the replicated GS values instead). The port-free
// watchdog stays as recovery for the phantom-failure case.
// [1.14.7 ACCEPTANCE AID 2026-10-04] The 1.14.7 login screen waits behind PressStartScreen_bps_C, whose own
// log says "UI Screen does not have a valid default focusable widget" - and it ignores synthetic input
// entirely (verified: neither keybd_event nor SendInput with a centre-window click dismisses it). A scripted
// repro therefore cannot get past it, even though everything behind it is fixed and verified.
//
// This invokes the very function a click would: ULoginScreen_bps_C::OnPressToStartDismissed
// (LoginScreen_bps_classes.hpp:287). The client's own log shows that call leading straight into the rest of
// the login chain (PrepareForLoginProxy -> OnSuccess_5A94... -> ...), so it is equivalent to the click and
// changes nothing else. Flag gated (AUTO_PRESS_START.flag beside the executable) so ordinary play, where a
// human clicks, is completely unaffected.
// [1.14.7 ACCEPTANCE AID 2026-10-04] Same idea as the press-to-start: the main menu's Play -> Ramsgate is a
// single game function, AArchonPlayerController::StartFastTravelToCity() (SDK/Archon_classes.hpp:1226, in the
// same class that owns bNoAFK), so a scripted repro can reach Ramsgate without a human choosing it in the UI.
// The main menu is recognisable from its own OnPlayScreenPollTimer event, which fires once a second; a few
// polls are allowed first so the menu is settled before travelling.
//
// Flag gated by the same AUTO_PRESS_START.flag and client-only, so ordinary play is unaffected.
static void AutoTravelToCityFromEvent(SDK::UObject* Object, const std::string& FunctionName) {
if (Globals::AmServer) return; // client only
if (!MpExeRelativeFlagPresent(L"AUTO_PRESS_START.flag")) return;
if (FunctionName.find("OnPlayScreenPollTimer") == std::string::npos) return;
static int s_pollsSeen = 0;
static bool s_invoked = false;
if (s_invoked) return;
if (++s_pollsSeen < 4) return; // let the menu settle
if (!SDK::UObject::GObjects) return;
// The first attempt used AArchonPlayerController::StartFastTravelToCity, which cannot work here: the
// diagnostics proved there is NO AArchonPlayerController at all on the login screen / main menu
// ("no local AArchonPlayerController yet (controllers=0 local=0)"). The travel is issued by the party
// client instead: AArchonPartyClient::StartTravelToCity(bool bLeaveWithParty)
// (SDK/Archon_classes.hpp:24321, class at 24244 - an AInfo actor, so it exists while the menu is up).
int PartyClientsSeen = 0;
for (int i = 0; i < SDK::UObject::GObjects->Num(); i++) {
SDK::UObject* Obj = SDK::UObject::GObjects->GetByIndex(i);
if (!Obj || Obj->IsDefaultObject()) continue;
if (!Obj->IsA(SDK::AArchonPartyClient::StaticClass())) continue;
++PartyClientsSeen;
SDK::AArchonPartyClient* Party = static_cast<SDK::AArchonPartyClient*>(Obj);
if (!IsReadablePointer(Party, 0x700)) continue;
SDK::UFunction* Fn = Party->Class
? Party->Class->GetFunction("ArchonPartyClient", "StartTravelToCity")
: nullptr;
if (!Fn) {
MpLog("[AutoTravel] StartTravelToCity not found on " + std::string(Party->Class ? Party->Class->GetName() : "null")
+ "; partyClients=" + std::to_string(PartyClientsSeen) + "; leaving travel to the UI");
s_invoked = true;
return;
}
// StartTravelToCity(bool bLeaveWithParty) - solo, so leave the party behind.
struct TravelParams { bool bLeaveWithParty; } Params{ false };
Party->ProcessEvent(Fn, &Params);
s_invoked = true;
MpLog("[AutoTravel] invoked AArchonPartyClient::StartTravelToCity(false) after "
+ std::to_string(s_pollsSeen) + " main-menu polls; party=" + MpPtr(Party) + " fn=" + MpPtr(Fn));
return;
}
if (s_pollsSeen % 10 == 0) {
MpLog("[AutoTravel] no AArchonPartyClient yet (partyClients=" + std::to_string(PartyClientsSeen)
+ ") at poll " + std::to_string(s_pollsSeen));
}
}
// [1.14.7 ACCEPTANCE AID 2026-10-05] Scripted-repro counterpart of the operator pressing the login
// screen's Play button. Measured: after the press-to-start helper above runs, the client sits on the
// Play screen and its own OnPlayScreenPollTimer fires once a second forever; the transition to
// Ramsgate is raised by the Blueprint button event
// LoginScreen_bps_C.BndEvt__w_loginbutton_K2Node_ComponentBoundEvent_4_Button Clicked__DelegateSignature
// which a scripted run cannot produce (same reason OnPressToStartDismissed needed its helper). In the
// previous recorded run that exact event is what led straight into TryTransitionToState ->
// StartMatchmakingIntoCity, and without it no AArchonPartyClient is ever created, so
// AutoTravelToCityFromEvent below can never fire either.
//
// The event name carries a Blueprint-graph index (the "_4_"), so it is found by walking the login
// screen class's UField children (UStruct::Children @ +0x48, UField::Next @ +0x28) and matching the
// widget's own name instead of hardcoding the index. Nothing else is touched: the found UFunction is
// dispatched on the login screen object exactly as the button click would.
//
// Same AUTO_PRESS_START.flag gate and client-only, so ordinary play is unaffected.
static void AutoPlayFromEvent(SDK::UObject* Object, const std::string& FunctionName) {
if (Globals::AmServer) return; // client only
if (!MpExeRelativeFlagPresent(L"AUTO_PRESS_START.flag")) return;
if (FunctionName.find("OnPlayScreenPollTimer") == std::string::npos) return;
static int s_polls = 0;
static bool s_invoked = false;
if (s_invoked) return;
if (++s_polls < 4) return; // let the menu settle
if (!Object || !Object->Class) return;
if (!IsReadablePointer(Object->Class, 0x50)) return;
SDK::UField* Child = *reinterpret_cast<SDK::UField**>(
reinterpret_cast<uintptr_t>(Object->Class) + 0x48);
int Guard = 0;
while (Child && Guard++ < 8192) {
if (!IsReadablePointer(Child, 0x30)) break;
const std::string ChildName = Child->GetName();
if (ChildName.find("w_loginbutton") != std::string::npos
&& ChildName.find("Clicked") != std::string::npos) {
uint8_t Params[256] = {}; // the event takes no parameters
Object->ProcessEvent(reinterpret_cast<SDK::UFunction*>(Child), Params);
s_invoked = true;
MpLog("[AutoPlay] invoked " + ChildName + " on " + FunctionName
+ " (the same event the Play button click raises); screen=" + MpPtr(Object)
+ " fn=" + MpPtr(Child));
return;
}
Child = Child->Next;
}
if (s_polls % 10 == 0) {
MpLog("[AutoPlay] no Play-button click event found on "
+ std::string(Object->Class->GetName()) + " at poll " + std::to_string(s_polls)
+ "; leaving the screen alone");
}
}
// Driven by the ProcessEvent hook rather than a tick. The first attempt lived behind the GameMode lookup,
// which does not run on the login screen, so it never fired (measured: autoPress=0 for two minutes). The
// LoginScreen's own OnStartupMovieFinished event is the moment the screen becomes interactive and it is
// delivered on the client, so that is the trigger.
static void AutoPressStartFromEvent(SDK::UObject* Object, const std::string& FunctionName) {
if (Globals::AmServer) return; // client only
if (!MpExeRelativeFlagPresent(L"AUTO_PRESS_START.flag")) return;
if (FunctionName.find("OnStartupMovieFinished") == std::string::npos) return;
static bool s_invoked = false;
if (s_invoked) return;
if (!Object || !Object->Class) return;
SDK::UFunction* Fn = Object->Class->GetFunction("LoginScreen_bps_C", "OnPressToStartDismissed");
if (!Fn) {
MpLog("[AutoPressStart] OnPressToStartDismissed not found on " + FunctionName + "; leaving the screen alone");
s_invoked = true; // do not spam every event
return;
}
// OnPressToStartDismissed(int32 UserIndex) takes a single int32, so a local buffer is enough and the SDK
// parameters header does not have to be included here.
struct PressStartParams { int32_t UserIndex; } Params{ 0 };
Object->ProcessEvent(Fn, &Params);
s_invoked = true;
MpLog("[AutoPressStart] invoked OnPressToStartDismissed after " + FunctionName
+ " (the same function a click calls); screen=" + MpPtr(Object) + " fn=" + MpPtr(Fn));
}
// [CLIENT BRANDING 2026-10-08] Mystic Paradox login screen. Client only and RVA-free: every call is a reflected
// UFunction reached through ProcessEvent, every field comes from the 1.14.7 SDK dump.
// - Background. LoginScreen_bps_C.SetSplashImage reads the served hunt-pass season's
// HuntPassScreenDetails.LoginTexture and puts it on SplashImage with SetBrushFromSoftTexture (read from the
// cooked asset's imports), which is why the art followed the season. The PNG embedded by MysticParadox.rc
// is decoded by KismetRenderingLibrary.ImportBufferAsTexture2D and set with Image.SetBrushFromTexture, which
// also cancels the season texture's pending async load. Applied from Construct onward.
// - The DAUNTLESS logo (ArchonLogoMat, /Game/UI/Textures/Logo/ArchonLogo_Mat) is collapsed; the background
// carries the Mystic Paradox logo.
// - The patch-notes "Update <release_version>" line (preview and popup) is collapsed; the Metagame serves the
// title and text (routes/system.ts /patchnotes).
// Re-asserted after every LoginScreen event because the screen's own animations and native refreshes may
// restore them; once applied each check is a pointer or byte compare. If decoding fails the season art stays.
// DISABLE_LOGIN_BRANDING.flag beside the executable reverts to the stock screen.
namespace LoginBranding {
static SDK::UTexture2D* s_Background = nullptr;
static int32_t s_BackgroundIndex = -1;
static bool s_ImportFailed = false;
static bool EmbeddedBackground(const uint8_t*& Data, uint32_t& Size) {
static const uint8_t* s_Data = nullptr;
static uint32_t s_Size = 0;
static bool s_Looked = false;
if (!s_Looked) {
s_Looked = true;
HMODULE Self = nullptr;
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(&EmbeddedBackground), &Self) && Self) {
if (HRSRC Res = FindResourceW(Self, MAKEINTRESOURCEW(IDR_MP_LOGIN_BACKGROUND), MAKEINTRESOURCEW(10) /*RT_RCDATA*/)) {
HGLOBAL Loaded = LoadResource(Self, Res);
s_Data = Loaded ? static_cast<const uint8_t*>(LockResource(Loaded)) : nullptr;
s_Size = s_Data ? SizeofResource(Self, Res) : 0;
}
}
}
Data = s_Data;
Size = s_Size;
return Data && Size > 0;
}
// The texture is referenced only by SplashImage's brush, so it is collected with the screen; a later screen
// re-imports. GObjects identity (index + pointer + class) tells whether the cached pointer is still that texture.
static bool BackgroundAlive() {
return s_Background && s_BackgroundIndex >= 0
&& SDK::UObject::GObjects->GetByIndex(s_BackgroundIndex) == static_cast<SDK::UObject*>(s_Background)
&& s_Background->IsA(SDK::UTexture2D::StaticClass());
}
static SDK::UTexture2D* ImportBackground(SDK::UObject* WorldContext) {
const uint8_t* Data = nullptr;
uint32_t Size = 0;
if (!EmbeddedBackground(Data, Size)) {
MpLog("[LoginBranding] embedded background resource not found - keeping the season image");
return nullptr;
}
SDK::UFunction* Fn = SDK::UKismetRenderingLibrary::StaticClass()->GetFunction("KismetRenderingLibrary", "ImportBufferAsTexture2D");
SDK::UObject* Library = SDK::UKismetRenderingLibrary::GetDefaultObj();
if (!Fn || !Library) {
MpLog("[LoginBranding] KismetRenderingLibrary.ImportBufferAsTexture2D not found - keeping the season image");
return nullptr;
}
// Params::KismetRenderingLibrary_ImportBufferAsTexture2D with the TArray<uint8> spelled out so it can point at
// the read-only resource bytes: the native thunk only reads a const TArray<uint8>&, and ProcessEvent never frees
// caller-owned parameters.
struct {
SDK::UObject* WorldContextObject;
const uint8_t* BufferData;
int32_t BufferNum;
int32_t BufferMax;
SDK::UTexture2D* ReturnValue;
} Parms{ WorldContext, Data, static_cast<int32_t>(Size), static_cast<int32_t>(Size), nullptr };
static_assert(sizeof(Parms) == 0x20, "KismetRenderingLibrary_ImportBufferAsTexture2D is 0x20 bytes");
const uint64_t StartMs = GetTickCount64();
Library->ProcessEvent(Fn, &Parms);
MpLog("[LoginBranding] decoded embedded background (" + std::to_string(Size) + " bytes) in "
+ std::to_string(GetTickCount64() - StartMs) + "ms -> texture=" + MpPtr(Parms.ReturnValue));
return Parms.ReturnValue;
}
static void ApplyBackground(SDK::ULoginScreen_bps_C* Screen, const std::string& FunctionName) {
SDK::UImage* Splash = Screen->SplashImage;
if (s_ImportFailed || !Splash || !IsReadablePointer(Splash, sizeof(SDK::UImage))) return;
const bool Alive = BackgroundAlive();
if (Alive && Splash->Brush.ResourceObject == static_cast<SDK::UObject*>(s_Background)) return; // already ours
if (!Alive) {
s_Background = ImportBackground(Screen);
s_BackgroundIndex = s_Background ? s_Background->Index : -1;
if (!s_Background) {
s_ImportFailed = true; // do not retry on every event
return;
}
}
static SDK::UFunction* s_SetBrushFromTexture = nullptr;
if (!s_SetBrushFromTexture) s_SetBrushFromTexture = Splash->Class->GetFunction("Image", "SetBrushFromTexture");
if (!s_SetBrushFromTexture) {
MpLog("[LoginBranding] Image.SetBrushFromTexture not found - keeping the season image");
s_ImportFailed = true;
return;
}
// bMatchSize=false keeps the brush size the screen already laid out (the season art is the same 16:9 shape).
struct { SDK::UTexture2D* Texture; bool bMatchSize; uint8_t Pad[7]; } Parms{ s_Background, false, {} };
Splash->ProcessEvent(s_SetBrushFromTexture, &Parms);
static int s_Logged = 0;
if (s_Logged++ < 16) {
MpLog("[LoginBranding] background set after " + FunctionName + " screen=" + MpPtr(Screen)
+ " applied=" + std::to_string(Splash->Brush.ResourceObject == static_cast<SDK::UObject*>(s_Background)));
}
}
static void Collapse(SDK::UWidget* Widget, const char* What) {
if (!Widget || !IsReadablePointer(Widget, sizeof(SDK::UWidget))) return;
if (Widget->Visibility == SDK::ESlateVisibility::Collapsed) return;
static SDK::UFunction* s_SetVisibility = nullptr;
if (!s_SetVisibility) s_SetVisibility = Widget->Class->GetFunction("Widget", "SetVisibility");
if (!s_SetVisibility) return;
SDK::ESlateVisibility Parms = SDK::ESlateVisibility::Collapsed;
Widget->ProcessEvent(s_SetVisibility, &Parms);
static int s_Logged = 0;
if (s_Logged++ < 16) {
std::string Parent = "?";
SDK::UPanelSlot* Slot = Widget->Slot;
if (Slot && IsReadablePointer(Slot, sizeof(SDK::UPanelSlot)) && Slot->Parent
&& IsReadablePointer(Slot->Parent, sizeof(SDK::UObject))) {
Parent = Slot->Parent->GetName();
}
MpLog(std::string("[LoginBranding] collapsed ") + What + " widget=" + MpPtr(Widget) + " parent=" + Parent);
}
}
static void OnLoginScreenEvent(SDK::UObject* Object, const std::string& FunctionName) {
if (Globals::AmServer) return; // client only
static const bool s_Disabled = MpExeRelativeFlagPresent(L"DISABLE_LOGIN_BRANDING.flag");
if (s_Disabled) return;
if (!Object || !IsReadablePointer(Object, sizeof(SDK::ULoginScreen_bps_C))) return;
if (!Object->IsA(SDK::ULoginScreen_bps_C::StaticClass())) return;
auto* Screen = static_cast<SDK::ULoginScreen_bps_C*>(Object);
ApplyBackground(Screen, FunctionName);
Collapse(Screen->ArchonLogoMat, "DAUNTLESS logo (ArchonLogoMat)");
SDK::UPatchNotesPreviewWidget* Preview = Screen->PatchNotesPreviewWidget;
if (Preview && IsReadablePointer(Preview, sizeof(SDK::UPatchNotesPreviewWidget))) {
Collapse(Preview->PatchNotesVersionTextBlock, "patch-notes preview version line");
}
SDK::UPatchNotesPopupWidget* Popup = Screen->PatchNotesPopupWidget;
if (Popup && IsReadablePointer(Popup, sizeof(SDK::UPatchNotesPopupWidget))) {
Collapse(Popup->PatchVersion, "patch-notes popup version line");
}
}
} // namespace LoginBranding
static FProperty* FindPropertyByNameInChain(UStruct* Struct, const char* TargetName); // defined below
// [CLIENT BRANDING 2026-10-09] Credits: a "Mystic Paradox" block above Phoenix Labs. Client only and RVA-free.
// bpw_Credits_C stacks its studio sections in one ScrollBox; each credits_section_name_title_bpw_C is a title
// TextBlock (SectionTextBlock) over a ColumnsBox whose first two TextBlocks receive every name and every title as
// one multi-line string, filled by UArchonCreditsSection::NativeConstruct from SectionTable (FCreditsNameTitle
// rows). NativeConstruct returns before touching any text when SectionTable is null (it only logs "No
// SectionTable specified"), so a section created without a table keeps the text set here.
// The block is: title (Phoenix's gold section-title style), subheading and disclaimer (the titles-column style),
// then a table-less section of the same class as Phoenix showing only the names | titles columns (its own title
// collapsed). The extra lines sit in the ScrollBox itself: the section's inner layout is not a VerticalBox, so
// lines cannot go inside it (0.2.91-0.2.93 logged subheading=0 for that reason).
// UPanelWidget has no reflected InsertChildAt, so everything is added while the credits widget is initialised
// (UserWidget.OnInitialized, before Slate exists): AddChild, copy Phoenix's slot layout, then move the new slot in
// front of Phoenix in the ScrollBox's Slots array - Slate later builds the children in that order.
// DISABLE_CREDITS_BRANDING.flag beside the executable reverts to the stock credits.
namespace CreditsBranding {
// Layout: title, subheading, empty line, disclaimer, empty line, names | titles, two empty lines, Phoenix Labs.
// The empty lines are trailing newlines; both columns end with the same two so their rows stay aligned.
static const wchar_t* const kTitle = L"Mystic Paradox";
static const wchar_t* const kSubheading = L"Private Server Development\n";
static const wchar_t* const kDisclaimer =
L"Mystic Paradox is a community preservation server\nand is not affiliated with Phoenix Labs.\n";
static const wchar_t* const kNames = L"Mystic / Pranav Karande\nHarmonicRain\ngwog / SyST3MDeV\n\n";
static const wchar_t* const kRoles = L"Mystic Paradox\nUndaunted Fork\nUndaunted 1.4.4\n\n";
static SDK::UObject* s_Section = nullptr; // last injected section, for the PreConstruct re-check
static int32_t s_SectionIndex = -1;
static bool CallUFunction(SDK::UObject* Object, const char* ClassName, const char* FunctionName, void* Parms) {
if (!Object || !Object->Class) return false;
SDK::UFunction* Fn = Object->Class->GetFunction(ClassName, FunctionName);
if (!Fn) {
MpLog(std::string("[CreditsBranding] ") + ClassName + "." + FunctionName + " not found");
return false;
}
Object->ProcessEvent(Fn, Parms);
return true;
}
// Each FText is created once and never destroyed, so the reference it holds keeps the copies handed to SetText
// valid for the session (a few hundred bytes in total).
static const uint8_t* TextFor(const wchar_t* Value) {
static std::unordered_map<const wchar_t*, const uint8_t*> s_Texts;
if (auto It = s_Texts.find(Value); It != s_Texts.end()) return It->second;
// Params::KismetTextLibrary_Conv_StringToText: FString InString (Data, Num, Max) + FText ReturnValue.
struct { const wchar_t* Data; int32_t Num; int32_t Max; uint8_t ReturnValue[0x18]; } Parms{};
static_assert(sizeof(Parms) == 0x28, "KismetTextLibrary_Conv_StringToText is 0x28 bytes");
Parms.Data = Value;
Parms.Num = Parms.Max = static_cast<int32_t>(wcslen(Value)) + 1;
if (!CallUFunction(SDK::UKismetTextLibrary::GetDefaultObj(), "KismetTextLibrary", "Conv_StringToText", &Parms)) {
return nullptr;
}
auto* Text = new uint8_t[0x18];
memcpy(Text, Parms.ReturnValue, 0x18);
s_Texts.emplace(Value, Text);
return Text;
}
static void SetText(SDK::UWidget* TextBlock, const wchar_t* Value) {
const uint8_t* Text = TextFor(Value);
if (!TextBlock || !Text) return;
struct { uint8_t InText[0x18]; } Parms;
memcpy(Parms.InText, Text, 0x18);
CallUFunction(TextBlock, "TextBlock", "SetText", &Parms);
}
static SDK::UWidget* ChildAt(SDK::UPanelWidget* Panel, int32_t Index) {
if (!Panel || !Panel->Slots.IsValidIndex(Index)) return nullptr;
SDK::UPanelSlot* Slot = Panel->Slots[Index];
return Slot ? Slot->Content : nullptr;
}
static int32_t SlotIndexOf(SDK::UPanelWidget* Panel, SDK::UWidget* Child) {
for (int32_t i = 0; Panel && i < Panel->Slots.Num(); ++i) {
if (Panel->Slots[i] && Panel->Slots[i]->Content == Child) return i;
}
return -1;
}
// AddChild, give the new slot Reference's alignment (and padding if asked), then move it to Index. Only valid
// before Slate is built.
static bool AddChildAt(SDK::UPanelWidget* Panel, SDK::UWidget* Child, int32_t Index, SDK::UPanelSlot* Reference,
bool CopyPadding) {
struct { SDK::UWidget* Content; SDK::UPanelSlot* ReturnValue; } Parms{ Child, nullptr };
if (!Child || !CallUFunction(Panel, "PanelWidget", "AddChild", &Parms) || !Parms.ReturnValue) return false;
SDK::UPanelSlot* NewSlot = Parms.ReturnValue;
if (Reference && Reference->Class == NewSlot->Class) {
for (const char* Name : { "Padding", "Size", "HorizontalAlignment", "VerticalAlignment" }) {
if (!CopyPadding && strcmp(Name, "Padding") == 0) continue;
FProperty* Prop = FindPropertyByNameInChain(reinterpret_cast<UStruct*>(NewSlot->Class), Name);
if (!Prop || Prop->ElementSize <= 0 || Prop->ElementSize > 0x20) continue; // POD layout fields only
memcpy(reinterpret_cast<uint8_t*>(NewSlot) + Prop->Offset,
reinterpret_cast<uint8_t*>(Reference) + Prop->Offset, Prop->ElementSize);
}
}
auto& Slots = Panel->Slots;
const int32_t Last = Slots.Num() - 1;
if (Index < 0 || Index > Last || Slots[Last] != NewSlot) return false;
for (int32_t i = Last; i > Index; --i) Slots[i] = Slots[i - 1];
Slots[Index] = NewSlot;
return true;
}
// A centred TextBlock owned by Outer, styled like Style.
static SDK::UTextBlock* MakeStyledText(SDK::UObject* Outer, SDK::UTextBlock* Style, const wchar_t* Value) {
struct { SDK::UClass* ObjectClass; SDK::UObject* Outer; SDK::UObject* ReturnValue; } Spawn{ Style->Class, Outer, nullptr };
CallUFunction(SDK::UGameplayStatics::GetDefaultObj(), "GameplayStatics", "SpawnObject", &Spawn);
auto* Line = static_cast<SDK::UTextBlock*>(Spawn.ReturnValue);
if (!Line) return nullptr;
// Field by field: FSlateFontInfo+0x30 and FSlateColor+0x18 are TSharedPtrs (CompositeFont,
// LinkedSpecifiedColor) that a byte copy would leave unreferenced.
Line->Font.FontObject = Style->Font.FontObject;
Line->Font.FontMaterial = Style->Font.FontMaterial;
Line->Font.OutlineSettings = Style->Font.OutlineSettings;
Line->Font.TypefaceFontName = Style->Font.TypefaceFontName;
Line->Font.Size = Style->Font.Size;
Line->Font.LetterSpacing = Style->Font.LetterSpacing;
const uint8_t* Linked = reinterpret_cast<const uint8_t*>(&Style->ColorAndOpacity) + 0x18; // TSharedPtr
if (std::all_of(Linked, Linked + 0x10, [](uint8_t B) { return B == 0; })) Line->ColorAndOpacity = Style->ColorAndOpacity;
Line->ShadowOffset = Style->ShadowOffset;
Line->ShadowColorAndOpacity = Style->ShadowColorAndOpacity;
Line->Justification = SDK::ETextJustify::Center;
SetText(Line, Value);
return Line;
}
static void ApplyTexts(SDK::UArchonCreditsSection* Section) {
SetText(ChildAt(reinterpret_cast<SDK::UPanelWidget*>(Section->ColumnsBox), 0), kNames);
SetText(ChildAt(reinterpret_cast<SDK::UPanelWidget*>(Section->ColumnsBox), 1), kRoles);
}
static void Inject(SDK::Ubpw_Credits_C* Credits) {
SDK::UArchonCreditsSection* Phoenix = Credits->credits_section_phoenix;
if (!Phoenix || !IsReadablePointer(Phoenix, sizeof(SDK::UArchonCreditsSection)) || !Phoenix->Slot) {
MpLog("[CreditsBranding] credits_section_phoenix missing - credits left unchanged");
return;
}
SDK::UPanelWidget* Parent = Phoenix->Slot->Parent;
const int32_t PhoenixIndex = SlotIndexOf(Parent, Phoenix);
auto* TitleStyle = static_cast<SDK::UTextBlock*>(Phoenix->SectionTextBlock);
auto* RolesStyle = static_cast<SDK::UTextBlock*>(ChildAt(reinterpret_cast<SDK::UPanelWidget*>(Phoenix->ColumnsBox), 1));
if (!RolesStyle) {
MpLog("[CreditsBranding] Phoenix titles column not found - subheading and disclaimer use the title style");
RolesStyle = TitleStyle;
}
if (PhoenixIndex < 0 || !TitleStyle || !Credits->WidgetTree) {
MpLog("[CreditsBranding] Phoenix Labs layout not as expected (index " + std::to_string(PhoenixIndex)
+ ") - credits left unchanged");
return;
}
// Same class as the Phoenix section, so the same fonts, colours and column layout; no SectionTable.
struct { SDK::UObject* WorldContextObject; SDK::UClass* WidgetType; SDK::UObject* OwningPlayer; SDK::UObject* ReturnValue; }
Create{ Credits, Phoenix->Class, nullptr, nullptr };
CallUFunction(SDK::UWidgetBlueprintLibrary::GetDefaultObj(), "WidgetBlueprintLibrary", "Create", &Create);
auto* Section = static_cast<SDK::UArchonCreditsSection*>(Create.ReturnValue);
if (!Section || !Section->SectionTextBlock || !Section->ColumnsBox) {
MpLog("[CreditsBranding] could not create the Mystic Paradox section - credits left unchanged");
return;
}
Section->bShowContributerTitle = Phoenix->bShowContributerTitle;
Section->SectionTextBlock->Visibility = SDK::ESlateVisibility::Collapsed; // the title is its own line above
ApplyTexts(Section);
// Only the first line takes Phoenix's padding (the gap between studios); the rest stack tight.
SDK::UWidget* Lines[] = {
MakeStyledText(Credits->WidgetTree, TitleStyle, kTitle),
MakeStyledText(Credits->WidgetTree, RolesStyle, kSubheading),
MakeStyledText(Credits->WidgetTree, RolesStyle, kDisclaimer),
Section,
};
int32_t Placed = 0;
for (SDK::UWidget* Line : Lines) {
if (!AddChildAt(Parent, Line, PhoenixIndex + Placed, Phoenix->Slot, Placed == 0)) break;
++Placed;
}
if (Placed == 4) {
s_Section = Section;
s_SectionIndex = Section->Index;
}
MpLog("[CreditsBranding] Mystic Paradox block above Phoenix Labs: placed " + std::to_string(Placed)
+ "/4 (title, subheading, disclaimer, names) at index " + std::to_string(PhoenixIndex) + " of "
+ Parent->Class->GetName() + " section=" + MpPtr(Section));
}
static void OnCreditsEvent(SDK::UObject* Object, const std::string& FunctionName) {
if (Globals::AmServer) return; // client only
static const bool s_Disabled = MpExeRelativeFlagPresent(L"DISABLE_CREDITS_BRANDING.flag");
if (s_Disabled || !Object || !Object->Class) return;
// OnInitialized fires for every widget the game creates: match the class by FName index once it is known.
static int32_t s_CreditsNameIdx = 0;
const int32_t NameIdx = Object->Class->Name.ComparisonIndex;
if (s_CreditsNameIdx == 0) {
if (Object->Class->GetName() != "bpw_Credits_C") return;
s_CreditsNameIdx = NameIdx;
} else if (NameIdx != s_CreditsNameIdx) {
return;
}
if (FunctionName.find("OnInitialized") != std::string::npos) {
Inject(static_cast<SDK::Ubpw_Credits_C*>(Object));
} else if (FunctionName.find("PreConstruct") != std::string::npos) {
// Built by now: re-assert the texts in case anything rewrote them, and say so once.
const bool Alive = s_Section && s_SectionIndex >= 0
&& SDK::UObject::GObjects->GetByIndex(s_SectionIndex) == s_Section;
if (Alive) ApplyTexts(static_cast<SDK::UArchonCreditsSection*>(s_Section));
static int s_Logged = 0;
if (s_Logged++ < 4) MpLog(std::string("[CreditsBranding] credits constructed; Mystic Paradox section ") + (Alive ? "present" : "MISSING"));
}
}
} // namespace CreditsBranding
// [CLIENT BRANDING 2026-10-09] Press-start popup text. PressStartScreen_bps_C (the popup before the login screen)
// shows "Dauntless is made with feedback from Slayers like you. Join the community on Discord, Reddit, or
// Twitter!" (Game.locres) in its PressStartPanel's WelcomeText TextBlock. The same panel class also backs the
// login screen's queue and "cannot play online" dialogs, so only a panel whose owner (Outer -> WidgetTree ->
// Outer) is a PressStartScreen_bps_C is changed. Re-applied after each event of that screen or panel, because
// the panel's own text refresh may rewrite the TextBlock. Client only and RVA-free.
// DISABLE_PRESS_START_BRANDING.flag beside the executable reverts.
namespace PressStartBranding {
// The empty line sets the credit apart (the TextBlock has one style for all lines).
static const wchar_t* const kWelcome =
L"Welcome back to the Shattered Isles, Slayer.\n"
L"Mystic Paradox keeps the hunt going, together.\n"
L"\n"
L"Credits: Mystic • github.com/pranav158/Mystic-Paradox";
static bool IsPressStartScreen(SDK::UObject* Object) {
return Object && IsReadablePointer(Object, sizeof(SDK::UPressStartScreen_bps_C))
&& Object->IsA(SDK::UPressStartScreen_bps_C::StaticClass());
}
static void OnPressStartEvent(SDK::UObject* Object, const std::string& FunctionName) {
if (Globals::AmServer) return; // client only
static const bool s_Disabled = MpExeRelativeFlagPresent(L"DISABLE_PRESS_START_BRANDING.flag");
if (s_Disabled || !Object || !Object->Class) return;
SDK::UPressStartPanel_bpw_C* Panel = nullptr;
if (FunctionName.find("PressStartScreen_bps_C.") != std::string::npos) {
if (!IsPressStartScreen(Object)) return;
Panel = static_cast<SDK::UPressStartScreen_bps_C*>(Object)->PressStartPanel;
} else if (FunctionName.find("PressStartPanel_bpw_C.") != std::string::npos) {
SDK::UObject* Tree = Object->Outer;
if (!Tree || !IsReadablePointer(Tree, sizeof(SDK::UObject)) || !IsPressStartScreen(Tree->Outer)) return;
Panel = static_cast<SDK::UPressStartPanel_bpw_C*>(Object);
}
if (!Panel || !IsReadablePointer(Panel, sizeof(SDK::UPressStartPanel_bpw_C)) || !Panel->WelcomeText) return;
CreditsBranding::SetText(Panel->WelcomeText, kWelcome);
static int s_Logged = 0;
if (s_Logged++ < 6) MpLog("[PressStartBranding] welcome text set after " + FunctionName);
}
} // namespace PressStartBranding
// The SDK has no GetWorld() on AArchonPlayerController, so the owning world is found by walking Outer
// (Actor -> Level -> World). Used only on the game thread.
static SDK::UWorld* MpOwningWorld(SDK::UObject* Obj) {
SDK::UObject* Cur = Obj;
for (int Depth = 0; Cur != nullptr && Depth < 8; ++Depth) {
if (!IsReadablePointer(Cur, 0x30)) return nullptr;
if (Cur->IsA(SDK::UWorld::StaticClass())) return static_cast<SDK::UWorld*>(Cur);
Cur = Cur->Outer;
}
return nullptr;
}
static void AfkExemptionTick() {
if (!Globals::AmServer) return; // server role only
// [2026-10-10 1.14.7 AFK] No local controller exists on any server role whose 0x03E07353 patch applied, and
// the AFK check (0x01CB8E50) runs only for local controllers, so this 5 s GObjects walk had no effect there.
// It still runs (unchanged) where the patch did not apply and a phantom local controller exists.
if (g_NoLocalPlayerApplied.load(std::memory_order_relaxed)) return;
if (!SDK::UObject::GObjects) return;
static uint64_t s_lastMs = 0;
const uint64_t NowMs = GetTickCount64();
if (s_lastMs != 0 && NowMs - s_lastMs < 5000) return; // throttle the GObjects walk
s_lastMs = NowMs;
SDK::UWorld* CurrentWorld = SDK::UWorld::GetWorld();
if (!CurrentWorld) return;
const int Count = SDK::UObject::GObjects->Num();
int Considered = 0;
int Changed = 0;
for (int i = 0; i < Count; i++) {
SDK::UObject* Obj = SDK::UObject::GObjects->GetByIndex(i);
if (!Obj || Obj->IsDefaultObject()) continue;
if (!Obj->IsA(SDK::AArchonPlayerController::StaticClass())) continue;
SDK::AArchonPlayerController* PC = static_cast<SDK::AArchonPlayerController*>(Obj);
if (!IsReadablePointer(PC, 0xC30)) continue; // covers bNoAFK at +0xC2C
if (PC->Player == nullptr) continue; // unattached controller
// [1.14.7 FIX 2026-10-05] Cover REMOTE players as well, not just the hub's own.
// [2026-10-10 correction] The remote-player part of this never worked: bNoAFK is not replicated and a
// remote player's AFK check runs on that player's own client (0x01CB8E50, local controllers only), where
// it only disconnects that client. Only the hub's own local controller could make the hub leave its map.
// The note below is kept as history.
//
// The previous version required a verified ULocalPlayer here, which silently excluded every remote
// player's controller - a remote player's APlayerController::Player is a UNetConnection, not a
// ULocalPlayer. Measured consequence, and it is the repeat cycle the operator is seeing:
// - the hub's phantom WAS exempt (bNoAFK set on it);
// - the remote player was NOT, so their AFK idle-out fired ~2 minutes into the session;
// - the hub left the map, its UDP listener went away, the client's connection died
// ("Host closed the connection" -> the error-exit popup);
// - the DLL watchdog then reaped the hub:
// [Watchdog/thread] our UDP port 8790 has been free for >45s after this hub had served -
// the AFK idle-out left it alive but unavailable
// [Watchdog/thread] persistent hub unavailable (port free) -> TerminateProcess(0)
// - the DeployServer respawned it, and the whole thing repeated.
// bNoAFK is a per-controller flag, and an exemption whose purpose is "the hub can no longer idle out"
// has to cover every controller in the world. The world and liveness checks below still apply.
if (MpOwningWorld(PC) != CurrentWorld) continue; // must belong to the CURRENT world
++Considered;
if (!PC->bNoAFK) {
PC->bNoAFK = true;
++Changed;
}
}
if (Changed > 0) {
MpLog("[AfkExemption] set bNoAFK on " + std::to_string(Changed) + " local controller(s) in the "
"current world (considered=" + std::to_string(Considered) + ") - the hub can no longer idle out");
}
}
/*
* [2026-10-10 1.14.7 AFK] Server-side AFK timeout, written into the replicated AArchonGameState values.
*
* The client kicks itself (0x01CB8E50, local controllers only) when its idle time exceeds
* GS AFKTimeout (+0x3F8) / AFKTimeoutFriendly (+0x3FC); 0 or less never kicks. Both replicate COND_InitialOnly
* (GetLifetimeReplicatedProps 0x01901D60), so the value must be in place BEFORE a client's GameState channel opens:
* - GameModeEmptyState writes it once a second (first call immediate). That runs only where EnableWatchdog is on:
* every launch with the full DeployServer-style argument list (hubs, dedicated hunts and the P2P player host, on
* any port; only the manual-test launch turns it off - see Init);
* - the K2_PostLogin branch of the server ProcessEvent hook writes it for every login, before that connection's
* first replication pass. (An older note here said this was the player host's only write path; it never was.)
* The GameMode copy (+0x468/+0x46C, read once by InitGameState 0x019086E0) is written too, so the two stay
* consistent. Both objects are taken from the world itself (GWorld->AuthorityGameMode +0x118, GameState +0x120),
* never from a cached pointer that may have outlived a map change. Same writes as the engine's own cheat
* UArchonCheatManager::AFKTimeout (0x018BE5C0). No hook, no byte patch.
*
* Values (read once, at listen, on the game thread; a change needs a restart and reaches clients that join after it):
* HUB_AFK_TIMEOUT_SECONDS.txt Ramsgate/Training (GameMode class name contains City / TrainingGrounds, the
* g_wdIsHub rule, MpHubRoleFromGameModeClass). Default 1200 s (native Ramsgate is 600 s).
* HUNT_AFK_TIMEOUT_SECONDS.txt every other server instance, and every player-hosted server - a Training hosted on a
* player's PC included (11 Oct). Default: native (no write).
* Integer N: negative = native; 0 = never kick (warned: idle players then hold hub slots indefinitely);
* 1..59 -> 60; above 604800 -> 604800; an unreadable value keeps the default.
* DISABLE_AFK_TIMEOUT_OVERRIDE.flag turns every write off (pure native).
*/
struct MpAfkSetting { int Seconds; const char* Source; }; // Seconds < 0 = native (no override)
static MpAfkSetting MpAfkSecondsFromFile(const wchar_t* Name, int Default) {
MpAfkSetting R{ Default, "default" };
wchar_t Path[MAX_PATH];
size_t DirLen = 0;
if (InterlockedCompareExchange(&g_MpExeDirState, 0, 0) == 2) {
DirLen = g_MpExeDirLenW;
for (size_t i = 0; i < DirLen; ++i) Path[i] = g_MpExeDirW[i];
} else {
DirLen = MpExeDirFromModuleW(Path);
}
size_t NameLen = 0;
while (Name[NameLen]) ++NameLen;
if (DirLen == 0 || DirLen + NameLen + 1 > MAX_PATH) return R;
for (size_t i = 0; i < NameLen; ++i) Path[DirLen + i] = Name[i];
Path[DirLen + NameLen] = L'\0';
std::ifstream File(Path);
if (!File.is_open()) return R;
long long Value = 0;
if (!(File >> Value)) { R.Source = "file unreadable -> default"; return R; }
if (Value < 0) { R.Seconds = -1; R.Source = "file (negative = native)"; return R; }
if (Value == 0) { R.Seconds = 0; R.Source = "file (0 = never kick)"; return R; }
if (Value < 60) { R.Seconds = 60; R.Source = "file (clamped to 60)"; return R; }
if (Value > 604800) { R.Seconds = 604800; R.Source = "file (clamped to 604800)"; return R; }
R.Seconds = static_cast<int>(Value);
R.Source = "file";
return R;
}
// Game thread only (filled at listen from StartEmptyWatchdogThread, or by the first MpApplyAfkTimeout call).
static bool g_MpAfkPrimed = false;
static bool g_MpAfkDisabled = false;
static MpAfkSetting g_MpAfkHub{ 1200, "default" };
static MpAfkSetting g_MpAfkHunt{ -1, "default" };
static std::string MpAfkSecondsText(const MpAfkSetting& S) {
return (S.Seconds < 0 ? std::string("native") : std::to_string(S.Seconds) + " s") + " (" + S.Source + ")";
}
static void MpAfkPrimeConfig() {
if (!Globals::AmServer || g_MpAfkPrimed) return;
g_MpAfkPrimed = true;
g_MpAfkDisabled = MpExeRelativeFlagPresent(L"DISABLE_AFK_TIMEOUT_OVERRIDE.flag");
g_MpAfkHub = MpAfkSecondsFromFile(L"HUB_AFK_TIMEOUT_SECONDS.txt", 1200);
g_MpAfkHunt = MpAfkSecondsFromFile(L"HUNT_AFK_TIMEOUT_SECONDS.txt", -1);
// [2026-10-13 realms hub] A hub the launcher started (MYSTICPARADOX_HOSTED_ROLE=HUB) takes its idle-kick timeout
// from MYSTICPARADOX_HUB_AFK_SECONDS when the launcher set it (the hook is -1 otherwise, always -1 on a dedicated
// server and in the build without the p2p module): no per-install text file in the host's game folder. The same
// floor as the file applies, so a value of 1..59 becomes 60; 0 = never kick. HUNT_AFK_TIMEOUT_SECONDS.txt is
// unchanged and DISABLE_AFK_TIMEOUT_OVERRIDE.flag still turns every write off.
bool HubFromLauncher = false;
if (Globals::AuthMode == Globals::ServerAuthMode::HuntCapability) {
const int LauncherAfk = P2PHooks::HubAfkSecondsOverride();
if (LauncherAfk >= 0) {
HubFromLauncher = true;
g_MpAfkHub = (LauncherAfk == 0) ? MpAfkSetting{ 0, "launcher role (0 = never kick)" }
: (LauncherAfk < 60) ? MpAfkSetting{ 60, "launcher role (clamped to 60)" }
: MpAfkSetting{ LauncherAfk, "launcher role" };
}
}
if (g_MpAfkDisabled) {
MpLog("[AfkTimeout] override DISABLED by DISABLE_AFK_TIMEOUT_OVERRIDE.flag; every role keeps the native timeout");
return;
}
MpLog("[AfkTimeout] config hub=" + MpAfkSecondsText(g_MpAfkHub) + " hunt=" + MpAfkSecondsText(g_MpAfkHunt));
if (g_MpAfkHub.Seconds == 0) {
MpLog(HubFromLauncher
? "[AfkTimeout] WARNING: MYSTICPARADOX_HUB_AFK_SECONDS = 0 -> hub players are never idle-kicked; idle "
"players can hold every hub slot indefinitely"
: "[AfkTimeout] WARNING: HUB_AFK_TIMEOUT_SECONDS.txt = 0 -> hub players are never idle-kicked; idle "
"players can hold every hub slot indefinitely");
}
if (g_MpAfkHunt.Seconds == 0) {
MpLog("[AfkTimeout] WARNING: HUNT_AFK_TIMEOUT_SECONDS.txt = 0 -> hunt players are never idle-kicked");
}
}
static bool MpAfkLiveObjectOf(SDK::UObject* Obj, SDK::UClass* Cls) {
return Obj && Cls && IsRegisteredLiveObject(Obj) && !Obj->IsDefaultObject() && Obj->Class
&& IsRegisteredLiveObject(Obj->Class) && Obj->IsA(Cls);
}
static std::string MpAfkFloatText(float V) {
char Buf[32];
sprintf_s(Buf, "%.0f", static_cast<double>(V));
return Buf;
}
/*
* [2026-10-11 1.14.7 P2P host] Hub role from the GameMode class name (the g_wdIsHub rule): 1 = persistent hub
* (Ramsgate BPGM_City_C, Training BPGM_ArchonTrainingGrounds_C), 0 = disposable. A persistent hub is never reaped and
* gets the hub AFK timeout, because the DeployServer owns its lifecycle. A player-hosted (HuntCapability) server has
* no such owner: the launcher starts it for one session, so a Training hosted on a player's PC classified as a hub
* was never reaped when empty (both reap paths require hub == 0) and kept idle players for 1200 s. Under HuntCapability
* every class is disposable (0): empty-reap and hunt AFK rules. Dedicated Ramsgate/Training are unchanged.
* KEEP_HOSTED_HUB_ROLE.flag reverts (read once). Game thread only (both callers are).
*
* [2026-10-13 realms hub] The one exception is a launcher-declared hub: the launcher that runs a realm's Ramsgate or
* Training on the host's PC starts it with MYSTICPARADOX_HOSTED_ROLE=HUB, and P2PHooks::HostedRoleIsHub() then makes
* the named hub class persistent (role 1: never reaped, hub AFK rule - the launcher's supervisor owns its lifecycle,
* as the DeployServer does for a dedicated hub). A launcher role HUNT (also the default when the variable is absent)
* keeps today's behaviour for every class. The flag stays a manual override that wins over a HUNT role. A HUB role
* on a class that is not a named hub changes nothing (it is a hunt server by its map), and says so once.
*/
static int MpHubRoleFromGameModeClass(const std::string& ClassName) {
const bool NamedHub = ClassName.find("City") != std::string::npos
|| ClassName.find("TrainingGrounds") != std::string::npos;
if (Globals::AuthMode == Globals::ServerAuthMode::HuntCapability && !NamedHub && P2PHooks::HostedRoleIsHub()) {
static bool s_loggedUnnamed = false;
if (!s_loggedUnnamed) {
s_loggedUnnamed = true;
MpLog("[HubRole] WARNING: " + ClassName + " -> disposable although the launcher role is HUB: the GameMode "
"class is not a named hub class (City / TrainingGrounds), so empty-reap and hunt AFK rules apply");
}
}
if (!NamedHub || Globals::AuthMode != Globals::ServerAuthMode::HuntCapability) return NamedHub ? 1 : 0;
static const bool KeepHubRole = MpExeRelativeFlagPresent(L"KEEP_HOSTED_HUB_ROLE.flag");
static const bool LauncherHub = P2PHooks::HostedRoleIsHub();
static bool s_logged = false;
if (!s_logged) {
s_logged = true;
MpLog("[HubRole] " + ClassName + " -> " + (LauncherHub
? std::string("persistent hub (launcher role): never reaped, hub AFK rule")
: KeepHubRole
? std::string("persistent hub (KEEP_HOSTED_HUB_ROLE.flag, launcher role HUNT): never reaped, hub AFK rule")
: std::string("disposable (launcher role HUNT): reaped when empty, hunt AFK rules; "
"KEEP_HOSTED_HUB_ROLE.flag reverts")));
}
return (LauncherHub || KeepHubRole) ? 1 : 0;
}
// Game thread only. HintGameMode is used only to classify the role while the world has no GameMode yet; it is never
// a write target. Cheap: pointer checks, two IsA, no GObjects walk, no exceptions.
static void MpApplyAfkTimeout(SDK::UObject* HintGameMode, const char* Why) {
if (!Globals::AmServer || !SDK::UObject::GObjects) return;
MpAfkPrimeConfig();
if (g_MpAfkDisabled) return;
SDK::UWorld* World = SDK::UWorld::GetWorld();
if (!World || !IsReadablePointer(World, 0x128)) return;
const uintptr_t W = reinterpret_cast<uintptr_t>(World);
SDK::UObject* GM = *reinterpret_cast<SDK::UObject**>(W + 0x118); // UWorld::AuthorityGameMode
SDK::UObject* GS = *reinterpret_cast<SDK::UObject**>(W + 0x120); // UWorld::GameState
if (!MpAfkLiveObjectOf(GS, SDK::AArchonGameState::StaticClass()) || !IsReadablePointer(GS, 0x400)) return;
const bool GmOk = MpAfkLiveObjectOf(GM, SDK::AArchonGameMode::StaticClass()) && IsReadablePointer(GM, 0x470);
static void* s_loggedGs = nullptr; // one line per GameState instance
const bool LogThis = GS != s_loggedGs;
int Hub = g_wdIsHub.load(std::memory_order_relaxed);
std::string ClassName;
if (Hub < 0 || LogThis) {
SDK::UObject* ClassSource = GmOk ? GM
: (MpAfkLiveObjectOf(HintGameMode, SDK::AArchonGameMode::StaticClass()) ? HintGameMode : nullptr);
if (ClassSource) ClassName = ClassSource->Class->GetName();
if (Hub < 0 && !ClassName.empty()) {
Hub = MpHubRoleFromGameModeClass(ClassName); // [2026-10-11] HuntCapability -> 0 (see there)
}
}
if (Hub < 0) return; // role unknown: write nothing yet
const MpAfkSetting& S = Hub ? g_MpAfkHub : g_MpAfkHunt;
auto* AGS = static_cast<SDK::AArchonGameState*>(GS);
auto* AGM = GmOk ? static_cast<SDK::AArchonGameMode*>(GM) : nullptr;
const float GsA = AGS->AFKTimeout, GsB = AGS->AFKTimeoutFriendly;
const float GmA = AGM ? AGM->AFKTimeout : -1.0f, GmB = AGM ? AGM->AFKTimeoutFriendly : -1.0f;
std::string Applied;
if (S.Seconds < 0) {
Applied = "no override (native)";
} else {
const float V = static_cast<float>(S.Seconds);
if (GsA != V || GsB != V) { AGS->AFKTimeout = V; AGS->AFKTimeoutFriendly = V; }
if (AGM && (GmA != V || GmB != V)) { AGM->AFKTimeout = V; AGM->AFKTimeoutFriendly = V; }
Applied = "applied " + std::to_string(S.Seconds) + " s" + (S.Seconds == 0 ? " (never kick)" : "");
}
if (LogThis) {
s_loggedGs = GS;
MpLog(std::string("[AfkTimeout] role=") + (Hub ? "hub" : "hunt")
+ " class=" + (ClassName.empty() ? std::string("?") : ClassName)
+ " before GM=" + (AGM ? MpAfkFloatText(GmA) + "/" + MpAfkFloatText(GmB) : std::string("?"))
+ " GS=" + MpAfkFloatText(GsA) + "/" + MpAfkFloatText(GsB)
+ " -> " + Applied + " (why=" + Why + ") GS=" + MpPtr(GS));
}
}
// GAME-THREAD ONLY (walks GObjects / reads FName). Returns: 1 empty, 0 occupied, -1 unknown.
static int GameModeEmptyState() {
if (!SDK::UObject::GObjects) return -1;
if (!g_CachedGameMode || !IsReadablePointer(g_CachedGameMode, 0x2E0)) {
uint64_t Now = GetTickCount64();
if (Now - g_LastGameModeSearchMs < 3000) return -1; // throttle the GObjects walk
g_LastGameModeSearchMs = Now;
g_CachedGameMode = nullptr;
const int Count = SDK::UObject::GObjects->Num();
for (int i = 0; i < Count; i++) {
SDK::UObject* Obj = SDK::UObject::GObjects->GetByIndex(i);
if (!Obj || Obj->IsDefaultObject()) continue;
if (Obj->IsA(SDK::AArchonGameMode::StaticClass())) { g_CachedGameMode = Obj; break; }
}
}
if (!g_CachedGameMode || !IsReadablePointer(g_CachedGameMode, 0x2E0)) return -1;
// Publish for the background watchdog thread (which does NO GObjects/name work of its own).
g_wdGameModePtr.store(g_CachedGameMode, std::memory_order_relaxed);
if (g_wdIsHub.load(std::memory_order_relaxed) < 0) {
SDK::UObject* Cls = *reinterpret_cast<SDK::UObject**>(reinterpret_cast<uintptr_t>(g_CachedGameMode) + 0x10);
if (Cls && IsReadablePointer(Cls, 0x20)) {
std::string Name = Cls->GetName(); // safe: game thread
// [2026-10-11] HuntCapability -> 0 (see MpHubRoleFromGameModeClass).
g_wdIsHub.store(MpHubRoleFromGameModeClass(Name), std::memory_order_relaxed);
}
}
uintptr_t Gm = reinterpret_cast<uintptr_t>(g_CachedGameMode);
int32_t NumPlayers = *reinterpret_cast<int32_t*>(Gm + 0x02D0);
int32_t NumTravelling = *reinterpret_cast<int32_t*>(Gm + 0x02DC);
// [1.14.7 WATCHDOG HEALTH FIX] Publish the counts (and their timestamp) for the watchdog thread so it
// never has to dereference a cached GameMode pointer that may have outlived the map.
g_wdNumPlayers.store(NumPlayers, std::memory_order_relaxed);
g_wdNumTravelling.store(NumTravelling, std::memory_order_relaxed);
g_wdCountsStampMs.store(GetTickCount64(), std::memory_order_relaxed);
g_wdEverHadCounts.store(true, std::memory_order_relaxed);
// Heartbeat: we are on the game thread and it is alive right now. Plain stores only.
g_wdLastTickMs.store(GetTickCount64(), std::memory_order_relaxed);
g_wdGameThreadId.store(GetCurrentThreadId(), std::memory_order_relaxed);
EnsureBleedoutDuration(g_CachedGameMode);
BleedoutWatchTick();
AfkExemptionTick(); // returns at once unless a phantom local controller exists (no-local-player patch failed)
{
// [2026-10-10 1.14.7 AFK] Once a second, first call immediate; see MpApplyAfkTimeout.
static uint64_t s_afkMs = 0;
const uint64_t AfkNow = GetTickCount64();
if (s_afkMs == 0 || AfkNow - s_afkMs >= 1000) {
s_afkMs = AfkNow;
MpApplyAfkTimeout(g_CachedGameMode, "tick");
}
}
if (NumPlayers > 0) g_wdSawPlayer.store(true, std::memory_order_relaxed);
return (NumPlayers <= 0 && NumTravelling <= 0) ? 1 : 0;
}
/*
* [2026-10-10 1.14.7 RAM] Server memory: backup OOM pool release, hub idle working-set trim, [Perf]/[Memory] lines.
* All three run on the empty-watchdog thread (never the game thread) and change no frame, tick or replication setting.
*
* Backup OOM pool (static proof, 1.14.7 exe): FGenericPlatformMemory::SetupMemoryPools (0x02525890, called from
* FWindowsPlatformMemory::Init 0x0266ACD0) does VirtualAlloc(NULL, 0x2000000, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE)
* through the thunk 0x02661D90 and stores the block in the global 0x06DACBD0. Its only reader is
* FGenericPlatformMemory::OnOutOfMemory (0x02529C00), which skips a null pool (test rcx,rcx; je at 0x02529C6B) and
* otherwise frees it with VirtualFree(p, 0, MEM_RELEASE) before its fatal log. The pages are never touched, so the
* saving is 32 MB of commit charge (pagefile budget) per server, not resident RAM. Trade-off: on a real out-of-memory
* death the engine's (and our crash logging's) last 32 MB of headroom is gone. KEEP_OOM_POOL.flag keeps the pool.
*/
static const char* MpVerifyBackupOomPoolSites(); // defined after CwVerifySite (.pdata entry + bytes)
static bool MpProcessMemory(PROCESS_MEMORY_COUNTERS_EX& Out) {
Out = {};
Out.cb = sizeof(Out);
return GetProcessMemoryInfo(GetCurrentProcess(), reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&Out), sizeof(Out)) != FALSE;
}
static std::string MpMiBText(SIZE_T Bytes) {
char Buf[32];
sprintf_s(Buf, "%.1f", static_cast<double>(Bytes) / (1024.0 * 1024.0));
return Buf;
}
static void MpReleaseBackupOomPool() {
if (const char* Why = MpVerifyBackupOomPoolSites()) {
MpLog(std::string("[Memory] backup OOM pool left alone: ") + Why + " (SetupMemoryPools +0x2525890 / "
"OnOutOfMemory +0x2529C00 not as verified on 1.14.7)");
return;
}
void* volatile* Slot = reinterpret_cast<void* volatile*>(Globals::BaseAddress + 0x06DACBD0);
if (!IsReadablePointer(const_cast<void**>(Slot), sizeof(void*))) {
MpLog("[Memory] backup OOM pool left alone: global +0x6DACBD0 unreadable");
return;
}
void* Block = *Slot;
MEMORY_BASIC_INFORMATION Mi{};
if (!Block || !VirtualQuery(Block, &Mi, sizeof(Mi)) || Mi.BaseAddress != Block || Mi.AllocationBase != Block
|| Mi.RegionSize != 0x2000000 || Mi.State != MEM_COMMIT || Mi.Type != MEM_PRIVATE || Mi.Protect != PAGE_READWRITE) {
MpLog("[Memory] backup OOM pool left alone: block=" + MpPtr(Block) + " is not the expected 32 MB private "
"read-write allocation (region=" + std::to_string(static_cast<unsigned long long>(Mi.RegionSize)) + ")");
return;
}
PROCESS_MEMORY_COUNTERS_EX Before{}, After{};
const bool HaveBefore = MpProcessMemory(Before);
if (InterlockedCompareExchangePointer(reinterpret_cast<PVOID volatile*>(Slot), nullptr, Block) != Block) {
MpLog("[Memory] backup OOM pool left alone: the global changed while releasing it");
return;
}
if (!VirtualFree(Block, 0, MEM_RELEASE)) {
const DWORD Err = GetLastError();
InterlockedCompareExchangePointer(reinterpret_cast<PVOID volatile*>(Slot), Block, nullptr); // still valid: put back
MpLog("[Memory] backup OOM pool kept: VirtualFree failed err=" + std::to_string(Err));
return;
}
const bool HaveAfter = MpProcessMemory(After);
MpLog("[Memory] released the engine's 32 MB backup out-of-memory pool block=" + MpPtr(Block)
+ " (33554432 bytes of commit; OnOutOfMemory skips a null pool): private commit "
+ (HaveBefore ? MpMiBText(Before.PrivateUsage) : std::string("?")) + " -> "
+ (HaveAfter ? MpMiBText(After.PrivateUsage) : std::string("?")) + " MiB, working set "
+ (HaveBefore ? MpMiBText(Before.WorkingSetSize) : std::string("?")) + " -> "
+ (HaveAfter ? MpMiBText(After.WorkingSetSize) : std::string("?")) + " MiB");
}
// Hub idle working-set trim. Called only from the watchdog thread, so trims never overlap. SetProcessWorkingSetSizeEx
// takes the process working-set lock, so the game thread can wait on it (page faults, VirtualQuery) for the trim's
// duration; that is accepted only because no remote client has been connected for 60 s. A client whose handshake starts
// inside that window gets a short pre-login hitch on its loading screen, and the next arrival soft-faults pages back in.
static void MpTrimWorkingSet(uint64_t IdleMs) {
PROCESS_MEMORY_COUNTERS_EX Before{}, After{};
const bool HaveBefore = MpProcessMemory(Before);
LARGE_INTEGER T0{}, T1{}, F{};
QueryPerformanceFrequency(&F);
QueryPerformanceCounter(&T0);
const BOOL Ok = SetProcessWorkingSetSizeEx(GetCurrentProcess(), static_cast<SIZE_T>(-1), static_cast<SIZE_T>(-1), 0);
const DWORD Err = Ok ? 0 : GetLastError();
QueryPerformanceCounter(&T1);
const bool HaveAfter = MpProcessMemory(After);
char Ms[32];
sprintf_s(Ms, "%.1f", F.QuadPart > 0 ? static_cast<double>(T1.QuadPart - T0.QuadPart) * 1000.0 / static_cast<double>(F.QuadPart) : -1.0);
MpLog("[WorkingSet] trimmed (hub idle: 0 remote connections for " + std::to_string(IdleMs / 1000) + " s): working set "
+ (HaveBefore ? MpMiBText(Before.WorkingSetSize) : std::string("?")) + " -> "
+ (HaveAfter ? MpMiBText(After.WorkingSetSize) : std::string("?")) + " MiB, private commit "
+ (HaveAfter ? MpMiBText(After.PrivateUsage) : std::string("?")) + " MiB, " + Ms + " ms"
+ (Ok ? std::string() : " (SetProcessWorkingSetSizeEx failed err=" + std::to_string(Err) + ")"));
}
// [2026-10-11 1.14.7 O0] avg/max of one phase in ms ("-" when it never ran in the window); drains the counters.
static std::string MpPerfPhaseText(MpPerfPhase& Phase, int64_t Freq) {
const uint64_t Calls = Phase.Calls.exchange(0, std::memory_order_relaxed);
const uint64_t Sum = Phase.Sum.exchange(0, std::memory_order_relaxed);
const uint64_t Max = Phase.Max.exchange(0, std::memory_order_relaxed);
if (Freq <= 0 || Calls == 0) return "-";
char Buf[64];
const double MaxMs = static_cast<double>(Max) * 1000.0 / static_cast<double>(Freq);
const double AvgMs = static_cast<double>(Sum) * 1000.0 / static_cast<double>(Freq) / static_cast<double>(Calls);
sprintf_s(Buf, "%.3f/%.2f", AvgMs, MaxMs);
return Buf;
}
// [2026-10-11 1.14.7 O0] [PerfThreads]: CPU per thread since the previous call (watchdog thread only). Each thread's
// start address comes from NtQueryInformationThread(ThreadQuerySetWin32StartAddress = 9) once per thread id and is
// printed as module+RVA. Every handle is closed; nothing here touches game objects. Prime = take the baseline only.
static void MpLogThreadCpu(bool Prime) {
using NtQueryInformationThreadFn = LONG(NTAPI*)(HANDLE, ULONG, PVOID, ULONG, PULONG);
using GetThreadDescriptionFn = HRESULT(WINAPI*)(HANDLE, PWSTR*);
static NtQueryInformationThreadFn s_ntQit = reinterpret_cast<NtQueryInformationThreadFn>(
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQueryInformationThread"));
static GetThreadDescriptionFn s_getDesc = reinterpret_cast<GetThreadDescriptionFn>(
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "GetThreadDescription"));
struct Prev { uint64_t Cpu100ns; uintptr_t Start; std::string Name; uint64_t SeenIter; };
static std::unordered_map<DWORD, Prev> s_prev; // watchdog thread only
static uint64_t s_iter = 0;
static uint64_t s_lastMs = 0;
++s_iter;
const uint64_t NowMs = GetTickCount64();
const uint64_t WindowMs = s_lastMs ? NowMs - s_lastMs : 0;
s_lastMs = NowMs;
HANDLE Snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (Snap == INVALID_HANDLE_VALUE) {
if (!Prime) MpLog("[PerfThreads] thread snapshot failed err=" + std::to_string(GetLastError()));
return;
}
struct Row { DWORD Tid; uint64_t Delta; uintptr_t Start; const std::string* Name; bool New; };
std::vector<Row> Rows;
uint64_t Total = 0;
int Threads = 0;
THREADENTRY32 Te{};
Te.dwSize = sizeof(Te);
const DWORD MyPid = GetCurrentProcessId();
if (Thread32First(Snap, &Te)) {
do {
if (Te.th32OwnerProcessID != MyPid) continue;
++Threads;
HANDLE Th = OpenThread(THREAD_QUERY_INFORMATION, FALSE, Te.th32ThreadID);
if (!Th) Th = OpenThread(THREAD_QUERY_LIMITED_INFORMATION, FALSE, Te.th32ThreadID);
if (!Th) continue;
FILETIME C{}, E{}, K{}, U{};
const bool HaveTimes = GetThreadTimes(Th, &C, &E, &K, &U) != FALSE;
auto It = s_prev.find(Te.th32ThreadID);
const bool New = It == s_prev.end();
if (New) {
Prev P{ 0, 0, std::string(), 0 };
if (s_ntQit) {
PVOID StartAddr = nullptr;
if (s_ntQit(Th, 9, &StartAddr, sizeof(StartAddr), nullptr) >= 0) P.Start = reinterpret_cast<uintptr_t>(StartAddr);
}
if (s_getDesc) {
PWSTR Desc = nullptr;
if (SUCCEEDED(s_getDesc(Th, &Desc)) && Desc) {
for (const wchar_t* c = Desc; *c && P.Name.size() < 40; ++c) P.Name.push_back(*c < 0x80 ? static_cast<char>(*c) : '?');
LocalFree(Desc);
}
}
It = s_prev.emplace(Te.th32ThreadID, P).first;
}
CloseHandle(Th);
if (!HaveTimes) continue;
const uint64_t Cpu = ((static_cast<uint64_t>(K.dwHighDateTime) << 32) | K.dwLowDateTime)
+ ((static_cast<uint64_t>(U.dwHighDateTime) << 32) | U.dwLowDateTime);
const uint64_t Delta = (!New && Cpu >= It->second.Cpu100ns) ? Cpu - It->second.Cpu100ns : (New ? Cpu : 0);
It->second.Cpu100ns = Cpu;
It->second.SeenIter = s_iter;
Total += Delta;
Rows.push_back({ Te.th32ThreadID, Delta, It->second.Start, &It->second.Name, New });
} while (Thread32Next(Snap, &Te));
}
CloseHandle(Snap);
for (auto It = s_prev.begin(); It != s_prev.end();) { // forget exited threads
if (It->second.SeenIter != s_iter) It = s_prev.erase(It);
else ++It;
}
if (Prime || WindowMs == 0) return;
const double Denom = static_cast<double>(WindowMs) * 10000.0; // 100 ns units in the window
std::sort(Rows.begin(), Rows.end(), [](const Row& A, const Row& B) { return A.Delta > B.Delta; });
const DWORD GameTid = g_wdGameThreadId.load(std::memory_order_relaxed);
auto RowText = [&](const Row& R) {
char Pct[32];
sprintf_s(Pct, "%.1f%%", static_cast<double>(R.Delta) * 100.0 / Denom);
return std::string(" tid=") + std::to_string(R.Tid) + (R.Tid == GameTid ? "(GAME)" : "") + " " + Pct
+ " start=" + MpAddress(reinterpret_cast<void*>(R.Start))
+ (R.Name && !R.Name->empty() ? " \"" + *R.Name + "\"" : std::string())
+ (R.New ? " (new: since thread start)" : "");
};
char Head[160];
sprintf_s(Head, "[PerfThreads] %.1fs: process %.1f%% of one CPU over %d threads; top:",
static_cast<double>(WindowMs) / 1000.0, static_cast<double>(Total) * 100.0 / Denom, Threads);
std::string Line = Head;
bool GameShown = false;
for (size_t i = 0; i < Rows.size() && i < 6; ++i) {
Line += (i ? ";" : "") + RowText(Rows[i]);
if (Rows[i].Tid == GameTid) GameShown = true;
}
if (!GameShown && GameTid) {
for (const Row& R : Rows) {
if (R.Tid == GameTid) { Line += " | game:" + RowText(R); break; }
}
}
MpLog(Line);
}
// [2026-10-11 1.14.7 O0] [PerfDetail] and [PerfNet] (watchdog thread). Frames = the window's frame count.
static void MpLogPerfDetail(uint64_t Frames, double WindowSec, int64_t Freq) {
const std::string Engine = MpPerfPhaseText(g_perfPhaseEngine, Freq);
const std::string Graph = MpPerfPhaseText(g_perfPhaseGraph, Freq);
const std::string Dispatch = MpPerfPhaseText(g_perfPhaseDispatch, Freq);
const std::string Flush = MpPerfPhaseText(g_perfPhaseFlush, Freq);
const uint64_t RepCalls = g_perfRepActorCalls.exchange(0, std::memory_order_relaxed);
const uint64_t RepBits = g_perfRepActorBits.exchange(0, std::memory_order_relaxed);
const double PerFrame = Frames ? 1.0 / static_cast<double>(Frames) : 0.0;
// Replicated bits per connection: drain, then ask the game thread to clear the keys for the next window.
struct ConnBits { void* Conn; uint64_t Bits; uint64_t Calls; };
std::vector<ConnBits> Conns;
for (MpRepBitsSlot& S : g_repBitsSlots) {
void* C = S.Conn.load(std::memory_order_relaxed);
const uint64_t B = S.Bits.exchange(0, std::memory_order_relaxed);
const uint64_t N = S.Calls.exchange(0, std::memory_order_relaxed);
if (C && (B || N)) Conns.push_back({ C, B, N });
}
const uint64_t BitsOverflow = g_repBitsOverflow.exchange(0, std::memory_order_relaxed);
g_repBitsResetRequested.store(true, std::memory_order_relaxed);
std::sort(Conns.begin(), Conns.end(), [](const ConnBits& A, const ConnBits& B) { return A.Bits > B.Bits; });
const uint64_t GraphStamp = g_perfGraphSampleMs.load(std::memory_order_relaxed);
const uint64_t NowMs = GetTickCount64();
const bool GraphFresh = GraphStamp != 0 && NowMs >= GraphStamp && NowMs - GraphStamp <= 10000;
char Buf[512];
sprintf_s(Buf, "[PerfDetail] %.1fs avg/max ms per call: engine tick %s, graph drive %s, manual dispatch %s, "
"manual flush %s; ReplicateActor %.1f calls/frame, %.0f kbit/s replicated (%.0f bits/frame)",
WindowSec, Engine.c_str(), Graph.c_str(), Dispatch.c_str(), Flush.c_str(),
static_cast<double>(RepCalls) * PerFrame,
WindowSec > 0 ? static_cast<double>(RepBits) / WindowSec / 1000.0 : 0.0,
static_cast<double>(RepBits) * PerFrame);
std::string Line = Buf;
if (GraphFresh) {
sprintf_s(Buf, "; graph: connections %d, global actors %d, actor-info per connection sum %d max %d; "
"client connections %d, open channels sum %d max %d",
g_perfGraphConns.load(std::memory_order_relaxed), g_perfGraphGlobalActors.load(std::memory_order_relaxed),
g_perfGraphActorInfoSum.load(std::memory_order_relaxed), g_perfGraphActorInfoMax.load(std::memory_order_relaxed),
g_perfClientConns.load(std::memory_order_relaxed), g_perfOpenChannelsSum.load(std::memory_order_relaxed),
g_perfOpenChannelsMax.load(std::memory_order_relaxed));
Line += Buf;
} else {
Line += "; graph: not sampled (no replication graph)";
}
if (!Conns.empty()) {
Line += "; top connections by replicated kbit/s:";
for (size_t i = 0; i < Conns.size() && i < 4; ++i) {
sprintf_s(Buf, " %s=%.1f(%llu calls)", MpPtr(Conns[i].Conn).c_str(),
WindowSec > 0 ? static_cast<double>(Conns[i].Bits) / WindowSec / 1000.0 : 0.0,
static_cast<unsigned long long>(Conns[i].Calls));
Line += Buf;
}
if (Conns.size() > 4) Line += " +" + std::to_string(Conns.size() - 4) + " more";
}
if (BitsOverflow) Line += "; untracked connections " + std::to_string(BitsOverflow) + " bits";
MpLog(Line);
if (!g_wireCountOn) return;
struct Peer { uint64_t Key; uint64_t Bytes; uint64_t Packets; };
std::vector<Peer> Peers;
uint64_t Unattributed = 0;
for (MpWireSlot& S : g_wireSlots) {
const uint64_t K = S.Key.load(std::memory_order_relaxed);
const uint64_t B = S.Bytes.exchange(0, std::memory_order_relaxed);
const uint64_t P = S.Packets.exchange(0, std::memory_order_relaxed);
if (K == 0) { Unattributed += B; continue; }
if (B == 0 && P == 0) {
uint64_t Expected = K;
S.Key.compare_exchange_strong(Expected, 0, std::memory_order_relaxed); // idle for a window: release
continue;
}
bool Merged = false; // a released slot can leave one peer in two slots for a window
for (Peer& Existing : Peers) {
if (Existing.Key == K) { Existing.Bytes += B; Existing.Packets += P; Merged = true; break; }
}
if (!Merged) Peers.push_back({ K, B, P });
}
const uint64_t TotalBytes = g_wireBytesTotal.exchange(0, std::memory_order_relaxed);
const uint64_t TotalPackets = g_wirePacketsTotal.exchange(0, std::memory_order_relaxed);
const uint64_t OtherBytes = g_wireBytesOther.exchange(0, std::memory_order_relaxed);
std::sort(Peers.begin(), Peers.end(), [](const Peer& A, const Peer& B) { return A.Bytes > B.Bytes; });
const double Sec = WindowSec > 0 ? WindowSec : 60.0;
sprintf_s(Buf, "[PerfNet] %.1fs: sent %.1f kB/s in %.0f packets/s to %zu peers",
Sec, static_cast<double>(TotalBytes) / Sec / 1000.0, static_cast<double>(TotalPackets) / Sec, Peers.size());
Line = Buf;
if (!Peers.empty()) {
Line += "; top:";
for (size_t i = 0; i < Peers.size() && i < 8; ++i) {
sprintf_s(Buf, " %s=%.2f kB/s (%.0f pkt/s)", MpWireKeyText(Peers[i].Key).c_str(),
static_cast<double>(Peers[i].Bytes) / Sec / 1000.0, static_cast<double>(Peers[i].Packets) / Sec);
Line += Buf;
}
}
if (OtherBytes) Line += "; other " + std::to_string(OtherBytes) + " bytes";
if (Unattributed) Line += "; unattributed " + std::to_string(Unattributed) + " bytes";
MpLog(Line);
}
// Once a minute from the watchdog thread. Reads only atomics the game thread published and this process's counters.
static void MpLogPerfAndMemory(bool FirstWindow) {
if (MpProcessDetaching()) return;
const int64_t Freq = g_perfQpcFreq.load(std::memory_order_relaxed);
const uint64_t Frames = g_perfFrames.exchange(0, std::memory_order_relaxed);
const uint64_t Sum = g_perfFrameTicksSum.exchange(0, std::memory_order_relaxed);
const uint64_t Max = g_perfFrameTicksMax.exchange(0, std::memory_order_relaxed);
const uint64_t Hitches = g_perfHitches.exchange(0, std::memory_order_relaxed);
const uint64_t EngCalls = g_perfEngineCalls.exchange(0, std::memory_order_relaxed);
const uint64_t EngSum = g_perfEngineTicksSum.exchange(0, std::memory_order_relaxed);
const uint64_t NowMs = GetTickCount64();
const uint64_t ConnStamp = g_wdConnStampMs.load(std::memory_order_relaxed);
const int32_t Conn = g_wdConnCount.load(std::memory_order_relaxed);
const bool ConnFresh = ConnStamp != 0 && NowMs >= ConnStamp && NowMs - ConnStamp <= MP_WD_COUNTS_MAX_AGE_MS;
const std::string ConnText = std::to_string(Conn) + (ConnFresh ? "" : " (stale)");
const char* Mark = FirstWindow ? " (first window since listen)" : "";
char Line[320];
double WindowSec = 0.0; // [2026-10-11 1.14.7 O0] for [PerfDetail]/[PerfNet] rates
if (Freq > 0 && Frames > 0 && Sum > 0) {
const double SumSec = static_cast<double>(Sum) / static_cast<double>(Freq);
WindowSec = SumSec;
const double AvgMs = SumSec * 1000.0 / static_cast<double>(Frames);
const double MaxMs = static_cast<double>(Max) * 1000.0 / static_cast<double>(Freq);
const double EngMs = EngCalls ? static_cast<double>(EngSum) * 1000.0 / static_cast<double>(Freq) / static_cast<double>(EngCalls) : 0.0;
sprintf_s(Line, "[Perf] %.1fs: %llu frames (%.1f/s), frame avg %.2f ms / max %.1f ms, hitches>100ms %llu, "
"engine tick %.2f ms/frame", SumSec, static_cast<unsigned long long>(Frames),
static_cast<double>(Frames) / SumSec, AvgMs, MaxMs, static_cast<unsigned long long>(Hitches), EngMs);
} else {
sprintf_s(Line, "[Perf] no frames measured in this window (game thread not ticking?)");
}
MpLog(std::string(Line) + ", connections " + ConnText + ", hub " + std::to_string(g_wdIsHub.load(std::memory_order_relaxed)) + Mark);
PROCESS_MEMORY_COUNTERS_EX Pm{};
if (MpProcessMemory(Pm)) {
static DWORD s_lastFaults = 0; // watchdog thread only
const std::string Faults = s_lastFaults ? "+" + std::to_string(Pm.PageFaultCount - s_lastFaults) + " since last line"
: std::to_string(Pm.PageFaultCount) + " total";
s_lastFaults = Pm.PageFaultCount ? Pm.PageFaultCount : 1;
MpLog("[Memory] connections " + ConnText + ", working set " + MpMiBText(Pm.WorkingSetSize) + " MiB (peak "
+ MpMiBText(Pm.PeakWorkingSetSize) + "), private commit " + MpMiBText(Pm.PrivateUsage) + " MiB, page faults "
+ Faults + Mark);
}
// [2026-10-11 1.14.7 O0] Same cadence: phase timings, replication/graph sizes, wire bytes, threads.
if (MpPerfDetailEnabled()) {
static uint64_t s_lastDetailMs = 0; // watchdog thread only; fallback window when no frames were measured
const uint64_t DetailNow = GetTickCount64();
if (WindowSec <= 0.0 && s_lastDetailMs) WindowSec = static_cast<double>(DetailNow - s_lastDetailMs) / 1000.0;
s_lastDetailMs = DetailNow;
MpLogPerfDetail(Frames, WindowSec, Freq);
MpLogThreadCpu(false);
}
}
// [2026-07-18] Background empty-server watchdog thread.
// The tick-based empty-exit (in the GameEngineTick hook below) rides the game/net tick — but that tick
// STOPS firing once the last player disconnects from a hunt. Observed in the logs: an empty
// BPGM_ArchonExploration_C hunt logged its final "[Lifecycle:ConnEdge 1->0 NumPlayers=0]" and then went
// totally silent for 30-90 min, never hitting the 50s exit, so the finished hunt kept holding a
// concurrent-server slot until the DeployServer cap refused new hunts (reported: "max capacity reached").
// This dedicated thread polls the AUTHORITATIVE GameMode player count on its own timer, independent of
// the engine loop, so an empty instance always self-terminates and the DeployServer reclaims its port +
// RAM. Safety (never kill an active/loading hunt): only CONTINUOUS emptiness counts — no remote client
// connection && NumTravelling==0 for 50s straight after the last client left (5 min if none ever arrived);
// any occupied OR unknown (-1, GameMode or NetDriver not resolved yet, stale sample) reading resets the
// timer. NumPlayers was unusable while the phantom local player kept it at 1 (removed in 0.2.110); the remote
// connection count stays the signal (see g_wdConnCount).
//
// SCOPE: only DISPOSABLE instances self-terminate. The two persistent hubs the DeployServer keeps alive
// — ramsgate (BPGM_City_C) and the training grounds (BPGM_ArchonTrainingGrounds_C) — are EXEMPT, so they
// stay up continuously (no churn, and no risk of the hub vanishing while a player is traveling back to
// it). Everything else (hunts/exploration, escalation, trials, etc.) is reapable when empty.
static std::atomic<bool> g_emptyWatchdogStarted{ false };
extern bool EnableWatchdog; // defined later (global watchdog master switch, default true)
static void StartEmptyWatchdogThread() {
bool expected = false;
if (!g_emptyWatchdogStarted.compare_exchange_strong(expected, true)) return; // start once
// [2026-10-10 1.14.7] Called on the GAME thread at listen (both listen paths in GameEngineTickHook): reset the
// [Perf] counters so the first window starts here, and read the AFK value files before any client can log in.
MpPerfResetCounters();
MpAfkPrimeConfig();
std::thread([] {
float emptySec = 0.0f;
const float POLL_SEC = 2.0f;
uint64_t iter = 0;
bool hangSampled = false; // one-shot [HangSuspect] capture per freeze episode (reset when ticks resume)
MpLog("[Watchdog/thread] started (poll 2s; reaps a disposable instance with no client connection for "
+ std::to_string(static_cast<int>(MP_WD_EMPTY_REAP_SEC)) + "s after its last client left, or 5 min if none ever arrived)");
// [2026-10-10 1.14.7 RAM] Read once at thread start (= listen). See the RAM block above StartEmptyWatchdogThread.
const uint64_t wdStartMs = GetTickCount64();
const bool keepOomPool = MpExeRelativeFlagPresent(L"KEEP_OOM_POOL.flag");
const bool perfLog = !MpExeRelativeFlagPresent(L"DISABLE_PERF_LOG.flag");
// Working-set trim: DeployServer instances only (never the P2P player host), and below only confirmed hubs.
const bool trimAllowed = Globals::AuthMode == Globals::ServerAuthMode::GlobalGameserver
&& !MpExeRelativeFlagPresent(L"KEEP_WORKING_SET.flag");
bool oomPoolDone = false;
bool perfFirstWindow = true;
uint64_t idleSinceMs = 0; // start of the current zero-connection period (0 = not idle)
bool idleTrimmed = false; // one trim per idle period; re-armed once a client connects
uint64_t trimmedAtMs = 0;
MpLog(std::string("[Memory] backup OOM pool: ") + (keepOomPool ? "KEPT (KEEP_OOM_POOL.flag)" : "release at listen+30 s")
+ "; hub idle working-set trim: "
+ (trimAllowed ? "ON (confirmed hubs only, after 60 s with 0 remote connections, once per idle period)"
: (Globals::AuthMode == Globals::ServerAuthMode::GlobalGameserver ? "OFF (KEEP_WORKING_SET.flag)"
: "OFF (not a DeployServer instance)"))
+ "; [Perf]/[Memory] lines: " + (perfLog ? "every 60 s" : "OFF (DISABLE_PERF_LOG.flag)")
+ "; [PerfDetail]/[PerfNet]/[PerfThreads]: "
+ (perfLog && MpPerfDetailEnabled() ? "every 60 s" : (perfLog ? "OFF (NO_PERF_DETAIL.flag)" : "OFF")));
// [2026-10-11 1.14.7 O0] Baseline for the first [PerfThreads] window (CPU since listen, not since process start).
if (perfLog && MpPerfDetailEnabled()) MpLogThreadCpu(true);
for (;;) {
Sleep(static_cast<DWORD>(POLL_SEC * 1000.0f));
++iter;
if (MpProcessDetaching()) continue; // never touch the CRT or log after DLL_PROCESS_DETACH
// [2026-10-10 1.14.7 RAM] Every server role (before the EnableWatchdog gate on purpose): the pool release
// once, 30 s after listen (its pages are untouched, so the connection state does not matter), and the
// [Perf]/[Memory] lines every 30 polls (~60 s; the rate is computed from measured frame time).
if (!oomPoolDone && GetTickCount64() - wdStartMs >= 30000) {
oomPoolDone = true;
if (!keepOomPool) MpReleaseBackupOomPool();
}
if (perfLog && (iter % 30) == 0) {
MpLogPerfAndMemory(perfFirstWindow);
perfFirstWindow = false;
}
if (!EnableWatchdog) { emptySec = 0.0f; continue; }
// CRASH-SAFE: this thread does NO GObjects walk and NO IsA/GetName calls (doing that off the
// game thread raced actor spawn/destroy and faulted inside the game). It only reads values the
// GAME THREAD published (gamemode ptr + hub flag) plus plain int fields on that stable ptr.
void* gm = g_wdGameModePtr.load(std::memory_order_relaxed);
int hub = g_wdIsHub.load(std::memory_order_relaxed);
bool empty = false;
int32_t numPlayers = -1, numTravelling = -1, connCount = -1;
bool countsUsable = false;
{
// [1.14.7 WATCHDOG HEALTH FIX] Values published by the game thread only - this thread never
// dereferences the GameMode pointer. A sample must be FRESH and PLAUSIBLE to count as health;
// anything else is treated as unknown and can never trigger a reap.
const uint64_t Stamp = g_wdCountsStampMs.load(std::memory_order_relaxed);
const uint64_t NowMs = GetTickCount64();
const bool Fresh = Stamp != 0 && NowMs >= Stamp && (NowMs - Stamp) <= MP_WD_COUNTS_MAX_AGE_MS;
const int32_t P = g_wdNumPlayers.load(std::memory_order_relaxed);
const int32_t T = g_wdNumTravelling.load(std::memory_order_relaxed);
const bool Plausible = P >= 0 && P <= MP_WD_MAX_PLAUSIBLE_PLAYERS
&& T >= 0 && T <= MP_WD_MAX_PLAUSIBLE_PLAYERS;
// [1.14.7 EMPTY-HUNT FIX] Emptiness is "no remote client connection and nobody travelling
// in", not NumPlayers (the phantom keeps that at 1). The connection count follows the same
// fresh-and-plausible rule; without it the instance is never considered empty.
const uint64_t ConnStamp = g_wdConnStampMs.load(std::memory_order_relaxed);
const int32_t C = g_wdConnCount.load(std::memory_order_relaxed);
const bool ConnUsable = ConnStamp != 0 && NowMs >= ConnStamp
&& (NowMs - ConnStamp) <= MP_WD_COUNTS_MAX_AGE_MS
&& C >= 0 && C <= MP_WD_MAX_PLAUSIBLE_PLAYERS;
if (Fresh && Plausible) {
numPlayers = P;
numTravelling = T;
countsUsable = true;
if (ConnUsable) {
connCount = C;
empty = (connCount <= 0 && numTravelling <= 0);
}
}
else if (!Plausible && Stamp != 0) {
static std::atomic<int> s_implausibleLogged{ 0 };
if (s_implausibleLogged.fetch_add(1, std::memory_order_relaxed) < 4) {
MpLog("[Watchdog/thread] ignoring implausible player counts (players=" + std::to_string(P)
+ " travelling=" + std::to_string(T) + " max_plausible="
+ std::to_string(MP_WD_MAX_PLAUSIBLE_PLAYERS)
+ ") - treated as unknown, no reap");
}
}
}
// [1.14.7 loader state dump trigger 2026-10-03] This loop runs every ~2s for the life of the
// process, so it is a reliable clock. The readiness hook only runs during the load window (its
// 25s check never got a chance), and the server-tick placement never fired either - hence the
// dump is triggered here, once, 25s after this thread starts.
{
static std::atomic<int> s_loaderDumpState{ 0 };
static ULONGLONG s_watchdogStart = 0;
if (s_loaderDumpState.load() == 0) {
s_watchdogStart = GetTickCount64();
s_loaderDumpState.store(1);
} else if (s_loaderDumpState.load() == 1 && GetTickCount64() - s_watchdogStart > 25000) {
s_loaderDumpState.store(2);
DumpLoadManagerEntries("t+25s (watchdog)");
}
}
// Heartbeat ~every 30s, and on every poll while counting down to a reap, so the log proves the
// thread is live and shows EXACTLY why it does/doesn't reap (gm published? hub class? counts?).
const bool hadRemoteClient = g_wdHadRemoteClient.load(std::memory_order_relaxed);
const float emptyLimitSec = hadRemoteClient ? MP_WD_EMPTY_REAP_SEC : MP_WD_NEVER_JOINED_REAP_SEC;
// While counting down, log every 10 s instead of every poll (the never-joined window is 5 min).
if ((iter % 15) == 0 || (empty && hub == 0 && (iter % 5) == 0)) {
MpLog("[Watchdog/thread] gm=" + MpPtr(gm) + " hub=" + std::to_string(hub)
+ " numPlayers=" + std::to_string(numPlayers) + " numTravelling=" + std::to_string(numTravelling)
+ " connCount=" + std::to_string(connCount) + " hadClient=" + std::to_string(hadRemoteClient ? 1 : 0)
+ " countsUsable=" + std::to_string(countsUsable ? 1 : 0)
+ " empty=" + std::to_string(empty ? 1 : 0) + " emptySec=" + std::to_string(emptySec)
+ "/" + std::to_string(static_cast<int>(emptyLimitSec)));
}
// [2026-10-10 1.14.7 RAM] Hub idle working-set trim. Only a CONFIRMED hub (hub == 1; never -1/unknown, never a
// hunt, never the P2P host), only after ZERO remote connections continuously for 60 s (usable counts, a
// connection sample at most 1 s old, nobody seamless-travelling; any connection seen by the game thread
// restarts the period), at most once per idle period (re-armed when a client connects). Runs here, on the
// watchdog thread. The server cannot see a hard-travel client before its handshake creates the connection,
// so an arrival can still coincide with a trim: performance only (see MpTrimWorkingSet).
{
const uint64_t NowMs = GetTickCount64();
const uint64_t LastConnMs = g_wdLastRemoteConnMs.load(std::memory_order_relaxed);
if (idleTrimmed && LastConnMs > trimmedAtMs) idleTrimmed = false; // a client connected since: re-arm
const uint64_t ConnStamp = g_wdConnStampMs.load(std::memory_order_relaxed);
const bool ConnFresh = ConnStamp != 0 && NowMs >= ConnStamp && NowMs - ConnStamp <= 1000;
const bool IdleNow = hub == 1 && countsUsable && ConnFresh && connCount == 0 && numTravelling == 0
&& g_wdConnCount.load(std::memory_order_relaxed) == 0;
if (!IdleNow) {
idleSinceMs = 0;
} else {
if (idleSinceMs == 0 || LastConnMs >= idleSinceMs) idleSinceMs = NowMs; // continuous zero only
if (trimAllowed && !idleTrimmed && NowMs - idleSinceMs >= 60000) {
idleTrimmed = true;
trimmedAtMs = NowMs;
MpTrimWorkingSet(NowMs - idleSinceMs);
}
}
}
// [1.14.7 AVAILABILITY FIX 2026-10-04] A persistent hub that leaves its map becomes ALIVE BUT
// UNAVAILABLE: the game's own AFK timeout kicks the phantom player, the match state goes to
// LeavingMap and the re-listen fails ("LoadMap: failed to Listen"), so UDP 8789/8790 stops being
// bound while the process keeps running. The DeployServer watchdog only checks process existence
// (kill(pid, 0)), so nothing ever restarts it. Report that state by exiting: the existing
// CleanupServer + restart path then brings the hub back, which is what makes it available again.
// Guarded so a hub that is still loading (never yet had counts) is never touched.
{
static ULONGLONG s_portFreeSince = 0;
// Only a hub that has actually served is considered (never one still loading), and the port
// must stay free for a full 45 s so a transient re-listen is not mistaken for an idle-out.
if (!g_wdEverHadCounts.load(std::memory_order_relaxed)) {
s_portFreeSince = 0;
} else if (MpOurUdpPortIsFree()) {
const ULONGLONG NowMs = GetTickCount64();
if (s_portFreeSince == 0) s_portFreeSince = NowMs;
if (NowMs - s_portFreeSince > 45000) {
MpLog("[Watchdog/thread] our UDP port " + std::to_string(Globals::Port)
+ " has been free for >45s after this hub had served - the AFK idle-out left it "
"alive but unavailable; exiting so the DeployServer restarts it");
MpReapExit("persistent hub unavailable (port free)");
}
} else {
s_portFreeSince = 0;
}
}
// Reap only a CONFIRMED disposable instance (hub==0) empty 50s continuous after its last client
// left, or 5 min if no client ever arrived (so a slow first load is never cut short). hub==1
// (persistent ramsgate/training) or hub==-1 (not yet classified by the game thread) is never
// reaped here.
if (empty && hub == 0) {
emptySec += POLL_SEC;
if (emptySec >= emptyLimitSec) {
const std::string reapReason = hadRemoteClient
? "disposable instance empty " + std::to_string(static_cast<int>(MP_WD_EMPTY_REAP_SEC))
+ "s continuous after its last client left"
: std::string("disposable instance never joined by a client within 5 min");
MpReapExit(reapReason.c_str());
}
} else {
emptySec = 0.0f;
}
// [2026-07-18 STALE-TICK REAP] Fallback for the ghost-connection deadlock described at
// g_wdLastTickMs. Independent of NumPlayers on purpose: that counter is exactly what gets
// stuck. A disposable instance that has stopped ticking for STALE_TICK_MS is finished —
// nothing is replicating to anyone, so no live hunt can be harmed by reaping it.
// Guards: disposable only (hub==0), and only after the instance actually hosted a player,
// so a server that has not reached its first tick is never mistaken for a dead one.
if (hub == 0 && g_wdSawPlayer.load(std::memory_order_relaxed)) {
const uint64_t STALE_TICK_MS = 180000; // 3 min; > any legitimate hitch or load spike
uint64_t lastTick = g_wdLastTickMs.load(std::memory_order_relaxed);
if (lastTick != 0) {
uint64_t sinceTick = GetTickCount64() - lastTick;
// EARLY DIAGNOSTIC (no reap): the game thread hasn't ticked for ~20s while a hunt is
// live. Almost certainly the freeze the player sees. Sample it NOW (once) so we capture
// the blocking location even if the operator kills the box before the 180s reap.
if (sinceTick >= 20000 && !hangSampled) {
hangSampled = true;
MpLog("[HangSuspect] game thread not ticked for " + std::to_string(sinceTick / 1000)
+ "s while numPlayers=" + std::to_string(numPlayers)
+ " — sampling (NOT reaping yet; reap at " + std::to_string(180) + "s)");
MpLogHungGameThread();
}
if (sinceTick < 10000) hangSampled = false; // ticks resumed -> re-arm for next episode
if (sinceTick >= STALE_TICK_MS) {
MpLog("[Watchdog/thread] disposable instance tick STALE for "
+ std::to_string(sinceTick / 1000) + "s (numPlayers=" + std::to_string(numPlayers)
+ " — hung game thread or ghost connection) -> reap");
// Sample WHERE it is hung once more before killing it (fresh chain), then reap.
MpLogHungGameThread();
MpReapExit("tick stale");
}
// Surface the countdown once it is clearly abnormal, so the log shows this path arming.
if (sinceTick >= 60000 && (iter % 15) == 0) {
MpLog("[Watchdog/thread] tick stale " + std::to_string(sinceTick / 1000)
+ "s (numPlayers=" + std::to_string(numPlayers) + "); reaping at "
+ std::to_string(STALE_TICK_MS / 1000) + "s");
}
}
}
}
}).detach();
}
void* OrigGetDefaultMap = nullptr;
void* OrigNetModeHook = nullptr;
void* OrigInternalNetModeHook = nullptr;
void* OrigWorldNetModeHook = nullptr;
void* OrigIsLevelInitForActor = nullptr;
void* OrigIsNetReady = nullptr;
bool IsNetReadyHook();
// [1.14.7 FIX 2026-10-05] MUST be int, not char. Returning char compiles to `mov al, 1; ret`, and the
// native caller reads all of EAX - the upper bits survive from whatever held the NetDriver pointer, so the
// live value was measured as 0xEE47C001 instead of 1. Unreal then classifies the server incorrectly when it
// routes ServerDraftBounty, which can send that RPC remotely or absorb it instead of executing locally -
// the automatic-draft stall (iteration 1, initialization flags set, FinishSetup never reached).
int InternalNetModeHook(void* a1);
int WorldNetModeHook(void* a1);
bool IsLevelInitForActorHook(void* a1, char a2);
// [2026-07-16] Instance player cap (HUB_MAX_PLAYERS setting). AArchonGameMode::InitGame
// defaults "max player count" to 1 when the map URL carries no ?MaxPlayers option, which
// strands a 2nd player at the loading screen until a slot frees (verified in the repro:
// port8790 InitGame "max player count is 1"; the 2nd player only activates after the 1st
// leaves). We inject ?MaxPlayers into the map URL (see GetGameDefaultMap) so InitGame sets up
// all related session/game-mode state for N players at once. This is the CAP only;
// ExpectedPlayerCount is synchronized separately from the concrete launch roster below.
// Default 4 (Dauntless party size). Override WITHOUT a rebuild by writing an integer into
// <game-exe-dir>\HUB_MAX_PLAYERS.txt. Read once.
// [2026-10-13 realms hub] A hub the launcher started (MYSTICPARADOX_HOSTED_ROLE=HUB) sets its cap with
// MYSTICPARADOX_MAX_PLAYERS (1..64, validated by the p2p module): P2PHooks::MaxPlayersOverride() is consulted first
// and then HUB_MAX_PLAYERS.txt is not read at all (no per-install text file in the host's game folder). The hook is 0
// ("none") on a dedicated server, for a HUNT role and in the build without the p2p module, so those read the file as
// before. A player-hosted server logs the source once.
static int HubMaxPlayers() {
static int Cached = -1;
if (Cached < 0) {
const int LauncherMax = P2PHooks::MaxPlayersOverride();
if (LauncherMax >= 1 && LauncherMax <= 64) {
Cached = LauncherMax;
MpLog("[HubRole] max players " + std::to_string(Cached) + " (launcher MYSTICPARADOX_MAX_PLAYERS)");
return Cached;
}
Cached = 4; // default party size
const char* MaxPlayersSource = "default";
wchar_t ExePath[MAX_PATH];
DWORD n = GetModuleFileNameW(nullptr, ExePath, MAX_PATH);
if (n > 0 && n < MAX_PATH) {
for (int i = (int)n - 1; i >= 0; --i) {
if (ExePath[i] == L'\\' || ExePath[i] == L'/') { ExePath[i + 1] = L'\0'; break; }
}
std::wstring Path = std::wstring(ExePath) + L"HUB_MAX_PLAYERS.txt";
std::ifstream File(Path);
if (File.is_open()) {
int Value = 0;
if ((File >> Value) && Value >= 1 && Value <= 64) { Cached = Value; MaxPlayersSource = "HUB_MAX_PLAYERS.txt"; }
}
}
if (Globals::AuthMode == Globals::ServerAuthMode::HuntCapability) {
MpLog("[HubRole] max players " + std::to_string(Cached) + " (" + MaxPlayersSource + ")");
}
}
return Cached;
}
/*
* [2026-10-11 1.14.7 P2P host] MAP_URL_LISTEN.flag (the engine-listener A/B path) is refused on a player-hosted
* (HuntCapability) server. That path lets the engine create the GameNetDriver during map load and then only waits for
* UWorld::NetDriver, so it skips the player-host checks the manual Networking::Listen makes: PrepareListen (loopback
* bind address, and the engine MULTIHOME check) and the readiness report whose failure fences the host
* (P2PHooks::OnListenFailed). Both readers - GetGameDefaultMap (?listen) and GameEngineTickHook (wait for the engine
* listener) - ask here, so the host's map URL never carries ?listen and the host always listens manually. DeployServer
* instances keep the flag's behaviour. No revert flag (the refused path has no player-host safety).
*/
static bool MpMapUrlListenEnabled() {
if (!MpExeRelativeFlagPresent(L"MAP_URL_LISTEN.flag")) return false;
if (Globals::AuthMode != Globals::ServerAuthMode::HuntCapability) return true;
static volatile LONG s_logged = 0;
if (InterlockedCompareExchange(&s_logged, 1, 0) == 0) {
MpLog("[EngineListen] MAP_URL_LISTEN.flag REFUSED on this player-hosted server: the engine-listener path skips "
"PrepareListen and the readiness fence; listening through Networking::Listen instead");
}
return false;
}
// Count the concrete `playerUid:playerHuntId` entries supplied by DeployServer/PlayerHost in
// Globals::ExpectedPlayerString. Persistent hubs use NO_EXPECTED_PLAYERS and intentionally return
// zero. A malformed launch roster also returns zero rather than making the load manager wait for a
// player that can never satisfy the entry. The runtime cap remains authoritative.
static int LaunchExpectedPlayerCount() {
if (!Globals::ExpectedPlayerString) return 0;
const std::wstring Roster(Globals::ExpectedPlayerString);
if (Roster.empty() || Roster.find(L"NO_EXPECTED_PLAYERS") != std::wstring::npos) return 0;
int Count = 0;
size_t Start = 0;
while (Start < Roster.size()) {
const size_t End = Roster.find(L',', Start);
const size_t Length = (End == std::wstring::npos ? Roster.size() : End) - Start;
const std::wstring Entry = Roster.substr(Start, Length);
const size_t Colon = Entry.find(L':');
if (Colon == std::wstring::npos || Colon == 0 || Colon + 1 >= Entry.size()) return 0;
++Count;
if (End == std::wstring::npos) break;
Start = End + 1;
}
return std::clamp(Count, 0, HubMaxPlayers());
}
FString* GetGameDefaultMap(FString* a1) {
// [2026-07-19] Per-call entry/return traces are VERBOSE_DIAG-only; this hook fires constantly.
if (VerboseDiag()) MpLog("[GetGameDefaultMap] entry out=" + MpPtr(a1));
FString* Ret = reinterpret_cast<FString*(*)(FString*)>(OrigGetDefaultMap)(a1);
if (VerboseDiag()) MpLog("[GetGameDefaultMap] original returned " + MpPtr(Ret));
std::wstring FinalURL(Globals::MapPath);
std::wstring BehemothPath(Globals::BehemothPath);
if (!BehemothPath.contains(L"NO_BEHEMOTH")) {
FinalURL += std::wstring(L"?MonsterClass=");
FinalURL += std::wstring(BehemothPath);
}
std::wstring MatchmakerHuntId(Globals::MatchmakerHuntId);
if (!MatchmakerHuntId.contains(L"NO_MM_HUNTID")) {
FinalURL += std::wstring(L"?HuntId=");
FinalURL += std::wstring(MatchmakerHuntId);
}
std::wstring ExpectedPlayers(Globals::ExpectedPlayerString);
if (!ExpectedPlayers.contains(L"NO_EXPECTED_PLAYERS")) {
FinalURL += std::wstring(L"?PlayerHuntIds=");
FinalURL += std::wstring(ExpectedPlayers);
}
// [2026-07-16] Always advertise the instance player cap. Without ?MaxPlayers the native
// AArchonGameMode::InitGame defaults "max player count" to 1 and gates every 2nd joiner at
// loading until a slot frees. Supplying the URL option lets InitGame initialize session/
// game-mode state for N players consistently. ExpectedPlayerCount is synchronized from the
// concrete launch roster in the server tick.
FinalURL += std::wstring(L"?MaxPlayers=");
FinalURL += std::to_wstring(HubMaxPlayers());
// [1.14.7 2026-10-03] With MAP_URL_LISTEN.flag present, advertise the map as a LISTEN server so the
// ENGINE builds the GameNetDriver during map load (the standard UE dedicated-server path,
// UWorld::Listen) instead of this runtime creating one by hand. The manual path is what leaves the
// world context invalid on 1.14.7: "WorldContext requested with invalid context object" appears 28
// times per run with it and 0 times without it.
// [2026-10-11] Never on a player-hosted server (MpMapUrlListenEnabled).
if (MpMapUrlListenEnabled()) {
FinalURL += std::wstring(L"?listen");
}
// [1.12.0] Write the URL into the engine-OWNED FString buffer instead of aliasing FinalURL's
// std::wstring storage (see EngineRealloc note above). Ret->Data was MOVED out of the engine's
// config map string by the original GetGameDefaultMap, so it is a genuine FMallocBinned2 block
// we can grow; the engine can then realloc/free it safely. realloc(nullptr) degrades to malloc.
{
struct RawFString { wchar_t* Data; int Num; int Max; };
RawFString* Raw = reinterpret_cast<RawFString*>(Ret);
const int Count = static_cast<int>(FinalURL.size()) + 1; // include null terminator
// [1.14.7 2026-10-03 LOG-FLOOD FIX] This hook fires on EVERY map-name query, and the original
// rebuilds the engine config string each time, so our rewrite always needs a fresh buffer. The
// per-call traces below therefore fired hundreds of times per second and grew the runtime log to
// 735 MB in one session - the flood the user sees as "infinite logging", and needless I/O on the
// hot path of a server that restarts. They are now verbose-only, exactly like the entry/return
// traces above; the behaviour is unchanged.
if (VerboseDiag()) {
MpLog("[GetGameDefaultMap] setting map URL (" + std::to_string(FinalURL.size()) + " chars): " + MpNarrow(FinalURL));
MpLog("[GetGameDefaultMap] before write Ret=" + MpPtr(Ret)
+ " Data=" + MpPtr(Raw->Data)
+ " Num=" + std::to_string(Raw->Num)
+ " Max=" + std::to_string(Raw->Max)
+ " Count=" + std::to_string(Count));
}
if (Raw->Max < Count || Raw->Data == nullptr) {
if (VerboseDiag()) MpLog("[GetGameDefaultMap] realloc needed");
Raw->Data = static_cast<wchar_t*>(EngineRealloc(Raw->Data, static_cast<size_t>(Count) * sizeof(wchar_t)));
if (VerboseDiag()) MpLog("[GetGameDefaultMap] realloc returned Data=" + MpPtr(Raw->Data));
}
else if (VerboseDiag()) {
MpLog("[GetGameDefaultMap] reusing existing buffer");
}
if (!Raw->Data) {
MpLog("[GetGameDefaultMap] null Data after allocation; returning original result");
return Ret;
}
wmemcpy(Raw->Data, FinalURL.c_str(), static_cast<size_t>(Count));
Raw->Num = Count;
if (Raw->Max < Count) {
Raw->Max = Count;
}
if (VerboseDiag()) {
MpLog("[GetGameDefaultMap] written OK Data=" + MpPtr(Raw->Data)
+ " Num=" + std::to_string(Raw->Num)
+ " Max=" + std::to_string(Raw->Max));
}
}
//*Ret = L"/Game/Maps/islands/1705/dia_moss_triforce?MonsterClass=/Game/Monsters/mcrollin/mcbeaver_tutorial_bp.mcbeaver_tutorial_bp_C";
//*Ret = L"/Game/Maps/ramsgate/ramsgate_01_persistent";
//*Ret = L"/Game/Maps/islands/dojo/training_dojo_persistent";
//*Ret = L"/Game/Maps/islands/1705/dia_moss_triforce?MonsterClass=/Game/Monsters/mcrollin/mcbeaver_tutorial_bp.mcbeaver_tutorial_bp_C";
return Ret;
}
void* OrigGetCommandLine = nullptr;
const wchar_t* GetCommandLineHook() {
// [Phase 0L 2026-07-12] Added LogNetPackageMap Verbose to capture FNetGUIDCache::SupportsObject /
// guid assignment/export for the interaction-invisibility root-cause (why the client can't resolve
// ClientInteractionCompleted's object-ref target for non-replicated NPCs like npc_bosun).
// The dedicated process has no official EAC launcher/session. Keep the
// engine's own opt-out on the overridden command line so the EAC module
// does not repeatedly try to create an IGameClient on every game server.
// -warp: force the WARP software D3D11 device so this client-as-server binary boots on a
// GPU-less VPS. -nullrhi alone does NOT suppress D3D11 device creation on the Shipping client
// build, so without a GPU the engine still hits "A D3D11-compatible GPU is required". WARP
// presents an FL11.0 device in software; the headless server renders nothing, so overhead is
// negligible. Server-only: this hook is installed in InitServerHooks, so clients are unaffected.
return L"Dauntless-Win64-Shipping.exe -server -unattended -nullrhi -warp -nosound -EpicPortal -NoEAC -RepDriverDisable -LogCmds=\"LogNet VeryVerbose, LogNetPackageMap Verbose\"";
}
// ============================================================================
// [WARP force 2026-07-15] Boot the client-as-server binary on a GPU-less VPS.
// The engine RHI selector (FUN_1432d9ca0) calls the D3D11RHI module IsSupported();
// with no Feature-Level-11 GPU it returns false and hard-exits with the popup
// "A D3D11-compatible GPU (Feature Level 11.0, Shader Model 5.0) is required to run
// the engine". -nullrhi/-warp do NOT bypass this on the Shipping client build.
// ONLY when the sentinel MP_FORCE_WARP.flag exists next to the game .exe (exe-relative
// so it works on the VPS, unlike the hardcoded debug path) do we:
// 1) byte-patch the IsSupported() failure branch (JNZ 0x75 -> JMP 0xEB at
// RVA 0x0032DAA1B) so RHI selection always accepts D3D11, and
// 2) force every D3D11CreateDevice[AndSwapChain] to the WARP software adapter
// (DriverType = D3D_DRIVER_TYPE_WARP(5), pAdapter = NULL) so the real device is
// created in software with zero GPUs.
// Reversible + default-OFF: absent flag = stock behaviour (inert). Delete the flag
// (or restore the previous DLL, SHA 4FEA5FA6) to revert without a rebuild.
// ============================================================================
static bool MpForceWarpEnabled() {
static int Cached = -1;
if (Cached < 0) {
Cached = 0;
wchar_t ExePath[MAX_PATH];
DWORD n = GetModuleFileNameW(nullptr, ExePath, MAX_PATH);
if (n > 0 && n < MAX_PATH) {
for (int i = (int)n - 1; i >= 0; --i) {
if (ExePath[i] == L'\\' || ExePath[i] == L'/') { ExePath[i + 1] = L'\0'; break; }
}
std::wstring FlagPath = std::wstring(ExePath) + L"MP_FORCE_WARP.flag";
if (GetFileAttributesW(FlagPath.c_str()) != INVALID_FILE_ATTRIBUTES) { Cached = 1; }
}
}
return Cached == 1;
}
// Case-insensitive "d3d11" substring test (avoids <cwctype>).
static bool MpPathHasD3D11(const wchar_t* Path) {
if (!Path) { return false; }
const wchar_t* Needle = L"d3d11";
for (const wchar_t* P = Path; *P; ++P) {
int i = 0;
for (; Needle[i] && P[i]; ++i) {
wchar_t a = P[i];
if (a >= L'A' && a <= L'Z') { a = (wchar_t)(a + 32); }
if (a != Needle[i]) { break; }
}
if (!Needle[i]) { return true; }
}
return false;
}
// Opaque signatures (avoid pulling in d3d11.h). D3D_DRIVER_TYPE_WARP = 5.
using PFN_D3D11CreateDevice = long (WINAPI*)(void*, unsigned int, HMODULE, unsigned int,
const void*, unsigned int, unsigned int, void**, void*, void**);
using PFN_D3D11CreateDeviceAndSwapChain = long (WINAPI*)(void*, unsigned int, HMODULE, unsigned int,
const void*, unsigned int, unsigned int, const void*, void**, void**, void*, void**);
static PFN_D3D11CreateDevice OrigD3D11CreateDevice = nullptr;
static PFN_D3D11CreateDeviceAndSwapChain OrigD3D11CreateDeviceAndSwapChain = nullptr;
static volatile LONG g_D3D11Hooked = 0;
static long WINAPI D3D11CreateDeviceHook(void* pAdapter, unsigned int DriverType, HMODULE Software,
unsigned int Flags, const void* pFeatureLevels, unsigned int FeatureLevels, unsigned int SDKVersion,
void** ppDevice, void* pFeatureLevel, void** ppImmediateContext) {
(void)pAdapter; (void)DriverType;
// WARP is always present in d3d11.dll and needs no DXGI hardware enumeration, so this
// succeeds with zero GPUs. Null adapter is REQUIRED with D3D_DRIVER_TYPE_WARP.
return OrigD3D11CreateDevice(nullptr, 5, Software, Flags, pFeatureLevels, FeatureLevels,
SDKVersion, ppDevice, pFeatureLevel, ppImmediateContext);
}
static long WINAPI D3D11CreateDeviceAndSwapChainHook(void* pAdapter, unsigned int DriverType, HMODULE Software,
unsigned int Flags, const void* pFeatureLevels, unsigned int FeatureLevels, unsigned int SDKVersion,
const void* pSwapChainDesc, void** ppSwapChain, void** ppDevice, void* pFeatureLevel, void** ppImmediateContext) {
(void)pAdapter; (void)DriverType;
return OrigD3D11CreateDeviceAndSwapChain(nullptr, 5, Software, Flags, pFeatureLevels, FeatureLevels,
SDKVersion, pSwapChainDesc, ppSwapChain, ppDevice, pFeatureLevel, ppImmediateContext);
}
// Install the D3D11CreateDevice[AndSwapChain] -> WARP hooks once, given a loaded d3d11.dll.
// No MpLog here: this can run under the loader lock while d3d11.dll is loading.
static void MpTryHookD3D11(HMODULE Mod) {
if (!Mod) { return; }
if (InterlockedCompareExchange(&g_D3D11Hooked, 1, 0) != 0) { return; }
void* pCreate = reinterpret_cast<void*>(GetProcAddress(Mod, "D3D11CreateDevice"));
void* pCreateSC = reinterpret_cast<void*>(GetProcAddress(Mod, "D3D11CreateDeviceAndSwapChain"));
if (pCreate) {
MH_CreateHook(pCreate, reinterpret_cast<void*>(D3D11CreateDeviceHook),
reinterpret_cast<void**>(&OrigD3D11CreateDevice));
MH_EnableHook(pCreate);
}
if (pCreateSC) {
MH_CreateHook(pCreateSC, reinterpret_cast<void*>(D3D11CreateDeviceAndSwapChainHook),
reinterpret_cast<void**>(&OrigD3D11CreateDeviceAndSwapChain));
MH_EnableHook(pCreateSC);
}
}
// Catch d3d11.dll being loaded (UE4 loads it dynamically during RHI init), then hook it.
using PFN_LoadLibraryExW = HMODULE (WINAPI*)(const wchar_t*, HANDLE, DWORD);
static PFN_LoadLibraryExW OrigLoadLibraryExW = nullptr;
static HMODULE WINAPI LoadLibraryExWHook(const wchar_t* lpLibFileName, HANDLE hFile, DWORD dwFlags) {
HMODULE Result = OrigLoadLibraryExW(lpLibFileName, hFile, dwFlags);
if (Result && !g_D3D11Hooked && MpPathHasD3D11(lpLibFileName)) {
MpTryHookD3D11(Result);
}
return Result;
}
static bool MpPatchBytesVerified(uintptr_t Rva, const uint8_t* Expected, const uint8_t* Replacement,
size_t Length, const char* Tag);
// Called from InitServerHooks only when MP_FORCE_WARP.flag is present (see header comment).
static void InstallWarpForceHooks() {
MpLog("[WARP] installing (MP_FORCE_WARP.flag present)");
// 1) Bypass the IsSupported() failure branch: JNZ (0x75) -> JMP (0xEB).
// [1.14.7 2026-10-08] The site was the 1.12 RVA 0x032DAA1B, guarded only by "byte == 0x75" - on 1.14.7
// that could have flipped an unrelated jne. tools/rva-port-audit.py maps it to 0x034AB73F (verified; the
// same `call [rdx+0x40]; test al,al; jne` sequence). The patch now checks `test al,al; jne` and fails closed.
{
static const uint8_t Expected[3] = { 0x84, 0xC0, 0x75 };
static const uint8_t Replacement[3] = { 0x84, 0xC0, 0xEB };
const bool Ok = MpPatchBytesVerified(0x034AB73D, Expected, Replacement, 3, "WARP IsSupported gate");
MpLog(std::string("[WARP] gate patch @+0x34AB73F ") + (Ok ? "(OK)"
: "(NOT applied: bytes differ - DLL likely loaded AFTER engine PreInit, or a different exe)"));
}
// 2) Force WARP for the real device creation: hook LoadLibraryExW to catch d3d11.dll,
// and hook it directly if it is already resident (static import).
HMODULE Kernel = GetModuleHandleW(L"kernel32.dll");
void* pLLEW = Kernel ? reinterpret_cast<void*>(GetProcAddress(Kernel, "LoadLibraryExW")) : nullptr;
if (pLLEW) {
MH_CreateHook(pLLEW, reinterpret_cast<void*>(LoadLibraryExWHook),
reinterpret_cast<void**>(&OrigLoadLibraryExW));
MH_EnableHook(pLLEW);
}
HMODULE Existing = GetModuleHandleW(L"d3d11.dll");
MpLog(std::string("[WARP] LoadLibraryExW hook=") + (pLLEW ? "on" : "FAIL")
+ " d3d11Resident=" + (Existing ? "yes(hook now)" : "no(hook on load)"));
if (Existing) { MpTryHookD3D11(Existing); }
}
void* OrigServerBootCrash = nullptr;
// Dauntless 1.14.7 / CL 647472: ServerBootCrash is the UE4 HandleError/error-history writer
// at RVA 0x02659A20. Ghidra confirms the fatal text is passed as a UTF-16 buffer at RVA
// 0x06E14AD0 (FUN_142659a20 passes &DAT_146e14ad0 to FUN_1425f0c70 as ushort*). This is a
// character buffer, not a pointer slot. Read it directly and with a strict bound because this
// hook runs on an error path and must not introduce a second fault while reporting the first.
void ServerBootCrash(void* param_1) {
const wchar_t* ErrorHist = reinterpret_cast<const wchar_t*>(Globals::BaseAddress + 0x06E14AD0);
std::string Category = "ServerBootCrash";
std::wstring SafeErrorHist;
SafeErrorHist.reserve(256);
if (!ErrorHist) {
SafeErrorHist = L"(null error history buffer)";
} else {
for (size_t i = 0; i < 1023; ++i) {
wchar_t Ch = L'\0';
SIZE_T BytesRead = 0;
if (!ReadProcessMemory(GetCurrentProcess(), ErrorHist + i, &Ch, sizeof(Ch), &BytesRead)
|| BytesRead != sizeof(Ch)) {
SafeErrorHist += L" <unreadable/truncated>";
break;
}
if (Ch == L'\0') break;
SafeErrorHist.push_back(Ch);
}
}
std::string MsgNarrow = MpNarrow(SafeErrorHist);
MpLog("[ServerBootCrash] FATAL (native handler preserved) - category=" + Category + " msg=" + MsgNarrow);
// [1.14.7 root-cause fix 2026-10-03] Do NOT swallow the engine's fatal path.
// Ghidra (1.14.7 program, CL 647472) decompiles +0x02659A20 as
// void FUN_142659a20(longlong *param_1) -- one pointer argument, void return
// It is FWindowsErrorOutputDevice::HandleError: it sets GIsCriticalError, emits
// "=== Critical error: ===" into the error history buffer at +0x06E14AD0, flushes the
// error device and lets UE request exit. The previous implementation logged and
// returned WITHOUT calling it, so the engine kept running after a fatal error and then
// corrupted unrelated state (NetDriver cleanup arrays; particle module hashes) before
// dying later with an unrelated message -- which is why the true first failure was
// never attributable. Preserve native handling: log first, then delegate.
if (OrigServerBootCrash != nullptr) {
reinterpret_cast<void(*)(void*)>(OrigServerBootCrash)(param_1);
}
return;
}
// [move2.md 2026-07-10] Forward decls for helpers used in the LoadFailed diagnostic
// hook below. Full definitions live near line 2605 (SafeReadPtr) and 90
// (IsReadablePointer). We also declare two new size-specific readers.
static uintptr_t SafeReadPtr(uintptr_t base, uintptr_t offset);
static int SafeReadU8At(uintptr_t base, uintptr_t offset);
// [1.14.7 identity diagnostic] defined near the byte-patch helper; declared here because the
// client hook-install block appears earlier in this translation unit than the definition.
static void InstallLoadMgrBeginHook(const char* Role);
// [1.14.7 progression diagnostics] same reason: defined later, installed from InitServerHooks.
static void InstallProgressionDiagnostics();
// [1.14.7 headless audio] same reason: defined later, installed from InitServerHooks.
static void InstallHeadlessAudioHook();
// [1.14.7 progression cache probe] same reason: defined later, installed from InitServerHooks.
static void InstallProgressionCacheProbe();
// [1.14.7 progression readiness probe] same reason: defined later, installed from InitServerHooks.
static void InstallProgressionReadinessProbe();
// [1.14.7 active hunt pass] same reason: defined later, installed from InitServerHooks.
static void InstallActiveHuntPassDriver();
// [1.14.7 load complete probe] same reason: defined later, installed from InitServerHooks.
static void InstallLoadCompleteProbe();
// [1.14.7 FindRow probe] same reason: defined later, installed from InitServerHooks.
static void InstallFindRowProbe();
// [1.14.7 config callback probe] same reason: defined later, installed from InitServerHooks.
static void InstallConfigCompleteProbe();
// [1.14.7 interface query call] defined later; used from the progression cache probe.
static void CallInterfaceQuerySlot(void* Interface, uintptr_t SlotOffset);
// [1.14.7 caller scan] defined later; installed from InitServerHooks.
static void InstallGameInstanceQueryHook();
// [1.14.7 feature flag dump] defined later; used from the readiness hook.
static void DumpFeatureFlags(const char* Tag);
// [1.14.7 hunt pass query call] defined later; used from the readiness hook.
static void CallHuntPassSeasonQuery();
// [1.14.7 mastery array probe] same reason: defined later, installed from InitServerHooks.
static void InstallMasteryProbe();
// [1.14.7 InteractionCalloutWidget::OnSetDisabled guard] same reason: defined later.
static void InstallInteractionCalloutSetDisabledGuard();
// [1.14.7 loader state dump] same reason: defined later, used from the server tick.
static void DumpLoadManagerEntries(const char* Tag);
// [Group C cleanup 2026-07-13] ForceNetUpdateEnabled fwd-decl removed (Phase-0e experiment deleted).
static int32_t SafeReadI32At(uintptr_t base, uintptr_t offset);
static void* ResolveWeakObj(int32_t objectIndex, int32_t serialNumber);
std::string SafeObjectNameForDiagnostic(void* Object);
// One automatic retry is enough to repair the observed hunt -> Ramsgate failure without turning a
// genuinely broken endpoint into an infinite request loop. The state is keyed by both manager and
// owning PlayerController so a recycled UObject allocation starts with a fresh budget.
struct PlayerDataLoadRecoverySlot {
uintptr_t LoadManager;
uintptr_t PlayerController;
uint64_t LastFailureMs;
uint32_t RetryCount;
};
static constexpr uint32_t kPlayerDataLoadMaxRetries = 1;
static constexpr size_t kPlayerDataLoadRecoverySlotCount = 32;
static constexpr uint64_t kPlayerDataLoadRecoveryStaleMs = 5ull * 60ull * 1000ull;
static SRWLOCK g_PlayerDataLoadRecoveryLock = SRWLOCK_INIT;
static PlayerDataLoadRecoverySlot
g_PlayerDataLoadRecoverySlots[kPlayerDataLoadRecoverySlotCount] = {};
static bool ClaimPlayerDataLoadRetry(
uintptr_t LoadManager,
uintptr_t PlayerController,
uint32_t* OutRetryNumber)
{
if (!LoadManager || !PlayerController || !OutRetryNumber) return false;
const uint64_t NowMs = GetTickCount64();
AcquireSRWLockExclusive(&g_PlayerDataLoadRecoveryLock);
PlayerDataLoadRecoverySlot* Match = nullptr;
PlayerDataLoadRecoverySlot* Replacement = nullptr;
uint64_t OldestFailureMs = UINT64_MAX;
for (PlayerDataLoadRecoverySlot& Slot : g_PlayerDataLoadRecoverySlots) {
if (Slot.LoadManager == LoadManager && Slot.PlayerController == PlayerController) {
Match = &Slot;
break;
}
const bool Empty = Slot.LoadManager == 0;
const bool Stale = Slot.LastFailureMs != 0
&& NowMs - Slot.LastFailureMs >= kPlayerDataLoadRecoveryStaleMs;
if (Empty || Stale) {
Replacement = &Slot;
if (Empty) break;
}
else if (!Replacement && Slot.LastFailureMs < OldestFailureMs) {
Replacement = &Slot;
OldestFailureMs = Slot.LastFailureMs;
}
}
if (!Match) {
Match = Replacement;
if (Match) {
*Match = {};
Match->LoadManager = LoadManager;
Match->PlayerController = PlayerController;
}
}
bool Claimed = false;
if (Match) {
Match->LastFailureMs = NowMs;
if (Match->RetryCount < kPlayerDataLoadMaxRetries) {
++Match->RetryCount;
*OutRetryNumber = Match->RetryCount;
Claimed = true;
}
}
ReleaseSRWLockExclusive(&g_PlayerDataLoadRecoveryLock);
return Claimed;
}
static bool ClearPlayerDataLoadRecovery(uintptr_t LoadManager, uintptr_t PlayerController) {
bool Cleared = false;
AcquireSRWLockExclusive(&g_PlayerDataLoadRecoveryLock);
for (PlayerDataLoadRecoverySlot& Slot : g_PlayerDataLoadRecoverySlots) {
if ((LoadManager && Slot.LoadManager == LoadManager)
|| (PlayerController && Slot.PlayerController == PlayerController)) {
Slot = {};
Cleared = true;
}
}
ReleaseSRWLockExclusive(&g_PlayerDataLoadRecoveryLock);
return Cleared;
}
// Reset only the native IArchonPlayerDataLoader::bLoading byte (+0x11) for loaders that are still
// bLoaded=false (+0x10), then re-enter UArchonLoadManager::Begin. Begin schedules a new bounded
// LoadFailed timer and calls the loader's real Load implementation. A late callback from the first
// request remains safe because IArchonPlayerDataLoader::Done is idempotent once bLoaded becomes true.
// Kept POD-only so SEH can contain any stale pointer/native-call fault.
static bool RearmIncompletePlayerDataLoaders(
uintptr_t LoadManager,
uintptr_t ArrayBase,
int32_t ArrayCount,
int ExpectedIncomplete,
int* OutRearmed)
{
if (OutRearmed) *OutRearmed = 0;
__try {
int Rearmed = 0;
for (int32_t i = 0; i < ArrayCount; ++i) {
const uintptr_t Entry = ArrayBase + static_cast<uintptr_t>(i) * 0x10;
const uintptr_t Loader = *reinterpret_cast<uintptr_t*>(Entry + 0x08);
if (!Loader) return false;
if (*reinterpret_cast<uint8_t*>(Loader + 0x10) == 0) {
*reinterpret_cast<uint8_t*>(Loader + 0x11) = 0;
++Rearmed;
}
}
if (Rearmed <= 0 || Rearmed != ExpectedIncomplete) return false;
// UArchonLoadManager::Begin guards timer creation with bBegun @ +0x58. The previous
// one-shot timer is the callback currently executing, so clearing this byte lets Begin
// replace/re-arm the same native timer handle at +0x60 and refresh StartTime at +0x68.
*reinterpret_cast<uint8_t*>(LoadManager + 0x58) = 0;
using BeginFn = void(*)(void*);
// [1.14.7 REMAP 2026-10-03] Begin was hardcoded at the 1.12 RVA 0x01B50080, which is a
// different function in this binary - calling it faulted at +0x1B50087 (0xC0000005) the
// moment the retry ran. The 1.14.7 function is resolved by the same string-anchor method:
// "UArchonLoadManager::Begin" (0x1454BFF10) has exactly one xref, inside FUN_141c997a0.
// (Complete is FUN_141c9dc70 if it is ever needed.)
reinterpret_cast<BeginFn>(Globals::BaseAddress + 0x01C997A0)(
reinterpret_cast<void*>(LoadManager));
if (OutRearmed) *OutRearmed = Rearmed;
return true;
}
__except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
// [1.12.0] UArchonLoadManager::LoadFailed - CONFIRMED RVA 0x01B65EB0
// Found via string-anchor on the ASCII function-name literal "UArchonLoadManager::LoadFailed"
// at .rdata file offset 0x51D7688 -> RVA 0x51D8888 -> VA 0x1451D8888; 3 xrefs, all inside
// FUN_141b65eb0 (Ghidra).
//
// Recovery contract verified from the 1.12.0 native functions:
// Begin @ RVA 0x01B50080 (1.12) -> 0x01C997A0 (1.14.7, string-anchor verified)
// Complete @ RVA 0x01B547D0 (1.12) -> 0x01C9DC70 (1.14.7, string-anchor verified)
// LoadFailed @ RVA 0x01B65EB0
// IPlayerDataLoader::Load @ RVA 0x01BA25E0
// IPlayerDataLoader::Done @ RVA 0x01B92600
//
// Each manager entry is {FWeakObjectPtr owner, IArchonPlayerDataLoader* loader}; it is NOT an
// FName. The previous diagnostic decoded the weak index/serial as an FName and produced mojibake.
// On the observed failed return, one loader remains bLoaded=0/bLoading=1. Complete() calls Begin()
// again, but Load() refuses to run while bLoading is still true, leaving the manager deadlocked
// until this timeout.
//
// Default behavior now performs one native retry of only the incomplete loader(s). If the retry
// also reaches LoadFailed, the original game handler runs and reports/disconnects normally rather
// than leaving the player on an endless loading screen. DISABLE_PLAYERDATA_LOAD_RETRY.flag bypasses
// the retry and immediately uses the original handler.
void* OrigArchonLoadManagerLoadFailed = nullptr;
void ArchonLoadManagerLoadFailedHook(void* This) {
static std::atomic<int> s_hitCount{0};
int hit = s_hitCount.fetch_add(1, std::memory_order_relaxed);
uintptr_t self = reinterpret_cast<uintptr_t>(This);
uintptr_t playerController = 0;
MpLog(std::string("[LoadFailedDiag] fire #") + std::to_string(hit)
+ " LoadManager=" + MpPtr(This));
auto CallOriginal = [&]() {
if (OrigArchonLoadManagerLoadFailed) {
reinterpret_cast<void(*)(void*)>(OrigArchonLoadManagerLoadFailed)(This);
}
else {
MpLog("[PlayerDataRecovery] original LoadFailed trampoline missing; cannot report terminal failure");
}
};
if (!IsReadablePointer(This, 0x70)) {
MpLog("[LoadFailedDiag] LoadManager pointer not readable; using original terminal handler");
CallOriginal();
return;
}
playerController = SafeReadPtr(self, 0x20);
uintptr_t arrayBase = SafeReadPtr(self, 0x48);
int32_t arrayCount = SafeReadI32At(self, 0x50);
MpLog(std::string("[LoadFailedDiag] ArrayBase=") + MpPtr((void*)arrayBase)
+ " Count=" + std::to_string(arrayCount)
+ " PlayerController=" + MpPtr((void*)playerController));
if (!arrayBase || arrayCount <= 0 || arrayCount > 128) {
MpLog("[LoadFailedDiag] Array empty/invalid; using original terminal handler");
CallOriginal();
return;
}
if (!IsReadablePointer(reinterpret_cast<void*>(arrayBase),
static_cast<size_t>(arrayCount) * 0x10)) {
MpLog("[LoadFailedDiag] Array unreadable; using original terminal handler");
CallOriginal();
return;
}
int incompleteCount = 0;
// [1.14.7 FIX 2026-10-05] Loaders this handler completed BY ITSELF (see the daily-bounty block below).
// The re-arm path requires Rearmed != 0, so completing a loader here used to leave nothing to re-arm,
// RearmIncompletePlayerDataLoaders returned false, and the recovery fell through to the original terminal
// handler anyway - which is why the client still showed the error-exit popup every ~60s.
int CompletedByUs = 0;
bool snapshotRecoverable = playerController != 0
&& IsReadablePointer(reinterpret_cast<void*>(playerController), 0xA78)
&& IsRegisteredLiveObject(reinterpret_cast<void*>(playerController));
for (int i = 0; i < arrayCount; ++i) {
uintptr_t entryAddr = arrayBase + static_cast<uintptr_t>(i) * 0x10;
const int32_t weakIndex = SafeReadI32At(entryAddr, 0x00);
const int32_t weakSerial = SafeReadI32At(entryAddr, 0x04);
const uintptr_t loaderPtr = SafeReadPtr(entryAddr, 0x08);
void* owner = ResolveWeakObj(weakIndex, weakSerial);
int loadedFlag = -1;
int loadingFlag = -1;
if (loaderPtr && IsReadablePointer(reinterpret_cast<void*>(loaderPtr), 0x28)) {
loadedFlag = SafeReadU8At(loaderPtr, 0x10);
loadingFlag = SafeReadU8At(loaderPtr, 0x11);
}
if (loadedFlag == 0) ++incompleteCount;
if (!owner || !loaderPtr || (loadedFlag != 0 && loadedFlag != 1)
|| (loadingFlag != 0 && loadingFlag != 1)) {
snapshotRecoverable = false;
}
uintptr_t ownerClass = owner
? SafeReadPtr(reinterpret_cast<uintptr_t>(owner), 0x10)
: 0;
// [1.14.7 FLAG PROBE 2026-10-04] The daily bounty component logs
// UBountyComponent_Daily::ServerInitializeBounties() - bServerInitializedBounties is true so bailing.
// and its loader is the only one stuck (bLoaded=0/bLoading=1). Our re-arm clears bLoading and re-enters
// Begin(), but the re-run bails on that guard, so the offset must be MEASURED rather than assumed.
// Dump the component's flag region (a bool the component sets) so the offset can be identified and then
// TESTED by clearing it - not guessed. Bounded to the bounty components.
{
const std::string OwnerName = SafeObjectNameForDiagnostic(owner);
// [1.14.7 FIX 2026-10-04] Clear the daily bounty component's init guard so the re-arm below can
// actually complete it.
//
// MEASURED (dump of all three bounty components at LoadFailed time):
// bounty_bpc +0x3F0: 01.. +0x3F8: 01.. loader loaded=1
// bounty_weekly_bpc +0x3F0: 01.. +0x3F8: 01.. loader loaded=1
// bounty_daily_bpc +0x3F0: 01.. +0x3F8: 00.. loader loaded=0 loading=1
// +0x3F0 is 1 on all three - each component has run ServerInitializeBounties, which is exactly why
// the daily logs "bServerInitializedBounties is true so bailing". +0x3F8 is 1 only on the two that
// COMPLETED. So the daily's first init set the guard but never finished, and every retry bails on
// that guard - our re-arm clears bLoading and re-enters Begin(), but the re-run bails again.
//
// Clearing the guard for the daily component lets its re-run do the work and complete the loader.
// Bounded to that one component by name, and only when its loader is genuinely incomplete.
// [1.14.7 REMOVED 2026-10-05] The forced-completion patch that lived here is DELETED. The audit
// corrected its premises, all of which were wrong:
// +0x3F0 means CONFIGURATION RECEIVED, not the initialization guard;
// the guard is +0x414, and +0x3F8 records FINISHED SETUP;
// and writing loaded=1 into the loader never executes native FinishSetup -> Done, so it only
// made the manager's count look satisfied while the component stayed stalled.
// The real defect is elsewhere: ReadFromBackend's WAIT branch binds a dynamic delegate named
// CompleteLoadInternal to this component, but the class hierarchy has no reflected UFunction with
// that name, so Unreal's function cache returns null and the dispatcher skips it - the component is
// stranded even though configuration and season selection finish. See the progression resume below.
const bool IsDailyBounty = false; // forced completion disabled
if (IsDailyBounty && loadedFlag == 0 && loaderPtr) {
// MEASURED, and this corrects an earlier wrong fix: clearing the component's +0x3F0 init guard
// does make ServerInitializeBounties run again (the guard comes back as 1 on the next fire),
// but the loader STILL ends at loaded=0 loading=1 and the manager times out a second time.
// So the guard was not the blocker - the loader's own Load() runs and never completes, while
// the component demonstrably finishes its work (it re-sets the guard). What is left is the
// manager's bookkeeping: Complete() counts loaders whose bLoaded (+0x10) is set, and this one
// never is. Mark it loaded so the manager can finish, and clear bLoading (+0x11) to keep the
// "Load refuses while bLoading" deadlock documented above from re-arming.
uint8_t* LoadedByte = reinterpret_cast<uint8_t*>(loaderPtr + 0x10);
uint8_t* LoadingByte = reinterpret_cast<uint8_t*>(loaderPtr + 0x11);
if (IsReadablePointer(LoadedByte, 2)) {
MpLog(std::string("[DailyBountyGuard] completing stuck loader: loaded ")
+ std::to_string(static_cast<int>(*LoadedByte)) + "->1, loading "
+ std::to_string(static_cast<int>(*LoadingByte)) + "->0 owner=" + MpPtr(owner)
+ " loader=" + MpPtr(reinterpret_cast<void*>(loaderPtr)));
*LoadedByte = 1;
*LoadingByte = 0;
++CompletedByUs;
}
}
if (OwnerName.find("bounty") != std::string::npos) {
std::string Dump = "[BountyFlagDump] " + OwnerName + " +0x3E0..+0x420:";
const uintptr_t Base = reinterpret_cast<uintptr_t>(owner);
for (uintptr_t Off = 0x3E0; Off < 0x420; Off += 0x10) {
if (!IsReadablePointer(reinterpret_cast<void*>(Base + Off), 0x10)) break;
Dump += " ";
for (uintptr_t B = 0; B < 0x10; ++B) {
char Buf[4];
snprintf(Buf, sizeof(Buf), "%02X",
*reinterpret_cast<const uint8_t*>(Base + Off + B));
Dump += Buf;
}
}
MpLog(Dump);
}
}
MpLog(std::string("[LoadFailedDiag] [") + std::to_string(i) + "] "
+ "owner=" + MpPtr(owner) + "/" + SafeObjectNameForDiagnostic(owner)
+ " class=" + MpPtr((void*)ownerClass) + "/"
+ SafeObjectNameForDiagnostic(reinterpret_cast<void*>(ownerClass))
+ " loader=" + MpPtr((void*)loaderPtr)
+ " loaded=" + std::to_string(loadedFlag)
+ " loading=" + std::to_string(loadingFlag)
+ " weak=(" + std::to_string(weakIndex) + "," + std::to_string(weakSerial) + ")");
}
if (MpExeRelativeFlagPresent(L"DISABLE_PLAYERDATA_LOAD_RETRY.flag")) {
MpLog("[PlayerDataRecovery] disabled by flag; using original terminal handler");
CallOriginal();
return;
}
if (!snapshotRecoverable || incompleteCount <= 0) {
MpLog("[PlayerDataRecovery] snapshot is not safely recoverable incomplete="
+ std::to_string(incompleteCount) + "; using original terminal handler");
CallOriginal();
return;
}
uint32_t retryNumber = 0;
if (!ClaimPlayerDataLoadRetry(self, playerController, &retryNumber)) {
MpLog("[PlayerDataRecovery] retry budget exhausted; using original terminal handler");
CallOriginal();
return;
}
// [1.14.7 FIX 2026-10-05] If this handler already completed the stuck loader(s), the manager's Complete()
// can fire on its own - so do NOT try to re-arm (there is nothing left to re-arm, and
// RearmIncompletePlayerDataLoaders would return false purely because Rearmed == 0) and do NOT report a
// terminal failure. That veto was the reason the recovery never actually succeeded:
// [DailyBountyGuard] completing stuck loader: loaded 0->1, loading 0->0
// [PlayerDataRecovery] native re-arm failed; using original terminal handler
// [1.14.7 REMOVED 2026-10-05] The CompletedByUs early return is DELETED: it suppressed the terminal
// failure while OTHER loaders could still be unfinished, so it hid real failures rather than fixing one.
// With the forced loader bytes gone, CompletedByUs is always 0 and the normal re-arm path applies.
if (false && CompletedByUs > 0) {
return;
}
int rearmedCount = 0;
if (!RearmIncompletePlayerDataLoaders(
self, arrayBase, arrayCount, incompleteCount, &rearmedCount)) {
ClearPlayerDataLoadRecovery(self, playerController);
MpLog("[PlayerDataRecovery] native re-arm failed; using original terminal handler");
CallOriginal();
return;
}
MpLog("[PlayerDataRecovery] native retry=" + std::to_string(retryNumber)
+ "/" + std::to_string(kPlayerDataLoadMaxRetries)
+ " rearmed=" + std::to_string(rearmedCount)
+ " manager=" + MpPtr(This)
+ " playerController=" + MpPtr((void*)playerController));
}
// ============================================================================
// [LoadMgrDump 2026-07-11] Deterministic UArchonLoadManager loader enumeration.
// Backend player-data is now valid (WP_EB_BEGINNER etc. all resolve; no catalog/loadout rejection,
// no "Loading can't continue!"), yet OnPlayerDataLoadComplete never fires and the client drops at
// ~36s - BEFORE UArchonLoadManager::LoadFailed's ~60s timeout - so [LoadFailedDiag] never fires to
// name the stuck loader. This reuses the LoadFailed enumeration but (a) finds the LoadManager via
// GObjects (no hook arg) and (b) is triggered on a timer inside the connected window (see
// GameEngineTickHook). Any loader whose loaded byte (loaderobj+0x10) stays 0 is the exact gate.
// ============================================================================
static std::atomic<uint64_t> g_postLoginTimeMs{ 0 }; // GetTickCount64() at first K2_PostLogin (0 = not yet)
// [2026-07-18] Per-login budget for the [PAWN #] ClientRestart/relevancy trace. Reset to 0 at each
// K2_PostLogin so EVERY login (including travel-back returns) gets a fresh capture window — the old
// process-global budget was exhausted by the first two logins, blinding us to whether ClientRestart
// even fires on the inte