Ctrl K
ComunidadGuías…

ParadoxRuntime/dllmain.cpp

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//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