//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 #include #include #include // [2026-10-10] GetProcessMemoryInfo -> K32GetProcessMemoryInfo in kernel32 (PSAPI_VERSION 2); no psapi.lib #include #include #include #include #include #include // atof — used by BleedoutDurationFallback() #include #include #include #include #include #include #include #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 #include #include #include #include #include #include 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 \ 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(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(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(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(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(Now.wHour), static_cast(Now.wMinute), static_cast(Now.wSecond), static_cast(Now.wMilliseconds), static_cast(GetCurrentProcessId())); MpLogEmit(Path, Port, Prefix, PrefixLen > 0 ? static_cast(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(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(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(Ptr); uintptr_t End = Start + Size; uintptr_t RegionEnd = reinterpret_cast(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(Ptr) & 0x7) != 0) return false; const uintptr_t P = reinterpret_cast(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(Ptr)->Index; return reinterpret_cast(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( reinterpret_cast(Ptr) + 0x08); const uint8_t ActorFlags5C = *reinterpret_cast( reinterpret_cast(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(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(Ch)); } return Out; } std::string MpPtr(const void* Ptr) { return Ptr ? std::to_string(reinterpret_cast(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(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(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(Ptr), &Module)) { char ModulePath[MAX_PATH]{}; GetModuleFileNameA(Module, ModulePath, MAX_PATH); uintptr_t Base = reinterpret_cast(Module); uintptr_t Address = reinterpret_cast(Ptr); return MpBaseName(ModulePath) + "+" + MpHex(Address - Base); } uintptr_t Base = Globals::BaseAddress ? Globals::BaseAddress : reinterpret_cast(GetModuleHandleA(nullptr)); uintptr_t Address = reinterpret_cast(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(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(Addr), &Module) || !Module) { return "?"; } wchar_t PathW[MAX_PATH]{}; DWORD PathLen = GetModuleFileNameW(Module, PathW, static_cast(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(InterlockedCompareExchange( reinterpret_cast(&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(ExitStatus)) + MpExitDiag(Caller)); MpLogStack("ntdll!NtTerminateProcess", static_cast(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(GetModuleHandleA(nullptr)); uintptr_t ExceptionAddress = reinterpret_cast(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(Record->ExceptionInformation[i])); } } } #if defined(_M_X64) if (Context) { Msg += " rip=" + MpAddress(reinterpret_cast(Context->Rip)); Msg += " rsp=" + MpHex(static_cast(Context->Rsp)); Msg += " regs{rax=" + MpHex(static_cast(Context->Rax)) + " rcx=" + MpHex(static_cast(Context->Rcx)) + " rdx=" + MpHex(static_cast(Context->Rdx)) + " rbx=" + MpHex(static_cast(Context->Rbx)) + " rsi=" + MpHex(static_cast(Context->Rsi)) + " rdi=" + MpHex(static_cast(Context->Rdi)) + " r8=" + MpHex(static_cast(Context->R8)) + " r9=" + MpHex(static_cast(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(Context->Rdi)) + " access=" + (Record->NumberParameters > 1 ? MpHex(static_cast(Record->ExceptionInformation[1])) : std::string("(unknown)"))); Context->Rdi = reinterpret_cast(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(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(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(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(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(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(GetModuleHandleA(nullptr)); uintptr_t ExceptionAddress = reinterpret_cast(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(ExceptionInfo->ContextRecord->Rip)); Msg += " rsp=" + MpHex(static_cast(ExceptionInfo->ContextRecord->Rsp)); } #endif MpLog(Msg); MpLogStack("UEUnhandledExceptionFilter", 3); return reinterpret_cast(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(*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(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= (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(*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(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 pong // (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 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(GetProcAddress(Ws2, "GetAddrInfoW")); if (TargetW) { MH_STATUS Create = MH_CreateHook(TargetW, HookGetAddrInfoW, reinterpret_cast(&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(GetProcAddress(Ws2, "getaddrinfo")); if (TargetA) { MH_STATUS Create = MH_CreateHook(TargetA, HookGetAddrInfoA, reinterpret_cast(&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 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(Sa); const uint16_t Family = *reinterpret_cast(p); if (Family != 2) return; // AF_INET only const uint16_t Port = static_cast((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 Key{ 0 }; std::atomic Bytes{ 0 }; std::atomic Packets{ 0 }; }; static MpWireSlot g_wireSlots[64]; static std::atomic g_wireBytesTotal{ 0 }; static std::atomic g_wirePacketsTotal{ 0 }; static std::atomic 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(Sa); const uint16_t Family = static_cast(p[0] | (p[1] << 8)); const uint64_t Port = static_cast((p[2] << 8) | p[3]); if (Family == 2 && SaLen >= 8) { const uint64_t Addr = (static_cast(p[4]) << 24) | (static_cast(p[5]) << 16) | (static_cast(p[6]) << 8) | static_cast(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(A[12]) << 24) | (static_cast(A[13]) << 16) | (static_cast(A[14]) << 8) | static_cast(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(Family) << 16); } static std::string MpWireKeyText(uint64_t Key) { const unsigned Tag = static_cast(Key >> 56); const unsigned Port = static_cast(Key & 0xFFFF); char Buf[64]; if (Tag == 1) { const uint32_t A = static_cast((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((Key >> 16) & 0xFFFFFFFFFFull), Port); } else { sprintf_s(Buf, "family%u", static_cast((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(((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(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 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(*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(GetProcAddress(Ws2, "sendto")); if (T1) { MH_STATUS C = MH_CreateHook(T1, HookSendTo, reinterpret_cast(&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(GetProcAddress(Ws2, "recvfrom")); if (T2) { MH_STATUS C = MH_CreateHook(T2, HookRecvFrom, reinterpret_cast(&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(GetProcAddress(Ws2, "WSARecvFrom")); if (T3) { MH_STATUS C = MH_CreateHook(T3, HookWSARecvFrom, reinterpret_cast(&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(GetProcAddress(Ws2, "WSARecv")); if (T4) { MH_STATUS C = MH_CreateHook(T4, HookWSARecv, reinterpret_cast(&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(&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 () { // c = FParse::Param(FCommandLine::Get(), L"DisableEOS"); // FUN_142634de0 // DAT_146d34178 = (c == 0); // cached result // } // return DAT_146d34178; // } // i.e. IsEOSEnabled() == !, 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 ""; void* TextData = *reinterpret_cast(const_cast(TextPtr)); if (!TextData || !IsReadablePointer(TextData, 0x38)) return ""; wchar_t* Data = *reinterpret_cast(reinterpret_cast(TextData) + 0x28); int32_t Num = *reinterpret_cast(reinterpret_cast(TextData) + 0x30); if (!Data || Num <= 0 || !IsReadablePointer(Data, 2)) return ""; 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(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(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(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(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, "") != 0) Out += ""; while (i + 1 < Text.size() && Out.size() < MaxLen) { const unsigned char N = static_cast(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(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(Caller) >= Globals::BaseAddress) ? reinterpret_cast(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 g_eacSuppressedCount{ 0 }; static std::atomic 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(StringPtr), 0x10)) return std::wstring(); const wchar_t* Data = *reinterpret_cast(StringPtr); const int32_t Num = *reinterpret_cast(reinterpret_cast(StringPtr) + 0x08); if (!Data || Num <= 0) return std::wstring(); const size_t Count = static_cast(Num) > MaxChars ? MaxChars : static_cast(Num); if (!IsReadablePointer(const_cast(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""; if (Message && IsReadablePointer(Message, 16)) { wchar_t* data = *reinterpret_cast(Message); int32_t num = *reinterpret_cast(reinterpret_cast(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(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(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(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(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(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(GetModuleHandleA(nullptr)); using ReallocFn = void* (*)(void*, size_t, uint32_t); void* Result = reinterpret_cast(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(World); OutGameMode = MpLiveObjectOfClass(*reinterpret_cast(W + 0x118), SDK::AArchonGameMode::StaticClass()); OutGameState = MpLiveObjectOfClass(*reinterpret_cast(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(GmObj); std::string MatchState = "?"; if (IsReadablePointer(reinterpret_cast(Gm + 0x02C0), 8)) { MatchState = reinterpret_cast(Gm + 0x02C0)->ToString(); } void* GameSession = *reinterpret_cast(Gm + 0x0278); int32_t GsMax = -1; if (IsReadablePointer(GameSession, 0x228)) { GsMax = *reinterpret_cast(reinterpret_cast(GameSession) + 0x0224); } Msg += " GM=" + MpPtr(GmObj) + " class=" + GmObj->Class->GetName() + " match=" + MatchState + " NumPlayers=" + std::to_string(*reinterpret_cast(Gm + 0x02D0)) + " NumSpectators=" + std::to_string(*reinterpret_cast(Gm + 0x02CC)) + " NumTravelling=" + std::to_string(*reinterpret_cast(Gm + 0x02DC)) + " ExpectedPlayerCount=" + std::to_string(*reinterpret_cast(Gm + 0x0498)) + " MaxPlayers=" + std::to_string(*reinterpret_cast(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(GsObj); std::string GsMatch = "?"; if (IsReadablePointer(reinterpret_cast(Gs + 0x0270), 8)) { GsMatch = reinterpret_cast(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 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 g_NoLocalPlayerApplied{ false }; static std::atomic 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(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 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(Port)); Addr.sin_addr.s_addr = htonl(BindHostOrder); const bool Bound = (bind(Probe, reinterpret_cast(&Addr), sizeof(Addr)) == 0); closesocket(Probe); return Bound; } static std::atomic g_wdEverHadCounts{ false }; // has this hub ever been in a map? static std::atomic g_wdNumPlayers{ -1 }; static std::atomic g_wdNumTravelling{ -1 }; static std::atomic 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 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 g_wdConnCount{ -1 }; static std::atomic g_wdConnStampMs{ 0 }; static std::atomic 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 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 g_wdSawPlayer{ false }; // Game thread id, published from the game thread. Needed to capture WHERE it is stuck (see below). static std::atomic 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 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 g_perfFrames{ 0 }; // frame intervals measured (entry to entry) static std::atomic g_perfFrameTicksSum{ 0 }; // QPC ticks summed over those intervals static std::atomic g_perfFrameTicksMax{ 0 }; static std::atomic g_perfHitches{ 0 }; // intervals > 100 ms static std::atomic g_perfEngineCalls{ 0 }; // OrigGameEngineTick calls timed static std::atomic g_perfEngineTicksSum{ 0 }; // QPC ticks inside OrigGameEngineTick static std::atomic g_perfLastFrameQpc{ 0 }; // 0 = start a new interval on the next frame static std::atomic 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 Calls{ 0 }; std::atomic Sum{ 0 }; std::atomic 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 g_perfRepActorCalls{ 0 }; // ReplicateActorFreqHook entries static std::atomic 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 g_perfGraphConns{ -1 }; static std::atomic g_perfGraphGlobalActors{ -1 }; static std::atomic g_perfGraphActorInfoSum{ -1 }; static std::atomic g_perfGraphActorInfoMax{ -1 }; static std::atomic g_perfClientConns{ -1 }; static std::atomic g_perfOpenChannelsSum{ -1 }; static std::atomic g_perfOpenChannelsMax{ -1 }; static std::atomic 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 Conn{ nullptr }; std::atomic Bits{ 0 }; std::atomic Calls{ 0 }; }; static MpRepBitsSlot g_repBitsSlots[64]; static std::atomic g_repBitsOverflow{ 0 }; static std::atomic 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(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(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(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(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 g_gtPeRingPos{ 0 }; // Innermost currently-dispatching UFunction*/object pointers (raw, for Ghidra) — plain diagnostic stores. static std::atomic g_gtPeCurFunc{ nullptr }; static std::atomic 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(p), 8)) break; uint64_t v = *reinterpret_cast(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(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(ctx.Rsp), 8)) break; ctx.Rip = *reinterpret_cast(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(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(Globals::BaseAddress); const auto* Nt = reinterpret_cast(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(rip[0])) + " / " + MpAddress(reinterpret_cast(rip[1])) + " / " + MpAddress(reinterpret_cast(rip[2])) + " RSP=" + MpAddress(reinterpret_cast(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(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(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(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(reinterpret_cast(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(Comp); const uint8_t State = *reinterpret_cast(C + 0x0B0); const uint8_t PrevState = *reinterpret_cast(C + 0x0B1); const float Duration = *reinterpret_cast(C + 0x0B4); const uint8_t Finishing = *reinterpret_cast(C + 0x130); const int32_t RepNum = *reinterpret_cast(C + 0x150); const int32_t NoHealth = *reinterpret_cast(C + 0x160); const uint8_t Ready = *reinterpret_cast(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(reinterpret_cast(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(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(Obj); const uint8_t State = *reinterpret_cast(C + 0x0B0); const float Duration = *reinterpret_cast(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(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( reinterpret_cast(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(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 " 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(&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(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(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 spelled out so it can point at // the read-only resource bytes: the native thunk only reads a const TArray&, 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(Size), static_cast(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(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(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(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 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(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(NewSlot->Class), Name); if (!Prop || Prop->ElementSize <= 0 || Prop->ElementSize > 0x20) continue; // POD layout fields only memcpy(reinterpret_cast(NewSlot) + Prop->Offset, reinterpret_cast(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(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(&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(Section->ColumnsBox), 0), kNames); SetText(ChildAt(reinterpret_cast(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(Phoenix->SectionTextBlock); auto* RolesStyle = static_cast(ChildAt(reinterpret_cast(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(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(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(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(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(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(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(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(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(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(World); SDK::UObject* GM = *reinterpret_cast(W + 0x118); // UWorld::AuthorityGameMode SDK::UObject* GS = *reinterpret_cast(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(GS); auto* AGM = GmOk ? static_cast(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(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(reinterpret_cast(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(g_CachedGameMode); int32_t NumPlayers = *reinterpret_cast(Gm + 0x02D0); int32_t NumTravelling = *reinterpret_cast(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(&Out), sizeof(Out)) != FALSE; } static std::string MpMiBText(SIZE_T Bytes) { char Buf[32]; sprintf_s(Buf, "%.1f", static_cast(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(Globals::BaseAddress + 0x06DACBD0); if (!IsReadablePointer(const_cast(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(Mi.RegionSize)) + ")"); return; } PROCESS_MEMORY_COUNTERS_EX Before{}, After{}; const bool HaveBefore = MpProcessMemory(Before); if (InterlockedCompareExchangePointer(reinterpret_cast(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(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(-1), static_cast(-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(T1.QuadPart - T0.QuadPart) * 1000.0 / static_cast(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(Max) * 1000.0 / static_cast(Freq); const double AvgMs = static_cast(Sum) * 1000.0 / static_cast(Freq) / static_cast(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( GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQueryInformationThread")); static GetThreadDescriptionFn s_getDesc = reinterpret_cast( GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "GetThreadDescription")); struct Prev { uint64_t Cpu100ns; uintptr_t Start; std::string Name; uint64_t SeenIter; }; static std::unordered_map 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 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(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(*c) : '?'); LocalFree(Desc); } } It = s_prev.emplace(Te.th32ThreadID, P).first; } CloseHandle(Th); if (!HaveTimes) continue; const uint64_t Cpu = ((static_cast(K.dwHighDateTime) << 32) | K.dwLowDateTime) + ((static_cast(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(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(R.Delta) * 100.0 / Denom); return std::string(" tid=") + std::to_string(R.Tid) + (R.Tid == GameTid ? "(GAME)" : "") + " " + Pct + " start=" + MpAddress(reinterpret_cast(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(WindowMs) / 1000.0, static_cast(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(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 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(RepCalls) * PerFrame, WindowSec > 0 ? static_cast(RepBits) / WindowSec / 1000.0 : 0.0, static_cast(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(Conns[i].Bits) / WindowSec / 1000.0 : 0.0, static_cast(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 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(TotalBytes) / Sec / 1000.0, static_cast(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(Peers[i].Bytes) / Sec / 1000.0, static_cast(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(Sum) / static_cast(Freq); WindowSec = SumSec; const double AvgMs = SumSec * 1000.0 / static_cast(Frames); const double MaxMs = static_cast(Max) * 1000.0 / static_cast(Freq); const double EngMs = EngCalls ? static_cast(EngSum) * 1000.0 / static_cast(Freq) / static_cast(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(Frames), static_cast(Frames) / SumSec, AvgMs, MaxMs, static_cast(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(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 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(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(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 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 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(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(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 // \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(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(Ret); const int Count = static_cast(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(EngineRealloc(Raw->Data, static_cast(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(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 ). 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(GetProcAddress(Mod, "D3D11CreateDevice")); void* pCreateSC = reinterpret_cast(GetProcAddress(Mod, "D3D11CreateDeviceAndSwapChain")); if (pCreate) { MH_CreateHook(pCreate, reinterpret_cast(D3D11CreateDeviceHook), reinterpret_cast(&OrigD3D11CreateDevice)); MH_EnableHook(pCreate); } if (pCreateSC) { MH_CreateHook(pCreateSC, reinterpret_cast(D3D11CreateDeviceAndSwapChainHook), reinterpret_cast(&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(GetProcAddress(Kernel, "LoadLibraryExW")) : nullptr; if (pLLEW) { MH_CreateHook(pLLEW, reinterpret_cast(LoadLibraryExWHook), reinterpret_cast(&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(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" "; 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(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(i) * 0x10; const uintptr_t Loader = *reinterpret_cast(Entry + 0x08); if (!Loader) return false; if (*reinterpret_cast(Loader + 0x10) == 0) { *reinterpret_cast(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(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(Globals::BaseAddress + 0x01C997A0)( reinterpret_cast(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 s_hitCount{0}; int hit = s_hitCount.fetch_add(1, std::memory_order_relaxed); uintptr_t self = reinterpret_cast(This); uintptr_t playerController = 0; MpLog(std::string("[LoadFailedDiag] fire #") + std::to_string(hit) + " LoadManager=" + MpPtr(This)); auto CallOriginal = [&]() { if (OrigArchonLoadManagerLoadFailed) { reinterpret_cast(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(arrayBase), static_cast(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(playerController), 0xA78) && IsRegisteredLiveObject(reinterpret_cast(playerController)); for (int i = 0; i < arrayCount; ++i) { uintptr_t entryAddr = arrayBase + static_cast(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(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(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(loaderPtr + 0x10); uint8_t* LoadingByte = reinterpret_cast(loaderPtr + 0x11); if (IsReadablePointer(LoadedByte, 2)) { MpLog(std::string("[DailyBountyGuard] completing stuck loader: loaded ") + std::to_string(static_cast(*LoadedByte)) + "->1, loading " + std::to_string(static_cast(*LoadingByte)) + "->0 owner=" + MpPtr(owner) + " loader=" + MpPtr(reinterpret_cast(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(owner); for (uintptr_t Off = 0x3E0; Off < 0x420; Off += 0x10) { if (!IsReadablePointer(reinterpret_cast(Base + Off), 0x10)) break; Dump += " "; for (uintptr_t B = 0; B < 0x10; ++B) { char Buf[4]; snprintf(Buf, sizeof(Buf), "%02X", *reinterpret_cast(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(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 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 intermittent stuck-in-Ramsgate returns. static std::atomic g_pawnDiagCount{ 0 }; // [Group B cleanup 2026-07-13] g_loadMgrDumpCount / g_clientLifecycleDumpCount removed with their dump blocks. // SEH-guarded call to the game's FWeakObjectPtr resolver (bounds-checks ObjectIndex against GUObjectArray, // validates the serial, returns the live UObject* or 0 if stale). Takes {ObjectIndex, ObjectSerialNumber}. // POD-only inside __try. [2026-10-08] The comment used to name 0x026C2C10 (the 1.12 RVA) while the call used // 0x0287AA20 (audit R10). 0x0287AA20 is the 1.14.7 resolver: a leaf after int3 padding (`mov r8d,[rcx+4]; // test r8d,r8d ...`), the same function UInteractionCalloutWidget's key refresh calls at 0x1E3EBC6. static void* ResolveWeakObj(int32_t objectIndex, int32_t serialNumber) { __try { int32_t weak[2] = { objectIndex, serialNumber }; using ResolveFn = void* (*)(int32_t*); auto fn = reinterpret_cast(Globals::BaseAddress + 0x0287AA20); return fn(weak); } __except (EXCEPTION_EXECUTE_HANDLER) { return nullptr; } } // Enumerate one UArchonLoadManager's registered player-data loaders. // CORRECTED LAYOUT (2026-07-11, verified via Ghidra Complete@0x1B547D0 + resolver@0x026C2C10): // loader array base @ self+0x48, int32 count @ self+0x50, stride 0x10. // Each 16-byte entry: entry+0x00 = FWeakObjectPtr {int32 ObjectIndex, int32 ObjectSerialNumber} // (NOT an FName - the old hook misread this and produced the bogus "MovieScene*" mojibake that // seeded the false cinematic theory); entry+0x08 = IArchonPlayerDataLoader* interface pointer. // loaded @ iface+0x10, started @ iface+0x11, dependency count @ iface+0x20. // Complete() loops back to Begin and never fires while ANY loader's loaded byte is 0. // [Group B cleanup 2026-07-13] EnumerateLoadManagerLoaders + DumpLoadManagerViaGObjects removed // (uncalled UArchonLoadManager loader-array dump; obsolete Phase-0 readiness diagnostic). // [Phase 0 diagnostic 2026-07-11] Read-only snapshot of the natural player-init state (server side). // [1.14.7 SDK CORRECTION 2026-10-05] Fields: Character.PlayerRole @char+0x738; APawn.PlayerState // @char+0x240; AArchonPlayerState.bIsActiveGameplay @PS+0x6B0 (SDK line 26170 - the +0x690 this comment // used to name is stale and lands in Pad_658); controller readiness bOnlineDataReady @PC+0xC69 (the // +0xC59 named here was stale as well; the code already uses the correct +0xC69). // [Phase 0Q 2026-07-12] Cached readiness tracer. Finds PC/Char/LoadingDispatch ONCE (lazy), caches the // pointers, then reads the readiness leaves with NO GObjects re-scan on subsequent calls. Logs each leaf // (+0x98..+0x9E), the first false one, bIsActiveGameplay (PS+0x690), bOnlineDataReady (PC+0xC59). Server // and client both drive it. READ-ONLY. This is the natural-readiness first-false-leaf finder. // [readiness tracer removed 2026-07-13 — Pass 1 Step 1] g_rdPC/g_rdChar/g_rdDisp/g_rdInv + // ReadyPtrIsClass/EnsureReadinessPtrs/LogReadinessCached deleted (obsolete Phase-0 diagnostic; crash source). // [Group D2 cleanup 2026-07-13] g_ixnWrapperTag removed (interaction-probe correlation tag). // [Group B cleanup 2026-07-13] DumpLifecycleState removed (obsolete Phase-0 readiness/loadout GObjects // dump; also contained the [Phase 0e] ForceNetUpdate experiment — ForceNetUpdateEnabled cleaned in Group C). void* OrigEncounterableSetup = nullptr; void EncounterableSetupHook() { return; } float TotalNoPlayersTime = 0.0f; bool EnableWatchdog = true; void* OrigGameEngineTick = nullptr; void* OrigInteractionCalloutHideHoldText = nullptr; void* OrigArchonLoadingScreenFadeIn = nullptr; static bool SanitizeNetDriverClientConnections(void* NetDriver, const char* Tag); // [CollectionFix] The GameNetDriver is registered into the world's gameplay FLevelCollections (the engine's source of truth that // UWorld::SetActiveLevelCollection copies into UWorld->NetDriver). Unconditional — no flag, no legacy poke. // Per-tick idempotent registration guard (validation build; production repair moves this to the driver/world // init point once proven stable). Registers Networking::NetDriver into the gameplay FLevelCollections // (type 0 DynSource + type 2 Static; never demo/replay). Self-protecting: RESOLVES BY NAME FIRST and only writes // the collection fields if the WorldContext resolver returns our driver (so SetActiveLevelCollection's re-resolve // cannot re-null it). Never pokes World->NetDriver. Logs deferred/rejected states once per world for visibility. static void* g_regWorld = nullptr; static void* g_deferredWorld = nullptr; static void* g_rejectedWorld = nullptr; static bool RegisterNetDriverInLevelCollections() { if (!Globals::Listening || !Networking::NetDriver || !IsReadablePointer(Networking::NetDriver, 0x198)) return false; UWorld* w = UWorld::GetWorld(); if (!w || !IsReadablePointer(w, 0x160)) return false; if (g_regWorld == reinterpret_cast(w)) return true; // already registered for this world (fast path) void* drvWorld = *reinterpret_cast(reinterpret_cast(Networking::NetDriver) + 0x140); // UNetDriver::World uintptr_t arr = *reinterpret_cast(reinterpret_cast(w) + 0x148); int32_t count = *reinterpret_cast(reinterpret_cast(w) + 0x150); bool collectionsOk = (arr && count > 0 && count <= 8 && IsReadablePointer(reinterpret_cast(arr), static_cast(count) * 0x78)); // Deferred: driver not yet bound to this world, or collections not ready (e.g. mid-travel). Do NOT poke World->NetDriver. if (drvWorld != reinterpret_cast(w) || !collectionsOk) { if (g_deferredWorld != reinterpret_cast(w)) { g_deferredWorld = reinterpret_cast(w); MpLog("[CollectionFix] registration deferred: world=" + MpPtr(w) + " driverWorld=" + MpPtr(drvWorld) + " collections=" + (collectionsOk ? ("n=" + std::to_string(count)) : std::string("unavailable"))); } return false; } // RESOLVE FIRST (self-protect): the WorldContext by-name resolver must return our driver before touching any field. void* gengine = IsReadablePointer(reinterpret_cast(Globals::BaseAddress + 0x06FC3CE8), 8) ? *reinterpret_cast(Globals::BaseAddress + 0x06FC3CE8) : nullptr; uint64_t nm = *reinterpret_cast(reinterpret_cast(Networking::NetDriver) + 0x190); // NetDriverName // [1.14.7 ROOT-CAUSE FIX 2026-10-04] This called the WRONG overload: RVA 0x04266770 is the // PendingNetGame lookup (it compares the context field at +0x1A0, which the SDK documents as // PendingNetGame), while the argument here is a UWorld. The UWorld overload is RVA 0x042667F0 - it // compares context+0x280, the same FWorldContext::World offset ResolveWorldContextFromWorld uses in // reverse. Because the wrong overload never matched, the lookup returned null, registration was refused // ("registration REJECTED (driver not in WorldContext; would re-null) ... byNameResolve=null"), the level // collections were never populated, and UWorld::SetActiveLevelCollection (RVA 0x042D9740) then copied a // null driver into World->NetDriver - overwriting the one-time assignment Networking::Listen makes. // // That null is the dispatch fault: the native caller loads the world's network-notify interface from // NetDriver+0x208, and that interface lives at UWorld+0x28, so its +0x10 field IS UWorld::NetDriver // (UWorld+0x38). Measured: rcx=0x23437DF19F8 and rcx-0x28=0x23437DF19D0, which is exactly the world // address the runtime had logged. So the fault was never a destroyed UObject - it was a null world driver. void* reResolve = (gengine && IsReadablePointer(gengine, 0xC48)) ? reinterpret_cast(Globals::BaseAddress + 0x042667F0)(gengine, w, nm) : nullptr; if (reResolve != reinterpret_cast(Networking::NetDriver)) { if (g_rejectedWorld != reinterpret_cast(w)) { g_rejectedWorld = reinterpret_cast(w); MpLog("[CollectionFix] registration REJECTED (driver not in WorldContext; would re-null): world=" + MpPtr(w) + " byNameResolve=" + MpPtr(reResolve) + " driver=" + MpPtr(Networking::NetDriver) + " — NOT writing collections"); } return false; // retry next tick; if this persists, context registration is required before the collection write } // Verified -> safe to write the collection source-of-truth. int populated = 0; for (int i = 0; i < count; ++i) { uintptr_t c = arr + static_cast(i) * 0x78; uint8_t type = *reinterpret_cast(c + 0x00); if (type == 0 || type == 2) { // DynSource + Static gameplay collections only *reinterpret_cast(c + 0x10) = reinterpret_cast(Networking::NetDriver); // FLevelCollection::NetDriver ++populated; } } g_regWorld = reinterpret_cast(w); MpLog("[CollectionFix] set driver on " + std::to_string(populated) + " gameplay collection(s) world=" + MpPtr(w) + " driver=" + MpPtr(Networking::NetDriver) + " byNameResolve=" + MpPtr(reResolve) + " resolveMatches=Y"); return true; } static void SanitizeNetDriverBeforeEngineTick() { if (!Globals::Listening || !Networking::NetDriver || !IsReadablePointer(Networking::NetDriver, 0x2B0)) { return; } UWorld* World = UWorld::GetWorld(); if (!World || !IsReadablePointer(World, 0x40)) { return; } static FName GameNetDriverName = FName(); static bool GameNetDriverNameInit = false; if (!GameNetDriverNameInit) { GameNetDriverName = UKismetStringLibrary::Conv_StringToName(L"GameNetDriver"); GameNetDriverNameInit = true; } // Register the GameNetDriver into the gameplay FLevelCollections (the source UWorld::SetActiveLevelCollection // copies into UWorld->NetDriver); the engine then propagates it. Idempotent per world; we never poke // World->NetDriver directly. This is the ONLY supported path (no COLLECTION_FIX flag, no legacy fallback). RegisterNetDriverInLevelCollections(); Networking::NetDriver->World = World; Networking::NetDriver->NetDriverName = GameNetDriverName; Networking::NetDriver->ServerConnection = nullptr; } // [Group D cleanup 2026-07-13] SnapWorldDriver ([WorldDriverTransition] stage monitor) removed — // read-only diagnostic that localized the driver-null transition; root fixed by the collection registration (item 2). static bool NativeNetTick(); // [fwd] NATIVE_NET_TICK.flag A/B — defined below; gates the manual dispatch/flush // A native net-tick exception can become persistent once a connection enters a bad teardown state. // Logging it every engine frame creates megabytes of I/O and can itself stall the game thread. Preserve // the first failure, emit one aggregate every 30 seconds, and report recovery once. The SEH filters still // retain the bounded exception record/stack evidence used for root-cause analysis. struct ManualNetTickFailureState { bool Active = false; uint64_t LastReportMs = 0; uint64_t FailuresSinceReport = 0; }; static ManualNetTickFailureState g_TickDispatchFailureState; static ManualNetTickFailureState g_TickFlushFailureState; static void ReportManualNetTickFailure(bool IsFlush, uint64_t Tick) { ManualNetTickFailureState& State = IsFlush ? g_TickFlushFailureState : g_TickDispatchFailureState; const char* Phase = IsFlush ? "TickFlush" : "TickDispatch"; const uint64_t NowMs = GetTickCount64(); ++State.FailuresSinceReport; if (!State.Active) { State.Active = true; State.LastReportMs = NowMs; MpLog("[NetTickFault] " + std::string(Phase) + " first exception at tick=" + std::to_string(Tick) + "; repeated failures will be summarized every 30s"); State.FailuresSinceReport = 0; return; } if (NowMs - State.LastReportMs >= 30000) { MpLog("[NetTickFault] " + std::string(Phase) + " still failing at tick=" + std::to_string(Tick) + " failuresSinceLast=" + std::to_string(State.FailuresSinceReport)); State.LastReportMs = NowMs; State.FailuresSinceReport = 0; } } static void ReportManualNetTickRecovery(bool IsFlush, uint64_t Tick) { ManualNetTickFailureState& State = IsFlush ? g_TickFlushFailureState : g_TickDispatchFailureState; if (!State.Active) return; const char* Phase = IsFlush ? "TickFlush" : "TickDispatch"; MpLog("[NetTickFault] " + std::string(Phase) + " recovered at tick=" + std::to_string(Tick) + " unreportedFailures=" + std::to_string(State.FailuresSinceReport)); State = {}; } static int TickDispatchExceptionFilter(unsigned int Code, EXCEPTION_POINTERS* ExceptionInfo) { if (ExceptionInfo && IsReadablePointer(ExceptionInfo, sizeof(EXCEPTION_POINTERS)) && IsReadablePointer(ExceptionInfo->ExceptionRecord, sizeof(EXCEPTION_RECORD))) { LogExceptionRecord( "ManualTickDispatchSEH", ExceptionInfo->ExceptionRecord, IsReadablePointer(ExceptionInfo->ContextRecord, sizeof(CONTEXT)) ? ExceptionInfo->ContextRecord : nullptr); } else { EXCEPTION_RECORD Record{}; Record.ExceptionCode = Code; Record.ExceptionAddress = _ReturnAddress(); LogExceptionRecord("ManualTickDispatchSEH", &Record, nullptr); } return EXCEPTION_EXECUTE_HANDLER; } // [1.14.7 FIX] The UGameEngine captured by GameEngineTickHook; the manual tick needs it as its first // argument (see the note at the hook definition). Declared here because this function is defined earlier. static void* g_GameEnginePtr = nullptr; // [1.14.7 FIX 2026-10-04] Reentrancy guard. The manual dispatch drives the world/net path, which can route // back into this same function (the engine tick path re-enters the net tick). Without a guard that recursion // is unbounded, and the observed failure is exactly a stack-exhaustion signature rather than a bad pointer: // ManualTickDispatchSEH code=0xC0000005 address=ucrtbase.dll+0xD8AE params=0x0,0xFFFFFFFFFFFFFFFF // and the bytes at that address are // 66 0F 7F 85 F0 03 00 00 movdqa [rbp+0x3F0], xmm0 // i.e. a CRT routine writing a large stack frame and faulting on the frame itself. (The earlier [0+0x88] // fault at 0x42CE8B6 is gone - the multi-world UWorld::NetDriver poke fixed it, guardSkips stayed 0.) // Re-entering is never useful here, so the nested call simply returns. static thread_local int s_manualTickDepth = 0; // Set while the manual dispatch is running, so a re-entrant tick cannot recurse (see the call site in // GameEngineTickHook). The guard lives there because this function uses __try, which forbids objects // requiring unwinding. static thread_local bool s_inManualTick = false; static bool SafeManualTickDispatch(UNetDriver* NetDriver, float DeltaTime) { // [NATIVE_NET_TICK A/B] skip the manual (double) drive — native engine tick owns TickDispatch. // (No logging here: this function uses __try, which forbids objects requiring unwinding. The // one-time mode banner is emitted from NativeNetTick()'s cache init instead.) if (NativeNetTick()) return true; using TickDispatchFn = void(*)(UNetDriver*, float); // [1.14.7 2026-10-04] REVERTED: passing the UGameEngine as the first argument is WRONG. Measured with // the engine pointer captured from GameEngineTickHook and used here: // ManualTickDispatchSEH code=0xC0000005 address=exe+0x39F9A70 params=0x0,0x500000015 // [NetTickFault] TickDispatch first exception at tick=2 // recvfrom: 0 sendto: 0 fatal=1 (the hub crash-looped, one restart per minute) // i.e. it faults immediately and does not even read the socket, whereas passing the NetDriver reads it // (recvfrom <- 127.0.0.1:52155 len=29) before faulting at [0+0x88]. So NetDriver IS the correct first // argument and the [0+0x88] fault is a separate, still-open defect. No logging here - this function uses // __try, which forbids objects requiring unwinding (see the note above). void* TickTarget = reinterpret_cast(NetDriver); __try { // [1.14.7 2026-10-04] I changed this to 0x03FAE420 (FUN_143fae420, the address hooked as // NetDriverTickDispatchInner) on the theory that 0x00A82CF0 was a stale 1.12 RVA. That was WRONG and // the measurements say so: // with 0x00A82CF0 : hub reads -> "recvfrom <- 127.0.0.1:52155 len=29" (then faults, see below) // with 0x03FAE420 : hub reads -> nothing at all (recvfrom: 0, SEH faults: 0) // So 0x00A82CF0 IS the working dispatch entry and it is restored here. The real defect is that the // dispatch then faults at exe+0x42CE8C1 with 0xC0000005, which the SEH filter absorbs // ("[ManualTickDispatchSEH] code=0xC0000005", "[NetTickFault] TickDispatch recovered at tick=N"). // That fault - not the address - is what the earlier double-drive A/B recorded as "NetDriver // corruption", and it is the remaining blocker for the travel handshake. reinterpret_cast(Globals::BaseAddress + 0x00A82CF0)(TickTarget, DeltaTime); return true; } __except (TickDispatchExceptionFilter(GetExceptionCode(), GetExceptionInformation())) { return false; } } // [1.12.0] UNetDriver::TickFlush at RVA 0x03D91DC0. Send-side counterpart to TickDispatch. // Confirmed via Ghidra decompile of FUN_143d91dc0 (vtable slot right after TickDispatch and // PostTickDispatch on UIpNetDriver's vtable at 0x144E0AF98): // - Same signature as TickDispatch: void __fastcall(UNetDriver* this, float DeltaSeconds) // - Walks ClientConnections at this[0x12] (Num at [0x13]) // - Calls UNetConnection::Tick(DeltaSeconds) on each (vtable slot +0x2C8) // - Invokes ServerReplicateActors via this[0x21] (World) and driver vtable +0x2A8 // - Manipulates per-connection bytes-sent stats at conn+0x4C4..+0x4D0 (only meaningful // on the send path). This is the flush that emits queued replication + ACKs. // // Why we call it here: our custom net-tick path in GameEngineTickHook calls TickDispatch // (receive) but nothing was calling TickFlush (send). That's why the server sees InSeq // climbing but NextOutGoingSeq frozen; NMT_Welcome, pawn replication, and the "clear the // loading screen" chain sit queued and never leave the box. static int TickFlushExceptionFilter(unsigned int Code, EXCEPTION_POINTERS* ExceptionInfo) { if (ExceptionInfo && IsReadablePointer(ExceptionInfo, sizeof(EXCEPTION_POINTERS)) && IsReadablePointer(ExceptionInfo->ExceptionRecord, sizeof(EXCEPTION_RECORD))) { LogExceptionRecord( "ManualTickFlushSEH", ExceptionInfo->ExceptionRecord, IsReadablePointer(ExceptionInfo->ContextRecord, sizeof(CONTEXT)) ? ExceptionInfo->ContextRecord : nullptr); } else { EXCEPTION_RECORD Record{}; Record.ExceptionCode = Code; Record.ExceptionAddress = _ReturnAddress(); LogExceptionRecord("ManualTickFlushSEH", &Record, nullptr); } return EXCEPTION_EXECUTE_HANDLER; } static bool SafeManualTickFlush(UNetDriver* NetDriver, float DeltaTime) { // [NATIVE_NET_TICK A/B] skip the manual (double) drive — native engine tick owns TickFlush -> ServerReplicateActors. if (NativeNetTick()) return true; using TickFlushFn = void(*)(UNetDriver*, float); __try { reinterpret_cast(Globals::BaseAddress + 0x03FAE700)(NetDriver, DeltaTime); return true; } __except (TickFlushExceptionFilter(GetExceptionCode(), GetExceptionInformation())) { return false; } } // [2026-07-18 double-drive A/B] NATIVE_NET_TICK.flag — the network path is currently driven TWICE per // frame: the native UGameEngine::Tick (OrigGameEngineTick) drives UWorld::Tick -> UNetDriver::TickDispatch/ // TickFlush now that the NetDriver is registered in the world/level collections, AND the DLL calls // SafeManualTickDispatch + SafeManualTickFlush again immediately afterward. Both funnel through the hooked // vtable slot +0x2A8 (ServerReplicateActorsHook, ArchonDirect), so ServerReplicateActors runs ~2x per frame // -> the [SRA enter] log shows ~60Hz while NetServerMaxTickRate=30. Double-driving the graph during rapidly // changing actor state (travel / possession / disconnect) is the strongest remaining desync/lifecycle // suspect (ServerMove TimeStamp expired, stretched proxies, higher churn-failure probability). // // When this flag is present next to the game .exe, the manual TickDispatch + TickFlush are SKIPPED, leaving // the native engine tick as the SOLE driver -> the graph is invoked ONCE per network frame (expected // [SRA enter] ~30Hz). CONFIRMATION SIGNAL: enable REPGRAPH_DIAG.flag too and watch [SRA enter] SRA=..Hz: // ~30Hz => native tick drives the net driver; manual calls were redundant (KEEP this on, promote later). // ~0Hz / no [SRA enter] => native tick does NOT drive TickFlush here; manual calls were load-bearing // -> remove the flag (replication is broken in this mode). // Default OFF (absent flag = current double-drive behaviour). Opt-in + reversible: deploying the DLL alone // changes nothing until the flag is dropped and the server restarted. Read once (cached) — set before launch. /* * [2026-07-19] MANUAL_TICK_ZERO_DT.flag — *** BROKEN. DO NOT ENABLE. *** * * TESTED AND FAILED: with this flag the server becomes unjoinable. Verified A/B on port 8790: * WITH (22:58, pid 1308): connCount -2 -> 0 at startup, then NOTHING. No client ever connects. * WITHOUT (23:02, pid 4284): connCount 0 -> 1 @ 23:03:10, PostLoginPE#0 @ 23:03:12. Joins fine. * * WHY: UNetDriver::TickDispatch(DeltaSeconds) does not merely accumulate a clock — it drives * UNetConnection::Tick(DeltaSeconds), which runs the connection handshake state machine and its * timers. DeltaTime=0 freezes those timers, so the handshake never completes. The parameter is * load-bearing for connection progression, not just timekeeping. * * The surgical replacement (NOT yet implemented) is to let TickDispatch run with the REAL DeltaTime * and instead save/restore UNetDriver::Time (+0x210) around the manual call, removing only the double * clock advance. DO NOT build that until the underlying assumption is confirmed in Ghidra: that this * build's TickDispatch actually accumulates Time from DeltaSeconds. Two flags have now been shipped on * unverified assumptions (NATIVE_NET_TICK, this one) and both broke joining. * * Original rationale retained below for context. * * [2026-07-19] MANUAL_TICK_ZERO_DT.flag — beam/orbit visual desync A/B. * * BACKGROUND: NATIVE_NET_TICK.flag was the intended A/B for the double-drive, but it was tried and the * server became unjoinable — i.e. the documented "~0Hz / no [SRA enter] => manual calls were load-bearing" * branch. The native engine tick does NOT drive TickFlush on this setup, so the manual calls cannot simply * be removed. That flag is therefore a dead end for this investigation and must NOT be promoted. * * SYMPTOM THIS TARGETS: some behemoth abilities render wrong — Valomyr's orbiting orbs fire beams that * start from displaced positions, clip into terrain, or draw only partially. Gameplay/damage is correct. * Critically it is SELECTIVE: only some abilities on some behemoths. A globally corrupted replication * path would degrade everything equally, so a global explanation does not fit. * * MECHANISM: the affected visuals are the ones derived from ELAPSED TIME rather than from a replicated * transform: * UOrbitingProjectileComponent : float SpawnTime, float NormalizedElapsedTime * GetNormalizedTimeElapsed(), UpdateParticlePosition_Native(...) * UProjectileBehavior_BeamBase : FVector StartPos / OwnerPos / CurrentEndPoint (computed locally) * The orb's orbit phase comes from elapsed time; the beam's origin is inherited from the orb. Montage * melee, simple projectiles and AoEs do not integrate elapsed time client-side — and those look fine. * * UNetDriver::TickDispatch(DeltaSeconds) advances the driver's ElapsedTime (the network clock used for * client/server time sync). The manual call passes the SAME DeltaTime the native tick already consumed: * SafeManualTickDispatch(NetDriver, DeltaTime); // second time, same value * so that clock plausibly runs at ~2x real time, and anything phase-derived resolves to the wrong point. * * WHAT THIS FLAG DOES: passes 0.0f as DeltaTime to the MANUAL TickDispatch only. The call still happens, * so replication keeps running and the server stays joinable — unlike NATIVE_NET_TICK.flag. Only the * double clock advance is removed. TickFlush keeps its real DeltaTime because it drives bandwidth/ * throttling, not the clock. * * *** UNVERIFIED ASSUMPTION: that this build's TickDispatch accumulates ElapsedTime from DeltaSeconds. * That is standard UE behaviour and matches the symptom, but it has NOT been confirmed in Ghidra for this * binary. If this flag changes nothing, verify that before pursuing the time-base theory further. *** * * Default OFF. Read once, cached — set before launch. */ static bool ManualTickZeroDeltaTime() { static int c = -1; if (c < 0) { c = MpExeRelativeFlagPresent(L"MANUAL_TICK_ZERO_DT.flag") ? 1 : 0; MpLog(std::string("[ManualTickDT] MANUAL_TICK_ZERO_DT.flag ") + (c ? "PRESENT -> *** BROKEN: server will be UNJOINABLE (handshake timers frozen). REMOVE THIS FLAG. *** " "Replication still runs; server stays joinable. Watch whether orbit/beam visuals correct " "themselves while everything else is unchanged." : "absent -> manual TickDispatch gets the real DeltaTime (default; clock may double-advance).")); } return c == 1; } /* * [2026-07-19] MANUAL_TICK_HALF_RATE.flag — rate-parity A/B (alternative to the clock fix above). * * Runs the manual TickDispatch/TickFlush pair on every OTHER engine frame, so ServerReplicateActors is * invoked about once per network frame (~30Hz, matching NetServerMaxTickRate) instead of ~60Hz. * * This is a DIFFERENT hypothesis from MANUAL_TICK_ZERO_DT: it assumes the problem is replication * FREQUENCY rather than the clock. Note the cost — halving the manual drive also halves the effective * player-pawn replication rate, which works against the deliberate PlayerRepBoost smoothness fix. Prefer * the zero-DT flag first; use this only to test the frequency theory. * * Do NOT enable both at once — you would not know which one moved the needle. * Default OFF. */ static bool ManualTickHalfRate() { static int c = -1; if (c < 0) { c = MpExeRelativeFlagPresent(L"MANUAL_TICK_HALF_RATE.flag") ? 1 : 0; MpLog(std::string("[ManualTickRate] MANUAL_TICK_HALF_RATE.flag ") + (c ? "PRESENT -> manual TickDispatch/TickFlush run every OTHER frame (~30Hz SRA, matches " "NetServerMaxTickRate). NOTE: also halves player-pawn replication rate." : "absent -> manual drive runs every frame (default).")); } return c == 1; } static bool NativeNetTick() { static int c = -1; if (c < 0) { // [1.14.7 ROOT FIX 2026-10-03] Default is now NATIVE-ONLY (manual drive skipped). // // Ghidra: FUN_143fae420 is UNetDriver::TickDispatch (its own "Very long time between // ticks" string) and FUN_143fae700 is UNetDriver::TickFlush (it dispatches vtable // +0x2A8, ServerReplicateActors). GameEngineTickHook still calls the engine tick // (OrigGameEngineTick), which already drives UWorld::Tick -> TickDispatch/TickFlush, // so the manual path in this file drove the network path TWICE per frame. // // Controlled A/B on this machine (same build, same maps, only this flag differed): // double drive ENABLED : both hubs logged "resetting invalid ClientConnections" and // "resetting invalid cleanup array" ~60-90s in, then died // (Ramsgate exit 3 after "Fatal error ... RemoveFromHash // NumRemoved = 0"; Training exit 0xC0000005). // double drive DISABLED: both hubs stayed alive past 3 minutes with zero corruption // events and no fatal. // UNetDriver is byte-identical between the 1.12.0 and 1.14.7 SDK dumps and this // corruption never appears in the archived 1.12-era logs, so this is a 1.14.7-specific // regression caused by driving the net path twice. // // NATIVE_NET_TICK.flag (legacy) and MANUAL_NET_TICK.flag (new) are both honoured: // native-only is the default; MANUAL_NET_TICK.flag re-enables the legacy double drive // for A/B only and logs loudly. const bool LegacyNativeFlag = MpExeRelativeFlagPresent(L"NATIVE_NET_TICK.flag"); const bool ManualOverride = MpExeRelativeFlagPresent(L"MANUAL_NET_TICK.flag"); // [1.14.7 FIX 2026-10-04] Default flipped back to the MANUAL drive. The native-only default rested on // "the NetDriver is registered in the world/level collections, so the engine tick drives it", but this // build proves otherwise: // [CollectionFix] registration REJECTED (driver not in WorldContext; would re-null) // so the engine never dispatches our named driver. With the manual path skipped NOTHING ticked it and // its socket was never read - measured with the socket diagnostics: // client: sendto -> 127.0.0.1:8790 len=29 (once a second for the whole 20s window) // hub: zero recvfrom / WSARecvFrom / WSARecv calls // Enabling the manual drive made the hub read immediately: recvfrom <- 127.0.0.1:52155 len=29. // The crashes the earlier A/B blamed on double-driving were the stale TickDispatch RVA (0x00A82CF0), // now corrected to the verified 0x03FAE420 - not the double drive itself. // NATIVE_NET_TICK.flag still forces the old native-only behaviour for A/B. c = (LegacyNativeFlag || ManualOverride) ? 1 : 0; // [1.14.7 ROLE GATE 2026-10-04] The manual drive is for the MANAGED HUBS ONLY. // // Why it is needed there: the hub's named driver is not in the world context // ("[CollectionFix] registration REJECTED (driver not in WorldContext; would re-null)"), so the engine // never dispatches it and its socket is never read - measured: the client sent // "sendto -> 127.0.0.1:8790 len=29" once a second for 20s while the hub made zero receive calls. // // Why the CLIENT must not have it: a game client's driver IS engine-registered and IS ticked by the // engine, so driving it again would double-tick it. When the manual drive was enabled for both roles, // the client stopped reaching matchmaking entirely - it parked on the main menu with // OnPlayScreenPollTimer repeating and the backend saw no /candidate/join at all, where before it // travelled a few seconds after press-to-start. // // So: server role -> manual drive (unless NATIVE_NET_TICK.flag); client role -> always native. if (!Globals::AmServer) { c = 1; } // [1.14.7 FIX 2026-10-04] Native-only for the SERVER too, now that Networking::Listen points // UWorld::NetDriver (+0x38) at our named driver. The engine tick therefore dispatches OUR driver // exactly once, so the manual drive is no longer needed - and removing it also removes the null+0x88 // fault it caused (a second engine tick per frame running in a state the first one never created: // "[ManualTickDispatchSEH] code=0xC0000005 ... params=0x0,0x88 regs{rax=0x0}"). // NATIVE_NET_TICK.flag / MANUAL_NET_TICK.flag still override either way for A/B. // [1.14.7 2026-10-04] Round-20 forced native here on the theory that pointing UWorld::NetDriver at // our driver would make the engine tick dispatch it. Measured since: the engine NEVER calls // TickDispatch on it (instrumented counter: "TickDispatchInner call lines: 0"), so the server needs // the manual drive after all - now with the real UGameEngine as its first argument instead of the // net driver, which is what removes the [0+0x88] fault. // [2026-10-08] The message now reports the real reason. The old DISABLED text claimed // "MANUAL_NET_TICK.flag present ... expect NetDriver corruption" on every hub boot, although the // manual hub drive is the intended server default and no flag was installed. std::string Reason; if (!Globals::AmServer) { Reason = "ACTIVE: client role, the engine ticks its own registered driver."; } else if (c) { Reason = std::string("ACTIVE (A/B override: ") + (LegacyNativeFlag ? "NATIVE_NET_TICK.flag" : "MANUAL_NET_TICK.flag") + " present): manual TickDispatch/TickFlush SKIPPED on the server."; } else { Reason = "OFF (server default): the hub's named driver is not engine-registered, so the module " "drives TickDispatch/TickFlush manually."; } MpLog("[NativeNetTick] native-only net tick " + Reason); } return c == 1; } // [Group D cleanup 2026-07-13] DriverWriteWatch cluster removed (g_dww*, DriverWriteVEH, DriverWriteArmThread, // MaybeArmDriverWriteWatch) — flag-gated (DRIVER_WRITE_WATCH.flag) hardware-watchpoint diagnostic; root fixed by item 2. // [Group E2 cleanup 2026-07-13] DumpLevelCollectionsOnce removed — read-only [LevelCollections] dump used // to confirm the collection.NetDriver==null root; superseded by the collection-registration fix (item 2). static bool PlayerRepBoost(); // [fwd] defined with the replication helpers below; used in GameEngineTickHook static void MpReportReplicationMembership(); // [fwd] 1.14.7 client-replication membership probe static void MpRouteBaseActorsToConnections(); // [fwd] 1.14.7 client PlayerState/GameState route fix static void MpDriveReplicationGraph(float DeltaTime); // [fwd] 1.14.7 replication-graph drive static void DropStalePlayerControllerChannelQuarantine(); // [fwd] stale PC channel quarantine entries // [2026-07-20] FORCE_SERVER_MESH_POSE.flag — dedicated-server skeletal pose/socket fix. // See Progress/02_NETWORK_REPLICATION.md. HYPOTHESIS: on the -nullrhi dedicated server, skeletal meshes // whose VisibilityBasedAnimTickOption (+0x5FC) is OnlyTickPoseWhenRendered(3) / OnlyTickMontagesWhenNot // Rendered(2) never refresh bone/socket world transforms (the server never renders), so socket-anchored // VFX/projectile origins (Valomyr's orbiting orbs + their beams, etc.) collapse to the reference pose // (~actor origin, ground level) and that wrong transform replicates to clients -> flat, ground-hugging // beams. Forcing AlwaysTickPoseAndRefreshBones(0) makes bone transforms valid server-side. // // [2026-07-21 correctness fix] Two bugs found during a Rezakiri/Valomyr log review (verified before // fixing, not assumed): // 1. Only handled enum values 2/3. AlwaysTickPose(1) still gates BONE/SOCKET refresh behind rendering // per the SDK's own enum comment ("Always Tick Pose" only guarantees the pose graph runs, not that // bones are refreshed when unrendered) - a behemoth mesh sitting at 1 was silently left untouched // and never logged, i.e. exactly the failure mode this function exists to rule out. // 2. Logging was conditional on the value CHANGING, capped at 60 lines total across every scanned // mesh. A mesh already at 0 (or, per bug 1, sitting at 1) produced NO log line at all - so an // absent [ForceServerMeshPose] line for a given behemoth proved nothing: skipped-by-the-value-1-gap, // already-fine, or simply crowded out of the 60-line cap by unrelated meshes (player characters, // weapons, props) all look identical from the outside: silence. Now every qualifying mesh's CURRENT // value is logged on every pass, unconditionally, whether or not it needed changing, so a specific // behemoth's mesh can be grepped for by name and its state is never ambiguous. // // STILL NOT DONE (do not guess this blind): scoping to only the active behemoth's mesh instead of every // USkeletalMeshComponent in GObjects. That needs a verified UActorComponent "Owner"/Outer field offset // or a native GetOwner() call site from Ghidra - this SDK dump does not expose Owner as a named field // (private, non-reflected), and guessing a raw pointer offset in a live server patch is how you crash // the process reading garbage memory. Left as a follow-up requiring that offset first. // // SELF-VERIFYING: logs every mesh's value and full name on every pass, so even if this is NOT the // cause we learn the actual server-side setting. GAME-THREAD ONLY (GObjects walk + IsA/GetName are safe // here, like GameModeEmptyState); throttled; server-only (client meshes render and should keep their // render-gated option for perf). Default OFF; opt-in + reversible. static void ForceServerMeshPose() { if (!MpExeRelativeFlagPresent(L"FORCE_SERVER_MESH_POSE.flag")) return; // [2026-07-26 REPRO CLEANUP] The VPS still had this old diagnostic flag enabled during // "texture issue can walk". Every four seconds it walked up to 138,948 UObjects and flushed // ~21 log lines, but changed exactly zero meshes. The reason is deterministic: // IsReadablePointer requires an 8-byte-aligned address while VisibilityBasedAnimTickOption is // at Obj+0x5FC (4-byte misaligned), so the old gate rejects every valid field before the write. // // Do not silently turn the write on now: forcing AlwaysTickPoseAndRefreshBones for every server // mesh is a separate high-CPU experiment and is not a justified fix for a client-side remote // pawn/PlayerRole lifetime bug. Existing FORCE_SERVER_MESH_POSE.flag deployments therefore // become inert. Requiring a second deliberately named flag preserves the old experiment for // a controlled future test without letting a forgotten flag contaminate normal server timing. if (!MpExeRelativeFlagPresent(L"FORCE_SERVER_MESH_POSE_UNSAFE.flag")) { static bool s_loggedRetired = false; if (!s_loggedRetired) { s_loggedRetired = true; MpLog("[ForceServerMeshPose] retired nonfunctional diagnostic; " "remove FORCE_SERVER_MESH_POSE.flag (unsafe experiment now also requires " "FORCE_SERVER_MESH_POSE_UNSAFE.flag)"); } return; } if (!SDK::UObject::GObjects) return; static uint64_t s_lastMs = 0; uint64_t now = GetTickCount64(); if (s_lastMs != 0 && now - s_lastMs < 4000) return; // throttle the full GObjects walk s_lastMs = now; SDK::UClass* MeshClass = SDK::USkeletalMeshComponent::StaticClass(); if (!MeshClass) { MpLog("[ForceServerMeshPose] pass: USkeletalMeshComponent::StaticClass() returned null - class lookup failed"); return; } const int Count = SDK::UObject::GObjects->Num(); // [2026-07-21 diagnostic split] A live VPS repro (Ramsgate AND a hunt, 1h43m, hundreds of passes) // reported "scanned 0" every single time, including in Ramsgate where player character meshes are // always present - so the zero cannot be "no meshes existed this pass", something upstream of the // old single `scanned` counter is failing silently. Split into distinct stages so the NEXT capture // proves exactly where: does IsA(MeshClass) ever match anything at all (matched), and of those, does // the +0x5FC read fail (unreadable)? Previously `scanned` only incremented AFTER the pointer-read // check passed, so "scanned 0" was ambiguous between "IsA never matched" and "IsA matched but the // offset read always failed" - exactly the same class of silent-failure bug already fixed once for // the value-change logging, now applied to the scan itself. int walked = 0, matched = 0, unreadable = 0, changed = 0, logged = 0; const int LogCap = 500; // generous - this is an opt-in diagnostic pass, not a hot path const int NameSampleCap = 20; // sample of matched objects' names even when unreadable, to confirm identity int nameSampled = 0; for (int i = 0; i < Count; i++) { SDK::UObject* Obj = SDK::UObject::GObjects->GetByIndex(i); if (!Obj || Obj->IsDefaultObject()) continue; ++walked; if (!Obj->IsA(MeshClass)) continue; ++matched; uint8_t* opt = reinterpret_cast(reinterpret_cast(Obj) + 0x5FC); // IsReadablePointer intentionally requires its starting pointer to be 8-byte aligned. // Validate from the aligned UObject base through the field instead of passing Obj+0x5FC. if (!IsReadablePointer(Obj, 0x5FD)) { ++unreadable; if (nameSampled < NameSampleCap) { ++nameSampled; MpLog("[ForceServerMeshPose] MATCHED-BUT-UNREADABLE " + Obj->GetFullName() + " ptr=" + MpPtr(opt)); } continue; } uint8_t oldVal = *opt; bool willChange = (oldVal == 1 || oldVal == 2 || oldVal == 3); // any render-gated variant if (willChange) { *opt = 0; // AlwaysTickPoseAndRefreshBones ++changed; } if (logged < LogCap) { ++logged; MpLog("[ForceServerMeshPose] " + Obj->GetFullName() + " VisibilityBasedAnimTickOption " + std::to_string(oldVal) + (willChange ? " -> 0 (forced)" : " (already 0, no change)")); } } MpLog("[ForceServerMeshPose] pass walked " + std::to_string(walked) + " object(s), IsA-matched " + std::to_string(matched) + ", unreadable " + std::to_string(unreadable) + ", changed " + std::to_string(changed) + ", logged " + std::to_string(logged)); } // [2026-07-20 TRIALS GRACE DIAG] Confirm the downed self-revive window in Trials. Gated by // TRIALS_GRACE_DIAG.flag. Reads AArchonPlayerState.BleedoutState(+0x3AC) / BleedoutTimerLength(+0x3B0) / // BleedoutTimeRemaining(+0x3B4) from GameState.PlayerArray, so we can see the grace length the game assigns // (timerLen) and watch it count down (timeRemaining) versus when the match ends. This distinguishes: // (a) short self-revive window -> timerLen itself is ~3-4s // (b) solo wipe ends match early -> timerLen is ~normal (~30s) but the match ends with lots remaining // UWorld.GameState @ +0x120; AGameStateBase.PlayerArray data @ +0x238, Num @ +0x240. Read-only, no behavior // change. Server-only, throttled by the caller. static void SampleBleedoutGrace() { UWorld* w = *reinterpret_cast(Globals::BaseAddress + 0x06FC7F58); if (!w || !IsReadablePointer(w, 0x128)) return; void* gs = *reinterpret_cast(reinterpret_cast(w) + 0x120); if (!gs || !IsReadablePointer(gs, 0x248)) return; void** psData = *reinterpret_cast(reinterpret_cast(gs) + 0x238); int psNum = *reinterpret_cast(reinterpret_cast(gs) + 0x240); if (!psData || psNum <= 0 || psNum > 64 || !IsReadablePointer(psData, 8)) return; for (int i = 0; i < psNum; ++i) { void* ps = psData[i]; if (!ps || !IsReadablePointer(ps, 0x3B8)) continue; int32_t state = *reinterpret_cast(reinterpret_cast(ps) + 0x3AC); if (state == 0) continue; // only sample while a player is downed / bleeding out float len = *reinterpret_cast(reinterpret_cast(ps) + 0x3B0); float rem = *reinterpret_cast(reinterpret_cast(ps) + 0x3B4); MpLog("[GraceDiag] ps=" + reinterpret_cast(ps)->GetName() + " BleedoutState=" + std::to_string(state) + " timerLen=" + std::to_string(len) + " timeRemaining=" + std::to_string(rem)); } } // Object-free wrapper so the __try lives outside GameEngineTickHook (which requires object unwinding -> C2712). static void SampleBleedoutGraceGuarded() { __try { SampleBleedoutGrace(); } __except (EXCEPTION_EXECUTE_HANDLER) {} } // [2026-07-20 TRIALS GRACE DIAG v2] The tick sampler above reads AArchonPlayerState.BleedoutState (+0x3AC), // which is BlueprintVisible but NOT Net -> it can stay 0 on the dedicated server (bleedout is tracked on the // per-character UArchonBleedoutComponent). So also hook the knockout routine (FUN_141b8acd0 @ RVA 0x01B8ACD0), // which the arena logs prove fires server-side on every down ("Tried to knock out %s ..."). Logging every // knockout with MpLog's timestamp + the actor lets us measure the real grace = last-knockout -> WaitingPostMatch // (game log), and count downs per player. Gated by TRIALS_GRACE_DIAG.flag. void* OrigKnockout = nullptr; static void LogKnockout(void* self) { std::string nm = (self && IsReadablePointer(self, 0x40)) ? reinterpret_cast(self)->GetName() : std::string("?"); MpLog("[GraceDiag] KNOCKOUT actor=" + nm); } void KnockoutHook(void* self) { reinterpret_cast(OrigKnockout)(self); if (MpExeRelativeFlagPresent(L"TRIALS_GRACE_DIAG.flag")) { __try { LogKnockout(self); } __except (EXCEPTION_EXECUTE_HANDLER) {} } } // Forward decls: real definitions live later in the file (with ProcessEventClientHook, which is // their primary caller), but GameEngineTickHook's timeout fallback below (2026-07-21) needs them // too, and GameEngineTickHook is defined earlier in this translation unit. static bool DiagNaturalMode(); void TriggerArchonInputActivation(UObject* PC, const char* TriggerLabel, bool AllowCinematicClear); std::string CoreCapFString(void* fstr); // defined later in this file; ProcessEventClientHook's // 2026-07-24 accessory-unlock diag needs it earlier. // [2026-07-21/22] Forward decls for the ApplyPlayerRole timeout-retry (defined near // ApplyPlayerRoleHook, further down the file). Both server AND owning client need this lifecycle: // the server having a valid PR_TEMPEST inventory reference does not populate the client's local // pawn/HUD, because AArchonCharacter.PlayerRole is not a replicated SDK property. extern void* OrigApplyPlayerRole; void ApplyPlayerRoleHook(void* a1); static void TickPlayerRoleRetries(); static void TickPlayerRolePostActivationRefresh(); static void TickTempestModifierEnsure(); static void TickEscalationBuffChoiceSpawn(); static void NoteEscalationStateChangeForBuffChoices(UObject* ModeComponent, void* Parms); static void TickTempestChargeDiag(); static void TickProgressionHudRefresh(); static void ResetClientPlayerRoleRetryStateForTravel(); static bool ValidateClientPossessionChain(UObject* PC, UObject* ExpectedPawn, UObject** OutPawn); static void MpTraceClientPlayerStateGap(UObject* PC); // [1.14.7 client player-state probe] static void MpSendPathDiag(); // [1.14.7 hub send-path probe] static void MpProgressionWaitPoll(); // [1.14.7 parked progression components, 2026-10-10] static void MpArrivalTriggerServerProbe(); // [1.14.7 Training arrival-trigger diagnostic, 2026-10-10] static void MpHuntPassServerStateTick(); // [1.14.7 2026-10-10] read-only [HuntPassState], server static void MpHuntPassClientProbe(UObject* PC); // [1.14.7 2026-10-10] read-only [HuntPassProbe], client arrival static void MpCoinTossClientProbe(UObject* PC); // [1.14.7 2026-10-10] read-only [CoinTossProbe], client arrival static void MpStartMatchWithoutPhantom(void* GameMode); // [1.14.7 phantom removal: match start at the first login] static bool IsClientRolePumpPossessionStable(UObject* PC, UObject** OutPawn); static void TickFrankDroneClientDiag(void* Pawn, const char* Trigger); static std::string FrankDroneGateSummary(void* PlayerRole); static void TraceArtificerDroneEvent( const char* Side, const char* Phase, UObject* Object, const std::string& FunctionName, void* Parms); extern void* OrigFrankCanActivateAbilityBP; bool __fastcall FrankCanActivateAbilityBPHook(void* PlayerRole); // [1.12 archon-input-activation state — 2026-07-10, relocated to file scope 2026-07-21] // Was function-local to ProcessEventClientHook; promoted to file scope (and moved up here, ahead // of both its readers) so GameEngineTickHook's timeout fallback can see it too. Captures the // client-side AArchonPlayerController at ClientRestart so the input-activation cascade // (TriggerArchonInputActivation) can run against it, either from the first OnInputModeChanged // callback (normal path, in ProcessEventClientHook) or a bounded timeout below if that callback // never fires. See Progress/ 2026-07-21 entry for why: the native loading-screen-close decision // that would fire OnInputModeChanged can itself stall, leaving the player stuck on the loading // screen with input never enabled — confirmed by comparing a stuck client's log against a working // one: [ArchonInputActivate] never appeared at all in the stuck one. static UObject* s_LastPossessedPC = nullptr; static UObject* s_LastRestartPawn = nullptr; static void* s_LastPossessedWorld = nullptr; // GWorld when s_LastPossessedPC was captured static uint64_t s_LastPossessedAtMs = 0; static bool s_ArchonInputActivated = false; // [2026-10-10 1.14.7] s_LastPossessedPC / s_LastRestartPawn belong to the world they were captured in, and nothing // cleared them. After a travel, or a disconnect back to the login screen, every probe kept validating the freed // controller: IsRegisteredLiveObject reads UObject::Index (+0x0C) from decommitted memory, its SEH caught each fault // and the VEH logged them (192 in 5 s on 10 Oct 19:41, after a rejected party join left the client on the login // screen). Forget the pair once GWorld (0x06FC7F58) is no longer the capture world; the next ClientRestart in the new // world is then a new possession, as before, and every reader already treats a null controller as "not yet". static void MpForgetPossessionIfWorldChanged() { if (!s_LastPossessedPC && !s_LastRestartPawn) return; void* World = *reinterpret_cast(Globals::BaseAddress + 0x06FC7F58); if (World == s_LastPossessedWorld) return; MpLog("[PossessionChain] world changed (captured=" + MpPtr(s_LastPossessedWorld) + " now=" + MpPtr(World) + "): forgetting the tracked controller " + MpPtr(s_LastPossessedPC) + " and pawn " + MpPtr(s_LastRestartPawn)); s_LastPossessedPC = nullptr; s_LastRestartPawn = nullptr; } // [2026-10-10 1.14.7 travel-crash probe, read-only, client only] The hunt -> Ramsgate crash (38_ §18.3, 22.4) is // UEngine::LoadMap (0x04271370) at 0x04272D28: `World()->PersistentLevel->GetOutermost()` with PersistentLevel null - // the shipping build drops the check() before LoadPackagesFully. It is the NEW world, and it faults 0.2 s after the // connect, far too soon for a disk load of Ramsgate (~4 s): LoadMap's FindPackage found the map already in memory. // Hypothesis: the previous Ramsgate UWorld survives the trip (something still references it); GC destroyed its level // and nulled PersistentLevel; on return LoadMap reuses that world. This census lists every UWorld at each world change // and 10 s / 60 s later: package, PersistentLevel, object flags, the GUObjectArray item flags (RootSet 0x40000000, // PendingKill 0x20000000, Unreachable 0x10000000) and how many objects still sit inside it. NO_WORLD_CENSUS.flag off. struct MpWorldCensusEntry { void* World; void* Outer; void* Level; uint32_t ObjectFlags; int32_t ItemFlags; int32_t Inside; }; static int MpWorldCensusScan(MpWorldCensusEntry* Out, int Max, int* OutObjects) { int Found = 0; *OutObjects = 0; __try { SDK::TUObjectArray* Arr = SDK::UObject::GObjects.operator->(); const int Count = Arr ? Arr->NumElements : 0; *OutObjects = Count; SDK::UClass* WorldClass = SDK::UWorld::StaticClass(); for (int i = 0; i < Count && Found < Max; ++i) { SDK::FUObjectItem* Chunk = Arr->Objects[i / SDK::TUObjectArray::ElementsPerChunk]; if (!Chunk) continue; SDK::FUObjectItem* Item = &Chunk[i % SDK::TUObjectArray::ElementsPerChunk]; UObject* Obj = Item->Object; if (!Obj || !Obj->Class) continue; const uint32_t Flags = *reinterpret_cast(reinterpret_cast(Obj) + 0x08); if (Flags & 0x10) continue; // RF_ClassDefaultObject if (!Obj->IsA(WorldClass)) continue; MpWorldCensusEntry& E = Out[Found++]; E.World = Obj; E.Outer = *reinterpret_cast(reinterpret_cast(Obj) + 0x20); E.Level = *reinterpret_cast(reinterpret_cast(Obj) + 0x30); // UWorld::PersistentLevel E.ObjectFlags = Flags; E.ItemFlags = *reinterpret_cast(reinterpret_cast(Item) + 0x08); E.Inside = 0; } // Second pass: objects whose Outer chain reaches each world (what the world still holds in memory). for (int i = 0; i < Count && Found > 0; ++i) { SDK::FUObjectItem* Chunk = Arr->Objects[i / SDK::TUObjectArray::ElementsPerChunk]; if (!Chunk) continue; UObject* Obj = Chunk[i % SDK::TUObjectArray::ElementsPerChunk].Object; if (!Obj) continue; void* O = *reinterpret_cast(reinterpret_cast(Obj) + 0x20); for (int Depth = 0; O && Depth < 16; ++Depth) { for (int w = 0; w < Found; ++w) { if (Out[w].World == O) { ++Out[w].Inside; O = nullptr; break; } } if (O) O = *reinterpret_cast(reinterpret_cast(O) + 0x20); } } } __except (EXCEPTION_EXECUTE_HANDLER) { return -Found - 1; } return Found; } // Load/destroy state of a UObject from its ObjectFlags and its GUObjectArray item flags (UE 4.26 values). static std::string MpObjectStateFlags(uint32_t ObjectFlags, int32_t ItemFlags) { std::string State; const struct { uint32_t Bit; const char* Name; } kItem[] = { { 0x40000000, "rootset" }, { 0x20000000, "pendingkill" }, { 0x10000000, "unreachable" }, { 0x08000000, "asyncloading" }, { 0x04000000, "async" } }; const struct { uint32_t Bit; const char* Name; } kObj[] = { { 0x400, "needload" }, { 0x1000, "needpostload" }, { 0x8000, "begindestroyed" }, { 0x10000, "finishdestroyed" }, { 0x80000, "wasloaded" }, { 0x200000, "loadcompleted" } }; for (const auto& F : kItem) if (static_cast(ItemFlags) & F.Bit) { State += F.Name; State += ','; } for (const auto& F : kObj) if (ObjectFlags & F.Bit) { State += F.Name; State += ','; } if (!State.empty()) State.pop_back(); return State; } static void MpWorldCensus(const char* Reason) { MpWorldCensusEntry Entries[64]{}; int Objects = 0; int Found = MpWorldCensusScan(Entries, 64, &Objects); const bool Faulted = Found < 0; if (Faulted) Found = -Found - 1; void* Current = *reinterpret_cast(Globals::BaseAddress + 0x06FC7F58); std::string Line = std::string("[WorldCensus] reason=") + Reason + " gworld=" + MpPtr(Current) + " objects=" + std::to_string(Objects) + " worlds=" + std::to_string(Found) + (Faulted ? " (scan faulted)" : ""); for (int i = 0; i < Found; ++i) { const MpWorldCensusEntry& E = Entries[i]; std::string Name = SafeObjectNameForDiagnostic(E.World); std::string Pkg = E.Outer ? SafeObjectNameForDiagnostic(E.Outer) : std::string("none"); const std::string State = MpObjectStateFlags(E.ObjectFlags, E.ItemFlags); // Compact: the package is only shown when it is not the world's own name. Line += " | " + MpPtr(E.World) + " " + Name + (Pkg != Name ? " pkg=" + Pkg : std::string()) + (E.World == Current ? " CURRENT" : "") + " level=" + (E.Level ? MpPtr(E.Level) : std::string("NULL")) + (State.empty() ? "" : " [" + State + "]") + " inside=" + std::to_string(E.Inside); } MpLog(Line); // A pending-kill world that is not the current one has outlived its map: name what keeps it (once per world), then // release its stuck keep flags so the next GC (LoadMap's full purge at the latest) frees it. void* Zombies[64]; int NumZombies = 0; for (int i = 0; i < Found; ++i) { if (Entries[i].World != Current && (Entries[i].ItemFlags & 0x20000000)) { MpReportZombieWorld(Entries[i].World, Reason); Zombies[NumZombies++] = Entries[i].World; } } if (NumZombies > 0 && strcmp(Reason, "travel-crash") != 0) MpReleaseZombieKeepFlags(Zombies, NumZombies, Reason); } static bool MpReadWorldStateSafe(void* Obj, uint32_t* ObjFlags, int32_t* ItemFlags, void** Outer, void** Level) { *ObjFlags = 0; *ItemFlags = 0; *Outer = nullptr; *Level = nullptr; __try { *ObjFlags = *reinterpret_cast(reinterpret_cast(Obj) + 0x08); const int32_t Index = *reinterpret_cast(reinterpret_cast(Obj) + 0x0C); *Outer = *reinterpret_cast(reinterpret_cast(Obj) + 0x20); *Level = *reinterpret_cast(reinterpret_cast(Obj) + 0x30); SDK::TUObjectArray* Arr = SDK::UObject::GObjects.operator->(); if (Arr && Index >= 0 && Index < Arr->NumElements) { SDK::FUObjectItem* Chunk = Arr->Objects[Index / SDK::TUObjectArray::ElementsPerChunk]; if (Chunk) { *ItemFlags = *reinterpret_cast( reinterpret_cast(&Chunk[Index % SDK::TUObjectArray::ElementsPerChunk]) + 0x08); } } return true; } __except (EXCEPTION_EXECUTE_HANDLER) { return false; } } static void MpLogTravelCrashState(CONTEXT* Context) { static std::atomic s_dumps{ 0 }; if (s_dumps.fetch_add(1) != 0) return; void* WorldContext = reinterpret_cast(Context->R12); void* World = nullptr; if (WorldContext && IsReadablePointer(WorldContext, 0x288)) { World = *reinterpret_cast(reinterpret_cast(WorldContext) + 0x280); } void* GWorldNow = *reinterpret_cast(Globals::BaseAddress + 0x06FC7F58); std::string Line = "[TravelCrash] LoadMap null PersistentLevel: context(r12)=" + MpPtr(WorldContext) + " world=" + MpPtr(World) + " gworld=" + MpPtr(GWorldNow); uint32_t ObjFlags = 0; int32_t ItemFlags = 0; void* Outer = nullptr; void* Level = nullptr; if (World && MpReadWorldStateSafe(World, &ObjFlags, &ItemFlags, &Outer, &Level)) { Line += " name=" + SafeObjectNameForDiagnostic(World) + " pkg=" + (Outer ? SafeObjectNameForDiagnostic(Outer) : std::string("none")) + " level=" + (Level ? MpPtr(Level) : std::string("NULL")) + " objFlags=" + MpHex(ObjFlags) + " item=" + MpHex(static_cast(ItemFlags)) + " [" + MpObjectStateFlags(ObjFlags, ItemFlags) + "]"; uint32_t PkgFlags = 0; int32_t PkgItem = 0; void* PkgOuter = nullptr; void* Unused = nullptr; if (Outer && MpReadWorldStateSafe(Outer, &PkgFlags, &PkgItem, &PkgOuter, &Unused)) { Line += " pkgFlags=" + MpHex(PkgFlags) + " pkgItem=" + MpHex(static_cast(PkgItem)) + " [" + MpObjectStateFlags(PkgFlags, PkgItem) + "]"; } } else { Line += " (world unreadable)"; } MpLog(Line); MpWorldCensus("travel-crash"); } // [2026-10-10 1.14.7 travel-crash probe, read-only] 0.2.123 caught the crash (21:14:50): LoadMap reused the PREVIOUS // Ramsgate UWorld. On the way out (Ramsgate -> Training) LoadMap unrooted it and GC destroyed its level, yet the world // survived as pendingkill with 3 objects still inside; a reachable inner object keeps its Outer (the world) alive, since // GC never eliminates outer references. On return FindPackage found the leftover and LoadMap reused it. This names what // is left inside a pending-kill world and every live object that points at it (direct fields, plus one level of TArray // contents), once per such world. A holder found nowhere is a native FGCObject reference. struct MpRefHit { void* Referencer; void* Target; uint32_t Offset; int32_t ArrayIndex; // -1 = the field itself, else an element of the TArray at Offset }; static bool MpPtrSetHas(void* const* Set, uintptr_t P) { if (!P) return false; for (int Probe = 0, Slot = static_cast((P >> 4) & 255); Probe < 256; ++Probe, Slot = (Slot + 1) & 255) { if (!Set[Slot]) return false; if (reinterpret_cast(Set[Slot]) == P) return true; } return false; } static void MpPtrSetAdd(void** Set, void* P) { for (int Probe = 0, Slot = static_cast((reinterpret_cast(P) >> 4) & 255); Probe < 256; ++Probe, Slot = (Slot + 1) & 255) { if (!Set[Slot] || Set[Slot] == P) { Set[Slot] = P; return; } } } static bool MpOuterChainReaches(void* Obj, void* World) { void* O = *reinterpret_cast(reinterpret_cast(Obj) + 0x20); for (int Depth = 0; O && Depth < 16; ++Depth) { if (O == World) return true; O = *reinterpret_cast(reinterpret_cast(O) + 0x20); } return false; } // One level of TArray-shaped memory; its own SEH frame so a false-positive header only skips itself. static int MpScanPtrArraySafe(uintptr_t Data, int32_t Num, void* const* Set, void* Referencer, uint32_t Offset, MpRefHit* Hits, int Found, int MaxHits) { __try { const uintptr_t* Elems = reinterpret_cast(Data); for (int32_t i = 0; i < Num && Found < MaxHits; ++i) { if (MpPtrSetHas(Set, Elems[i])) Hits[Found++] = { Referencer, reinterpret_cast(Elems[i]), Offset, i }; } } __except (EXCEPTION_EXECUTE_HANDLER) {} return Found; } static int MpZombieInnerScan(void* World, void** OutInner, int MaxInner) { int Found = 0; __try { SDK::TUObjectArray* Arr = SDK::UObject::GObjects.operator->(); const int Count = Arr ? Arr->NumElements : 0; for (int i = 0; i < Count && Found < MaxInner; ++i) { SDK::FUObjectItem* Chunk = Arr->Objects[i / SDK::TUObjectArray::ElementsPerChunk]; if (!Chunk) continue; void* Obj = Chunk[i % SDK::TUObjectArray::ElementsPerChunk].Object; if (Obj && Obj != World && MpOuterChainReaches(Obj, World)) OutInner[Found++] = Obj; } } __except (EXCEPTION_EXECUTE_HANDLER) {} return Found; } static int MpZombieRefScan(void* const* Set, void* World, MpRefHit* Hits, int MaxHits, int* OutScanned) { int Found = 0; *OutScanned = 0; __try { SDK::TUObjectArray* Arr = SDK::UObject::GObjects.operator->(); const int Count = Arr ? Arr->NumElements : 0; for (int i = 0; i < Count && Found < MaxHits; ++i) { SDK::FUObjectItem* Chunk = Arr->Objects[i / SDK::TUObjectArray::ElementsPerChunk]; if (!Chunk) continue; void* Obj = Chunk[i % SDK::TUObjectArray::ElementsPerChunk].Object; if (!Obj || MpPtrSetHas(Set, reinterpret_cast(Obj)) || MpOuterChainReaches(Obj, World)) continue; void* Cls = *reinterpret_cast(reinterpret_cast(Obj) + 0x10); if (!Cls) continue; int32_t Size = *reinterpret_cast(reinterpret_cast(Cls) + 0x58); // UStruct::Size if (Size < 0x30) continue; if (Size > 0x4000) Size = 0x4000; ++*OutScanned; for (uint32_t Off = 0x28; Off + 8 <= static_cast(Size) && Found < MaxHits; Off += 8) { const uintptr_t Q = *reinterpret_cast(reinterpret_cast(Obj) + Off); if (MpPtrSetHas(Set, Q)) { Hits[Found++] = { Obj, reinterpret_cast(Q), Off, -1 }; continue; } if (Q > 0x10000 && (Q & 7) == 0 && Off + 16 <= static_cast(Size)) { const int32_t Num = *reinterpret_cast(reinterpret_cast(Obj) + Off + 8); const int32_t Max = *reinterpret_cast(reinterpret_cast(Obj) + Off + 12); if (Num > 0 && Num <= Max && Max <= 4096) Found = MpScanPtrArraySafe(Q, Num, Set, Obj, Off, Hits, Found, MaxHits); } } } } __except (EXCEPTION_EXECUTE_HANDLER) {} return Found; } static std::string MpObjectAndClassName(void* Obj) { if (!Obj) return "null"; void* Cls = IsRegisteredLiveObject(Obj) ? *reinterpret_cast(reinterpret_cast(Obj) + 0x10) : nullptr; return SafeObjectNameForDiagnostic(Obj) + "(" + (Cls ? SafeObjectNameForDiagnostic(Cls) : std::string("?")) + ")"; } static void MpReportZombieWorld(void* World, const char* Reason) { static void* s_reported[16] = {}; for (void* P : s_reported) if (P == World) return; for (void*& P : s_reported) if (!P) { P = World; break; } void* Inner[48] = {}; const int NumInner = MpZombieInnerScan(World, Inner, 48); void* Set[256] = {}; MpPtrSetAdd(Set, World); void* Package = *reinterpret_cast(reinterpret_cast(World) + 0x20); if (Package) MpPtrSetAdd(Set, Package); std::string InnerList; for (int i = 0; i < NumInner; ++i) { MpPtrSetAdd(Set, Inner[i]); uint32_t Of = 0; int32_t It = 0; void* Ou = nullptr; void* Lv = nullptr; MpReadWorldStateSafe(Inner[i], &Of, &It, &Ou, &Lv); InnerList += " | " + MpPtr(Inner[i]) + " " + MpObjectAndClassName(Inner[i]) + " outer=" + MpObjectAndClassName(Ou) + " [" + MpObjectStateFlags(Of, It) + "]"; } MpLog(std::string("[ZombieWorld] reason=") + Reason + " world=" + MpPtr(World) + " " + MpObjectAndClassName(World) + " pkg=" + MpObjectAndClassName(Package) + " inside=" + std::to_string(NumInner) + InnerList); // The reference sweep is opt-in since 0.2.125: its one-level TArray reads treat byte arrays (UFunction::Script at // +0x60) as pointer arrays, so it reported stale heap words as holders and raised ~190 first-chance faults per // world (MpScanPtrArraySafe, 21:26:34), all logged by the VEH. The holder it was looking for is a keep flag, which // the inner list above already shows. static int s_refScan = -1; if (s_refScan < 0) s_refScan = MpExeRelativeFlagPresent(L"ZOMBIE_REF_SCAN.flag") ? 1 : 0; if (!s_refScan) return; MpRefHit Hits[32] = {}; int Scanned = 0; const uint64_t T0 = GetTickCount64(); const int NumHits = MpZombieRefScan(Set, World, Hits, 32, &Scanned); std::string HitList; for (int i = 0; i < NumHits; ++i) { HitList += " | " + MpObjectAndClassName(Hits[i].Referencer) + " +" + MpHex(Hits[i].Offset) + (Hits[i].ArrayIndex >= 0 ? "[" + std::to_string(Hits[i].ArrayIndex) + "]" : std::string()) + " -> " + MpObjectAndClassName(Hits[i].Target); } MpLog("[ZombieWorld] holders of " + MpPtr(World) + ": " + std::to_string(NumHits) + " reference(s) from " + std::to_string(Scanned) + " objects scanned in " + std::to_string(GetTickCount64() - T0) + " ms" + (NumHits == 0 ? " (none in UObject memory: a native FGCObject holds it)" : "") + HitList); } // [2026-10-10 1.14.7 travel-crash FIX] Root cause, proven on 0.2.124 (leftovers at 21:26:34 and 21:26:48, crash on the // second one at 21:27:14): every leftover world held exactly one chain, `default_input_bpc_C [pendingkill,async]` -> // its PlayerController -> PersistentLevel -> UWorld -> package. GUObjectArray item flag 0x04000000 is // EInternalObjectFlags::Async, one of GarbageCollectionKeepFlags: the 1.14.7 mark phase (0x027D9580 and its three // siblings pass FastKeepFlags = 0x0E000000) keeps such an object even when it is PendingKill, and GC never eliminates // Outer references, so one input component whose Async flag was never cleared keeps its whole world alive. LoadMap // runs a full-purge CollectGarbage (0x027DFE10, called at 0x042721A6 with KeepFlags 0, after the old world is detached // and before FindPackage), so clearing that stale bit lets the purge free the leftover and LoadMap loads the map fresh // instead of reusing a world whose PersistentLevel is gone (0x04272D28). // Touched: only objects that are PendingKill, not RootSet, carry Async without AsyncLoading, and whose Outer chain // reaches a pending-kill UWorld that is not GWorld; only the Async bit is cleared. AsyncLoading/RootSet holders are // counted, never changed. KEEP_STUCK_ASYNC_FLAGS.flag reverts (report only). static void* s_ZombieKeepReleased[16] = {}; static int s_ZombieKeepReleasedNext = 0; static bool MpZombieKeepFlagReleased(void* World) { for (void* P : s_ZombieKeepReleased) if (P && P == World) return true; return false; } static int MpWorldIndexInOuterChain(void* Obj, void* const* Worlds, int NumWorlds) { void* O = Obj; for (int Depth = 0; O && Depth < 17; ++Depth) { for (int w = 0; w < NumWorlds; ++w) if (Worlds[w] == O) return w; O = *reinterpret_cast(reinterpret_cast(O) + 0x20); } return -1; } // Objects inside the given worlds that carry a GC keep flag. Release = clear Async on the eligible ones (game thread). static int MpStuckAsyncScan(void* const* Worlds, int NumWorlds, bool Release, void** OutObj, int* OutWorld, int Max, int* OutLeftAlone) { int Found = 0; *OutLeftAlone = 0; __try { SDK::TUObjectArray* Arr = SDK::UObject::GObjects.operator->(); const int Count = Arr ? Arr->NumElements : 0; for (int i = 0; i < Count; ++i) { SDK::FUObjectItem* Chunk = Arr->Objects[i / SDK::TUObjectArray::ElementsPerChunk]; if (!Chunk) continue; SDK::FUObjectItem* Item = &Chunk[i % SDK::TUObjectArray::ElementsPerChunk]; void* Obj = Item->Object; if (!Obj) continue; volatile LONG* Flags = reinterpret_cast(reinterpret_cast(Item) + 0x08); const LONG F = *Flags; if (!(F & 0x4C000000)) continue; // no RootSet / AsyncLoading / Async const int W = MpWorldIndexInOuterChain(Obj, Worlds, NumWorlds); if (W < 0) continue; const bool Eligible = (F & 0x20000000) && !(F & 0x40000000) && (F & 0x04000000) && !(F & 0x08000000); if (!Eligible) { ++*OutLeftAlone; continue; } if (Release) InterlockedAnd(Flags, ~static_cast(0x04000000)); if (Found < Max) { OutObj[Found] = Obj; OutWorld[Found] = W; } ++Found; } } __except (EXCEPTION_EXECUTE_HANDLER) {} return Found; } static void MpReleaseZombieKeepFlags(void* const* Worlds, int NumWorlds, const char* Reason) { static int s_reportOnly = -1; if (s_reportOnly < 0) { s_reportOnly = MpExeRelativeFlagPresent(L"KEEP_STUCK_ASYNC_FLAGS.flag") ? 1 : 0; MpLog(std::string("[ZombieWorld] stuck Async keep-flag release ") + (s_reportOnly ? "OFF (KEEP_STUCK_ASYNC_FLAGS.flag; report only)" : "ON: leftover worlds are freed by the next GC instead of being reused by LoadMap")); } // A world released before this LoadMap that is still here after it survived the full purge: another holder. static void* s_survivorWarned[16] = {}; if (strcmp(Reason, "world-change") == 0) { for (int w = 0; w < NumWorlds; ++w) { if (!MpZombieKeepFlagReleased(Worlds[w])) continue; bool Warned = false; for (void* P : s_survivorWarned) if (P == Worlds[w]) Warned = true; if (Warned) continue; for (void*& P : s_survivorWarned) if (!P) { P = Worlds[w]; break; } MpLog("[ZombieWorld] " + MpPtr(Worlds[w]) + " " + SafeObjectNameForDiagnostic(Worlds[w]) + " survived LoadMap's full purge after its keep flag was released: something else holds it" " (ZOMBIE_REF_SCAN.flag lists candidates)"); } } void* Obj[16] = {}; int WorldOf[16] = {}; int LeftAlone = 0; const int N = MpStuckAsyncScan(Worlds, NumWorlds, !s_reportOnly, Obj, WorldOf, 16, &LeftAlone); static int s_lastLeftAlone = -1; if (N == 0 && (LeftAlone == 0 || LeftAlone == s_lastLeftAlone)) return; s_lastLeftAlone = LeftAlone; std::string List; for (int i = 0; i < N && i < 16; ++i) { List += " | " + MpObjectAndClassName(Obj[i]) + " in " + SafeObjectNameForDiagnostic(Worlds[WorldOf[i]]); if (!s_reportOnly && !MpZombieKeepFlagReleased(Worlds[WorldOf[i]])) { s_ZombieKeepReleased[s_ZombieKeepReleasedNext] = Worlds[WorldOf[i]]; s_ZombieKeepReleasedNext = (s_ZombieKeepReleasedNext + 1) % 16; } } MpLog(std::string("[ZombieWorld] reason=") + Reason + (s_reportOnly ? " FOUND (not released)" : " RELEASED") + " stuck Async keep flag on " + std::to_string(N) + " pending-kill object(s) in " + std::to_string(NumWorlds) + " leftover world(s); keep-flag holders left alone (AsyncLoading/RootSet/live): " + std::to_string(LeftAlone) + List); } // Live objects of the current world that carry Async/AsyncLoading at +10 s / +60 s: shows whether the input component // is born with the stale flag (who sets it) and predicts which world would have leaked. static int MpLiveAsyncScan(void* World, void** OutObj, int Max, int* OutAsyncLoading) { int Found = 0; *OutAsyncLoading = 0; __try { SDK::TUObjectArray* Arr = SDK::UObject::GObjects.operator->(); const int Count = Arr ? Arr->NumElements : 0; for (int i = 0; i < Count; ++i) { SDK::FUObjectItem* Chunk = Arr->Objects[i / SDK::TUObjectArray::ElementsPerChunk]; if (!Chunk) continue; SDK::FUObjectItem* Item = &Chunk[i % SDK::TUObjectArray::ElementsPerChunk]; void* Obj = Item->Object; if (!Obj) continue; const int32_t F = *reinterpret_cast(reinterpret_cast(Item) + 0x08); if (!(F & 0x0C000000)) continue; if (MpWorldIndexInOuterChain(Obj, &World, 1) < 0) continue; if (F & 0x08000000) ++*OutAsyncLoading; if (F & 0x04000000) { if (Found < Max) OutObj[Found] = Obj; ++Found; } } } __except (EXCEPTION_EXECUTE_HANDLER) {} return Found; } static void MpAsyncFlagCensus(const char* Reason) { void* World = *reinterpret_cast(Globals::BaseAddress + 0x06FC7F58); if (!World) return; void* Obj[8] = {}; int AsyncLoading = 0; const int N = MpLiveAsyncScan(World, Obj, 8, &AsyncLoading); std::string List; for (int i = 0; i < N && i < 8; ++i) { uint32_t Of = 0; int32_t It = 0; void* Ou = nullptr; void* Lv = nullptr; MpReadWorldStateSafe(Obj[i], &Of, &It, &Ou, &Lv); List += " | " + MpObjectAndClassName(Obj[i]) + " outer=" + MpObjectAndClassName(Ou) + " [" + MpObjectStateFlags(Of, It) + "]"; } MpLog(std::string("[AsyncFlagCensus] reason=") + Reason + " world=" + SafeObjectNameForDiagnostic(World) + " async=" + std::to_string(N) + " asyncloading=" + std::to_string(AsyncLoading) + List); } // Client (from ProcessEventClientHook, every 250 ms): a census on every GWorld change and 10 s / 60 s after it. static void MpWorldCensusTick() { static int s_disabled = -1; if (s_disabled < 0) { s_disabled = MpExeRelativeFlagPresent(L"NO_WORLD_CENSUS.flag") ? 1 : 0; MpLog(std::string("[WorldCensus] ") + (s_disabled ? "OFF (NO_WORLD_CENSUS.flag)" : "ON: every UWorld at each world change, +10 s and +60 s (client)")); } if (s_disabled) return; static void* s_world = nullptr; static uint64_t s_changedAtMs = 0; static int s_stage = 0; // 0 = idle, 1 = +10 s pending, 2 = +60 s pending void* World = *reinterpret_cast(Globals::BaseAddress + 0x06FC7F58); const uint64_t Now = GetTickCount64(); if (World != s_world) { s_world = World; s_changedAtMs = Now; s_stage = 1; MpWorldCensus("world-change"); return; } if (s_stage == 1 && Now - s_changedAtMs >= 10000) { s_stage = 2; MpWorldCensus("arrival+10s"); MpAsyncFlagCensus("arrival+10s"); } else if (s_stage == 2 && Now - s_changedAtMs >= 60000) { s_stage = 0; MpWorldCensus("arrival+60s"); MpAsyncFlagCensus("arrival+60s"); } } // A natural OnInputModeChanged can arrive before the new map's LocalPlayer/PC/Pawn chain has // remained stable long enough for compatibility calls. Remember that edge and replay the input // activation only after the existing travel-settle gate passes; never advance the cinematic from // the timeout-only path. static bool s_ArchonInputActivationPending = false; static bool s_NaturalInputModeChangedObserved = false; // [1.14.7 FIX 2026-10-10, 38_ §21] The game's own latest OnInputModeChanged value for the current // possession (-1 none yet, 0 input taken away, 1 control handed to the player) and when it arrived. // Events raised from inside TriggerArchonInputActivation (EnableCharacterInput fires the same event) // are ours and are not recorded. On a hunt the first natural edge is false - the arrival cinematic // taking input - and true only arrives when the cinematic ends; the airship screen does the same. static int s_LastNaturalGameInput = -1; static uint64_t s_LastNaturalGameInputMs = 0; static int s_ArchonInputActivationDepth = 0; static bool s_ProgressionHudRefreshPending = false; static uint32_t s_ProgressionHudRefreshAttempts = 0; static uint64_t s_ProgressionHudRefreshNotBeforeMs = 0; // [2026-07-27 CLIENT TRAVEL STABILITY] The ProcessEvent client pump must not chase a freshly // replaced controller/pawn merely because both addresses are readable. During Ramsgate -> hunt the // old controller is detached, UObject slots are recycled, and the new Player/Controller/Pawn chain // converges over several frames. Require both an exact live ownership chain and a bounded settle // interval before invoking any compatibility lifecycle callback. static UObject* s_ClientRolePumpStablePC = nullptr; static UObject* s_ClientRolePumpStablePawn = nullptr; static uint64_t s_ClientRolePumpStableSinceMs = 0; static constexpr uint64_t kClientRolePumpTravelSettleMs = 3000; static constexpr uint64_t kClientRolePumpStableChainMs = 750; // [2026-10-10 1.14.7 CPU] ExpectedPlayerCount roster sync for ONE object (the world's GameMode or GameState). // Same checks, compare-then-write and one-time log text as the GObjects walk it replaces (see the roster block in // GameEngineTickHook); a null, unregistered, CDO or wrong-class pointer writes nothing. static void MpSyncExpectedPlayerField(SDK::UObject* Obj, SDK::UClass* Cls, uintptr_t Offset, const char* Tag, const char* OffsetText, bool& Logged, int32_t Value) { if (!Obj || !Cls || !IsRegisteredLiveObject(Obj) || Obj->IsDefaultObject() || !Obj->Class || !IsRegisteredLiveObject(Obj->Class) || !Obj->IsA(Cls)) { return; } int32_t* Field = reinterpret_cast(reinterpret_cast(Obj) + Offset); if (!IsReadablePointer(Field, sizeof(int32_t))) return; const int32_t Before = *Field; if (Before == Value) return; *Field = Value; if (!Logged) { Logged = true; MpLog(std::string("[ExpectedPlayerCount@") + Tag + "] synchronized instance=" + MpPtr(Obj) + " class=" + Obj->Class->GetName() + " before=" + std::to_string(Before) + " after=" + std::to_string(Value) + " offset=" + OffsetText); } } // [2026-10-10 1.14.7 CPU] Read-only, one-shot cross-check for the world-pointer roster sync: walks GObjects ONCE and // logs how many non-CDO AArchonGameMode / AArchonGameState instances exist and whether each is the world's // AuthorityGameMode (+0x118) / GameState (+0x120). The fast path is accepted only once hub, Training and hunt logs // show gameModes=1 gameStates=1 with match=1 each. Called from the game thread (the tick hook) only. static void MpExpectedPlayerWorldCensus(const char* When) { if (!SDK::UObject::GObjects) return; SDK::UWorld* World = SDK::UWorld::GetWorld(); void* WorldGM = nullptr; void* WorldGS = nullptr; if (World && IsReadablePointer(World, 0x128)) { WorldGM = *reinterpret_cast(reinterpret_cast(World) + 0x118); WorldGS = *reinterpret_cast(reinterpret_cast(World) + 0x120); } int GameModes = 0, GameStates = 0, GameModeMatch = 0, GameStateMatch = 0; std::string Detail; 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; const bool IsGM = Obj->IsA(SDK::AArchonGameMode::StaticClass()); const bool IsGS = !IsGM && Obj->IsA(SDK::AArchonGameState::StaticClass()); if (!IsGM && !IsGS) continue; const bool Match = IsGM ? (Obj == WorldGM) : (Obj == WorldGS); if (IsGM) { ++GameModes; if (Match) ++GameModeMatch; } else { ++GameStates; if (Match) ++GameStateMatch; } if (GameModes + GameStates <= 8) { Detail += std::string(" ") + (IsGM ? "GM" : "GS") + "=" + MpPtr(Obj) + "/" + (Obj->Class ? Obj->Class->GetName() : std::string("?")) + (Match ? "(world)" : "(NOT world)"); } } MpLog(std::string("[ExpectedPlayerCount] census ") + When + " world=" + MpPtr(World) + " worldGM=" + MpPtr(WorldGM) + " worldGS=" + MpPtr(WorldGS) + " gameModes=" + std::to_string(GameModes) + " match=" + std::to_string(GameModeMatch) + " gameStates=" + std::to_string(GameStates) + " match=" + std::to_string(GameStateMatch) + " objects=" + std::to_string(Count) + Detail); } // [2026-10-10 1.14.7] Blueprint TickFilterHelper answered as a dedicated server - OPT-IN, default OFF. // UArchonGameplayStatics::TickFilterHelper (native 0x019480B0, .pdata entry, 208 bytes; its only caller is the exec thunk // 0x0229DB70, so only the Blueprint VM reaches it) decides whether a Blueprint "tick" branch runs: rcx=Actor, dl=Where // (ECityExecFilter: IslandOnly 0, CityOnly 1, Both 2), r8b=Whom (ERemoteExecFilter: LocalOnly 0, RemoteOnly 1, // LocalOrRemote 2, ServerOnly 3, LocalOrServer 4, All 5); al = result. Decoded on the 1.14.7 exe (same logic as 1.12 // 0x01823000): WherePasses = Where==Both || ((Where==CityOnly) == world-is-city); Whom 0 and 4 pass only for an // AutonomousProxy (Role +0xF0 == 2), 1 only for a non-autonomous actor (Authority included), 2 and 5 always, and 3 // (ServerOnly) NEVER - the client build has no NetMode branch. Every pawn on our servers is ROLE_Authority, so ServerOnly // and LocalOrServer branches never run there and RemoteOnly/LocalOrRemote branches always do. // What is proven: the native logic above. What is NOT proven: which Blueprint sites (TickStamina, TickBleedout, the // 0.5 s UpdateLastValidPlayerTransform edge-recovery timer, ...) sit behind which filter on 1.14.7 - the bytecode is // Oodle-compressed in the pak - and the "dedicated-server answer" table below is a semantic reading of the enum names // (an official UE_SERVER native cannot be checked in this binary). The census shows where the answers differ, not which // one is right. // Two independent opt-in modes; the hook is installed ONLY when one of the flags is present at InitServerHooks, and // only on DeployServer instances (AuthMode GlobalGameserver) with the no-local-player patch applied. The P2P host // (HuntCapability) is excluded. With neither flag: no hook, no behaviour change, the manual TickStamina stays. // TICK_FILTER_CENSUS.flag - diagnostic only. The game's answer is returned unchanged. Calls are counted per Blueprint // call site (exec-thunk hook: FFrame.Node = calling script function, script offset of the // first parameter) with the game answer next to the dedicated-server answer, logged once a // minute as [TickFilterCensus]; each calling function's Script bytes are dumped once for an // offline Kismet review; [EdgeRecoveryProbe] reads the edge-recovery fields every 10 s. // SERVER_TICK_FILTER.flag - fix. Only for ROLE_Authority actors that IsA BP_PlayerCharacter_C (the combat anim BP passes // the pawn as Actor too): ServerOnly / LocalOrServer / All -> WherePasses, LocalOnly / // RemoteOnly / LocalOrRemote -> false. Every other actor (behemoths, pets, NPCs, any // Blueprint the partial SDK dump does not show) gets the game's answer. While it is active // the manual per-connection TickStamina is skipped (the character's own tick runs it; both // would evaluate stamina twice per frame). SUBTRACTIVE TOO: RemoteOnly/LocalOrRemote branches // that run on our servers today stop - review the census "stop" rows before enabling. // Both flags together: fix answers plus the census. The hook takes no lock, writes no log and allocates nothing outside // census mode; it forwards to the game once DLL detach has begun. All FFrame/UFunction reads are SEH-guarded in // MpTickFilterReadFrame. using TickFilterHelperFn = bool(__fastcall*)(void* Actor, uint8_t Where, uint8_t Whom); using TickFilterExecFn = void(__fastcall*)(void* Context, void* Stack, void* Result); static TickFilterHelperFn OrigTickFilterHelper = nullptr; static TickFilterExecFn OrigTickFilterExec = nullptr; static std::atomic g_ServerTickFilterActive{ false }; // SERVER_TICK_FILTER.flag and the filter hook enabled static std::atomic g_TickFilterCensus{ false }; // TICK_FILTER_CENSUS.flag and the filter hook enabled static std::atomic g_TickFilterPlayerClass{ nullptr }; // BP_PlayerCharacter_C, cached on the game thread // Per-thread call-site context, set by the exec-thunk hook (census only) for the duration of one native call. thread_local void* t_TfSiteNode = nullptr; thread_local int32_t t_TfSiteOffset = -1; thread_local bool t_TfSiteValid = false; thread_local int32_t t_TfSiteNodeIndex = -1; // the Node's GUObjectArray slot (UObject::Index @ 0x0C) at capture time // SEH-guarded FFrame read: Node +0x10 (calling UFunction), Code +0x20; UStruct::Script data +0x60 / count +0x68 (CANDIDATE // offsets, UE4.26 layout; the thunk itself uses +0x18/+0x20/+0x80). Offset = Code - Script.Data when Code lies inside the // script, else -1 (which also exposes a wrong Script offset in the census). OutIndex = the Node's UObject::Index (+0x0C), // recorded so the once-a-minute log can tell a still-live function from a freed or reused address. No C++ objects here (__try). __declspec(noinline) static bool MpTickFilterReadFrame(const void* Frame, void** OutNode, int32_t* OutOffset, int32_t* OutIndex) { __try { const uint8_t* F = static_cast(Frame); void* Node = *reinterpret_cast(F + 0x10); const uint8_t* Code = *reinterpret_cast(F + 0x20); int32_t Offset = -1; int32_t Index = -1; if (Node) Index = *reinterpret_cast(static_cast(Node) + 0x0C); if (Node && Code) { const uint8_t* N = static_cast(Node); const uint8_t* Script = *reinterpret_cast(N + 0x60); const int32_t Num = *reinterpret_cast(N + 0x68); if (Script && Num > 0 && Code >= Script && Code <= Script + Num) Offset = static_cast(Code - Script); } *OutNode = Node; *OutOffset = Offset; *OutIndex = Index; return true; } __except (EXCEPTION_EXECUTE_HANDLER) { return false; } } // SEH-guarded read of a UFunction's Script array (+0x60 data, +0x68 count). Copies at most Cap bytes into Out. __declspec(noinline) static int32_t MpTickFilterReadScript(const void* Node, uint8_t* Out, int32_t Cap, const void** OutData) { __try { const uint8_t* N = static_cast(Node); const uint8_t* Script = *reinterpret_cast(N + 0x60); const int32_t Num = *reinterpret_cast(N + 0x68); *OutData = Script; if (!Script || Num <= 0) return 0; if (!Out) return Num; const int32_t Copy = Num < Cap ? Num : Cap; memcpy(Out, Script, static_cast(Copy)); return Copy; } __except (EXCEPTION_EXECUTE_HANDLER) { return -1; } } struct MpTickFilterSiteRow { void* Node; int32_t NodeIndex; int32_t Offset; uint8_t Where; uint8_t Whom; uint64_t Calls, GamePass, ServerPass, Stop, Start, NoServerAnswer, OffThread, Total; }; static MpTickFilterSiteRow g_TfSites[64] = {}; static int g_TfSiteCount = 0; static uint64_t g_TfOverflow = 0; static volatile LONG64 g_TfFrameFaults = 0; static SRWLOCK g_TfLock = SRWLOCK_INIT; // The dedicated-server answer, or -1 when the hook gives the game's answer (not an authority player character, Whom out // of range, or the player class not cached yet). Reads only +0xF0 (the byte the native reads) and the object's class chain. static int MpTickFilterServerAnswer(void* Actor, uint8_t Where, uint8_t Whom) { if (!Actor || Whom > 5) return -1; if (reinterpret_cast(Actor)[0xF0] != 3 /*ROLE_Authority*/) return -1; UClass* PlayerClass = g_TickFilterPlayerClass.load(std::memory_order_acquire); if (!PlayerClass || !reinterpret_cast(Actor)->IsA(PlayerClass)) return -1; const bool WherePasses = OrigTickFilterHelper(Actor, Where, 5 /*All: Whom always passes, so this is the Where test*/); switch (Whom) { case 3: case 4: case 5: return WherePasses ? 1 : 0; // ServerOnly, LocalOrServer, All default: return 0; // LocalOnly, RemoteOnly, LocalOrRemote: no local player here } } static void MpTickFilterCount(uint8_t Where, uint8_t Whom, bool Game, int Server) { void* Node = t_TfSiteValid ? t_TfSiteNode : nullptr; const int32_t NodeIndex = t_TfSiteValid ? t_TfSiteNodeIndex : -1; const int32_t Offset = t_TfSiteValid ? t_TfSiteOffset : -1; const bool OffThread = GetCurrentThreadId() != GameTickThreadId; const bool Effective = Server >= 0 ? Server == 1 : Game; // what SERVER_TICK_FILTER.flag would answer AcquireSRWLockExclusive(&g_TfLock); MpTickFilterSiteRow* Row = nullptr; for (int i = 0; i < g_TfSiteCount; ++i) { MpTickFilterSiteRow& R = g_TfSites[i]; if (R.Node == Node && R.NodeIndex == NodeIndex && R.Offset == Offset && R.Where == Where && R.Whom == Whom) { Row = &R; break; } } if (!Row && g_TfSiteCount < static_cast(sizeof(g_TfSites) / sizeof(g_TfSites[0]))) { Row = &g_TfSites[g_TfSiteCount++]; *Row = MpTickFilterSiteRow{}; Row->Node = Node; Row->NodeIndex = NodeIndex; Row->Offset = Offset; Row->Where = Where; Row->Whom = Whom; } if (Row) { ++Row->Calls; ++Row->Total; if (Game) ++Row->GamePass; if (Effective) ++Row->ServerPass; if (Game && !Effective) ++Row->Stop; if (!Game && Effective) ++Row->Start; if (Server < 0) ++Row->NoServerAnswer; if (OffThread) ++Row->OffThread; } else { ++g_TfOverflow; } ReleaseSRWLockExclusive(&g_TfLock); } static bool __fastcall TickFilterHelperDetour(void* Actor, uint8_t Where, uint8_t Whom) { if (MpProcessDetaching()) return OrigTickFilterHelper(Actor, Where, Whom); const bool Fix = g_ServerTickFilterActive.load(std::memory_order_relaxed); if (!g_TickFilterCensus.load(std::memory_order_relaxed)) { if (!Fix) return OrigTickFilterHelper(Actor, Where, Whom); const int Server = MpTickFilterServerAnswer(Actor, Where, Whom); return Server >= 0 ? Server == 1 : OrigTickFilterHelper(Actor, Where, Whom); } const bool Game = OrigTickFilterHelper(Actor, Where, Whom); const int Server = MpTickFilterServerAnswer(Actor, Where, Whom); MpTickFilterCount(Where, Whom, Game, Server); return (Fix && Server >= 0) ? Server == 1 : Game; } // Census only: records the calling script function and offset for the filter call made inside this thunk. static void __fastcall TickFilterExecDetour(void* Context, void* Stack, void* Result) { void* PrevNode = t_TfSiteNode; const int32_t PrevOffset = t_TfSiteOffset; const bool PrevValid = t_TfSiteValid; const int32_t PrevIndex = t_TfSiteNodeIndex; void* Node = nullptr; int32_t Offset = -1; int32_t Index = -1; if (!MpProcessDetaching() && Stack && MpTickFilterReadFrame(Stack, &Node, &Offset, &Index)) { t_TfSiteNode = Node; t_TfSiteOffset = Offset; t_TfSiteNodeIndex = Index; t_TfSiteValid = true; } else { t_TfSiteNode = nullptr; t_TfSiteOffset = -1; t_TfSiteNodeIndex = -1; t_TfSiteValid = false; if (Stack && !MpProcessDetaching()) InterlockedIncrement64(&g_TfFrameFaults); } OrigTickFilterExec(Context, Stack, Result); t_TfSiteNode = PrevNode; t_TfSiteOffset = PrevOffset; t_TfSiteNodeIndex = PrevIndex; t_TfSiteValid = PrevValid; } static bool MpTickFilterHookActive() { return g_ServerTickFilterActive.load(std::memory_order_relaxed) || g_TickFilterCensus.load(std::memory_order_relaxed); } // Game thread: the BP class only exists once the map has loaded, so it is cached from the first connection pawn that the // existing per-connection loop has already matched with IsA(ABP_PlayerCharacter_C::StaticClass()). Until then the fix // answers like the game and the manual TickStamina keeps running. static void MpTickFilterCachePlayerClass(UClass* PlayerClass) { if (!PlayerClass || !MpTickFilterHookActive() || g_TickFilterPlayerClass.load(std::memory_order_relaxed)) return; g_TickFilterPlayerClass.store(PlayerClass, std::memory_order_release); MpLog("[TickFilter] BP_PlayerCharacter_C class cached=" + MpPtr(PlayerClass) + (g_ServerTickFilterActive.load(std::memory_order_relaxed) ? std::string("; dedicated-server answers now active for player characters") : std::string("; census now reports the dedicated-server answer for player characters"))); } static const char* MpTickFilterWhomName(uint8_t Whom) { static const char* const Names[6] = { "LocalOnly", "RemoteOnly", "LocalOrRemote", "ServerOnly", "LocalOrServer", "All" }; return Whom < 6 ? Names[Whom] : "?"; } static const char* MpTickFilterWhereName(uint8_t Where) { static const char* const Names[3] = { "IslandOnly", "CityOnly", "Both" }; return Where < 3 ? Names[Where] : "?"; } // Census only, game thread: dumps a calling function's Script bytes once to \tickfilter_script_port_.bin. // In-memory Kismet holds raw object pointers where the pak has import indices; the logged base and name let an offline // reader relate them. // Keyed by the function's full name, not its address: a freed function's address can be reused by another one. // The caller has already checked that Node is still the live UFunction it recorded (MpTickFilterNodeStillLive). static void MpTickFilterDumpScriptOnce(void* Node, const std::string& Name) { static std::string s_dumped[32]; static int s_count = 0; for (int i = 0; i < s_count; ++i) if (s_dumped[i] == Name) return; if (s_count >= 32) return; s_dumped[s_count++] = Name; const void* Data = nullptr; const int32_t Num = MpTickFilterReadScript(Node, nullptr, 0, &Data); if (Num <= 0 || Num > (8 << 20)) { MpLog("[TickFilterCensus] script dump skipped fn=" + Name + " num=" + std::to_string(Num) + " (unreadable/empty/too big)"); return; } std::vector Bytes(static_cast(Num)); const int32_t Got = MpTickFilterReadScript(Node, Bytes.data(), Num, &Data); if (Got != Num) { MpLog("[TickFilterCensus] script dump failed fn=" + Name + " got=" + std::to_string(Got) + " of " + std::to_string(Num)); return; } char Path[MAX_PATH]; sprintf_s(Path, "%stickfilter_script_port%d_%d.bin", MpLogDir(), Globals::Port, s_count); std::ofstream File(Path, std::ios::binary | std::ios::trunc); if (File.is_open()) File.write(reinterpret_cast(Bytes.data()), Num); MpLog("[TickFilterCensus] script dump fn=" + Name + " node=" + MpPtr(Node) + " script=" + MpPtr(const_cast(Data)) + " bytes=" + std::to_string(Num) + " file=" + Path + (File.good() ? "" : " (WRITE FAILED)")); } // [2026-10-10 1.14.7] Census only, game thread (GC runs on this thread, so the answer holds until the caller returns): is // the Node recorded up to 60 s ago still the same live UFunction? It may have been garbage-collected since (a streamed // sublevel or an unloaded Blueprint class) and its memory reused, and GetFullName walks Class/Outer/FName unguarded. // Requires the GObjects round trip, the same slot index as at capture, and a live class that IsA UFunction. static bool MpTickFilterNodeStillLive(void* Node, int32_t NodeIndex) { if (!Node || NodeIndex < 0 || !IsReadablePointer(Node, 0x70) || !IsRegisteredLiveObject(Node)) return false; UObject* Obj = reinterpret_cast(Node); if (Obj->Index != NodeIndex || !Obj->Class || !IsRegisteredLiveObject(Obj->Class)) return false; return Obj->IsA(UFunction::StaticClass()); } // Census only, game thread, once a minute: one line per call site with calls in the window, then the window resets. // "stop" = the game passes, the dedicated-server answer would not (branches that run on our servers today and would stop // under SERVER_TICK_FILTER.flag); "start" = the reverse (branches that never run today). static void MpTickFilterCensusTick() { if (!g_TickFilterCensus.load(std::memory_order_relaxed)) return; static ULONGLONG s_windowStart = 0; const ULONGLONG Now = GetTickCount64(); if (s_windowStart == 0) { s_windowStart = Now; return; } if (Now - s_windowStart < 60000) return; const double Seconds = static_cast(Now - s_windowStart) / 1000.0; s_windowStart = Now; MpTickFilterSiteRow Rows[64]; int Count = 0; uint64_t Overflow = 0; AcquireSRWLockExclusive(&g_TfLock); Count = g_TfSiteCount; for (int i = 0; i < Count; ++i) { Rows[i] = g_TfSites[i]; MpTickFilterSiteRow& R = g_TfSites[i]; R.Calls = R.GamePass = R.ServerPass = R.Stop = R.Start = R.NoServerAnswer = R.OffThread = 0; } Overflow = g_TfOverflow; ReleaseSRWLockExclusive(&g_TfLock); const uint64_t FrameFaults = static_cast(InterlockedCompareExchange64( reinterpret_cast(&g_TfFrameFaults), 0, 0)); uint64_t Calls = 0, Stop = 0, Start = 0; int Active = 0; for (int i = 0; i < Count; ++i) { const MpTickFilterSiteRow& R = Rows[i]; if (R.Calls == 0) continue; ++Active; Calls += R.Calls; Stop += R.Stop; Start += R.Start; std::string Fn = "(no frame)"; bool Live = false; if (R.Node) { Live = MpTickFilterNodeStillLive(R.Node, R.NodeIndex); Fn = Live ? reinterpret_cast(R.Node)->GetFullName() : std::string("(stale)"); } char Line[256]; sprintf_s(Line, " off=%d where=%s whom=%s calls=%llu game=%llu server=%llu stop=%llu start=%llu noServerAnswer=%llu offThread=%llu total=%llu", R.Offset, MpTickFilterWhereName(R.Where), MpTickFilterWhomName(R.Whom), static_cast(R.Calls), static_cast(R.GamePass), static_cast(R.ServerPass), static_cast(R.Stop), static_cast(R.Start), static_cast(R.NoServerAnswer), static_cast(R.OffThread), static_cast(R.Total)); MpLog("[TickFilterCensus] site fn=" + Fn + Line); if (Live) MpTickFilterDumpScriptOnce(R.Node, Fn); } char Summary[256]; sprintf_s(Summary, "[TickFilterCensus] %.1fs: sites=%d (known %d) calls=%llu stop=%llu start=%llu overflow=%llu frameFaults=%llu playerClass=%s mode=%s", Seconds, Active, Count, static_cast(Calls), static_cast(Stop), static_cast(Start), static_cast(Overflow), static_cast(FrameFaults), g_TickFilterPlayerClass.load(std::memory_order_relaxed) ? "cached" : "not-yet", g_ServerTickFilterActive.load(std::memory_order_relaxed) ? "server-answers" : "game-answers"); MpLog(Summary); } // Read-only, either tick-filter flag, game thread, every 10 s per connected player pawn: does the server ever run the edge // recovery recorder? EdgeRecoveryTransformTimer (+0x1910, FTimerHandle, 0 = never set), LastValidPlayerTransform translation // (+0x1880 + 0x10), BleedOutTImer (+0xE68) - BP_PlayerCharacter_C offsets, identical in the 1.12 and 1.14.7 SDKs. // Expected with the game's answers: edgeTimer=0 and a stale lastValid. Unreadable fields are logged as such, not skipped. static void MpEdgeRecoveryProbe(AActor* Pawn, int32_t ConnIndex) { const uintptr_t P = reinterpret_cast(Pawn); std::string Line = "[EdgeRecoveryProbe] conn=" + std::to_string(ConnIndex) + " pawn=" + MpPtr(Pawn); char Buf[160]; if (IsReadablePointer(reinterpret_cast(P + 0x1910), 8)) { sprintf_s(Buf, " edgeTimer=0x%llX", static_cast(*reinterpret_cast(P + 0x1910))); Line += Buf; } else Line += " edgeTimer=unreadable"; bool HaveLast = false; float Last[3] = {}; if (IsReadablePointer(reinterpret_cast(P + 0x1890), 16)) { memcpy(Last, reinterpret_cast(P + 0x1890), sizeof(Last)); HaveLast = true; sprintf_s(Buf, " lastValid=(%.0f,%.0f,%.0f)", Last[0], Last[1], Last[2]); Line += Buf; } else Line += " lastValid=unreadable"; const FVector Loc = Pawn->K2_GetActorLocation(); sprintf_s(Buf, " pawnLoc=(%.0f,%.0f,%.0f)", Loc.X, Loc.Y, Loc.Z); Line += Buf; if (HaveLast) { const float dx = Loc.X - Last[0], dy = Loc.Y - Last[1], dz = Loc.Z - Last[2]; sprintf_s(Buf, " dist=%.0f", std::sqrt(dx * dx + dy * dy + dz * dz)); Line += Buf; } if (IsReadablePointer(reinterpret_cast(P + 0xE68), 8)) { sprintf_s(Buf, " bleedTimer=0x%llX", static_cast(*reinterpret_cast(P + 0xE68))); Line += Buf; } else Line += " bleedTimer=unreadable"; MpLog(Line); } // [1.14.7 FIX 2026-10-04] The engine pointer, captured from the tick hook itself. // // The manual net tick has always called 0x00A82CF0 with Networking::NetDriver as the first argument: // reinterpret_cast(BaseAddress + 0x00A82CF0)(NetDriver, DeltaTime); // but that function is NOT a net-driver dispatch - it is a ~4.6 KB engine/world tick whose first argument // is a UGameEngine* (the same shape this hook receives). Passing the driver produced the measured // behaviour: it read the socket (recvfrom <- 127.0.0.1:52155 len=29, because the bogus base happened to // yield a usable world) and then faulted at [0+0x88]: // ManualTickDispatchSEH code=0xC0000005 params=0x0,0x88 regs{rax=0x0 ...} // 0x88 is UNetDriver::ServerConnection, which Networking::Listen deliberately nulls - i.e. the code was // following a client-style path off a garbage base. Calling it with the real engine is the correct fix. // [2026-10-11 1.14.7 O0] Replication-graph sizes for [PerfDetail], every 2 s, game thread, read-only. Offsets verified on // 1.14.7 (see MpDriveReplicationGraph and the relevance-settings layout block): NetDriver+0x6E8 graph (its +0x30 must // be the driver), graph Connections {Data +0x38, Num +0x40}, GlobalActorReplicationInfoMap Num +0xC0+0x08, each // connection manager's actor-info map Num +0x30+0x08, NetDriver ClientConnections {Data +0x90, Num +0x98} and each // UNetConnection's OpenChannels.Num +0x78. Every read goes through the SEH helpers; nothing is written. static void MpPerfSampleGraphCounts() { if (!Globals::AmServer || !Networking::NetDriver || !MpPerfDetailEnabled()) return; static uint64_t s_lastMs = 0; const uint64_t Now = GetTickCount64(); if (s_lastMs != 0 && Now - s_lastMs < 2000) return; s_lastMs = Now; const uintptr_t Driver = reinterpret_cast(Networking::NetDriver); const uintptr_t Graph = SafeReadPtr(Driver, 0x6E8); if (!Graph || SafeReadPtr(Graph, 0x30) != Driver) return; const int32_t Conns = SafeReadI32At(Graph, 0x40); const uintptr_t ConnData = SafeReadPtr(Graph, 0x38); int32_t InfoSum = 0, InfoMax = 0; for (int32_t i = 0; ConnData && i < Conns && i < 1024; ++i) { const uintptr_t Mgr = SafeReadPtr(ConnData, static_cast(i) * sizeof(void*)); if (!Mgr) continue; const int32_t N = SafeReadI32At(Mgr, 0x38); if (N < 0 || N > 1000000) continue; InfoSum += N; if (N > InfoMax) InfoMax = N; } const int32_t Clients = SafeReadI32At(Driver, 0x98); const uintptr_t ClientData = SafeReadPtr(Driver, 0x90); int32_t ChSum = 0, ChMax = 0; for (int32_t i = 0; ClientData && i < Clients && i < 1024; ++i) { const uintptr_t Conn = SafeReadPtr(ClientData, static_cast(i) * sizeof(void*)); if (!Conn) continue; const int32_t N = SafeReadI32At(Conn, 0x78); if (N < 0 || N > 1000000) continue; ChSum += N; if (N > ChMax) ChMax = N; } g_perfGraphConns.store(Conns, std::memory_order_relaxed); g_perfGraphGlobalActors.store(SafeReadI32At(Graph, 0xC8), std::memory_order_relaxed); g_perfGraphActorInfoSum.store(InfoSum, std::memory_order_relaxed); g_perfGraphActorInfoMax.store(InfoMax, std::memory_order_relaxed); g_perfClientConns.store(Clients, std::memory_order_relaxed); g_perfOpenChannelsSum.store(ChSum, std::memory_order_relaxed); g_perfOpenChannelsMax.store(ChMax, std::memory_order_relaxed); g_perfGraphSampleMs.store(Now, std::memory_order_relaxed); } void GameEngineTickHook(UGameEngine* GameEngine, float DeltaTime, char CanRender) { // [2026-10-10 1.14.7 Perf] Frame interval for the [Perf] line (entry to entry, so it includes the engine's own frame // wait). One QPC read and a few relaxed atomics; no logging, allocation or lock on the game thread. if (Globals::AmServer) { LARGE_INTEGER PerfEntry{}; QueryPerformanceCounter(&PerfEntry); MpPerfFrameSample(PerfEntry.QuadPart); // [2026-10-11 1.14.7 O3] New pointer-check epoch for this frame (see MpReadableThisFrame). if (g_MpReadableCacheMode < 0) { g_MpReadableCacheMode = MpExeRelativeFlagPresent(L"NO_FRAME_POINTER_CACHE.flag") ? 0 : 1; MpLog(std::string("[FramePointerCache] ") + (g_MpReadableCacheMode ? "ON: repeated net-driver/connection/graph pointer checks answered once per " "frame segment (NO_FRAME_POINTER_CACHE.flag reverts)" : "OFF (NO_FRAME_POINTER_CACHE.flag): every check calls VirtualQuery")); } g_MpReadableCacheThread = GetCurrentThreadId(); MpReadableCacheNextEpoch(); } if (GameEngine != nullptr && g_GameEnginePtr == nullptr) { g_GameEnginePtr = GameEngine; } static uint64_t tickCounter = 0; ++tickCounter; // [DIAG 2026-07-09] Record the game-tick thread once so the exit-path hooks can // report whether TerminateProcess/ExitProcess is being called from the tick thread // (normal shutdown) or a background thread (libcef watchdog, etc.). DWORD CurTid = GetCurrentThreadId(); InterlockedCompareExchange( reinterpret_cast(&GameTickThreadId), static_cast(CurTid), 0); static bool tickHookLogged = false; if (!tickHookLogged) { tickHookLogged = true; MpLog("[GameEngineTick] first entry GameEngine=" + MpPtr(GameEngine) + " DeltaTime=" + std::to_string(DeltaTime) + " CanRender=" + std::to_string(static_cast(CanRender)) + " DoListen=" + std::to_string(Globals::DoListen ? 1 : 0) + " Listening=" + std::to_string(Globals::Listening ? 1 : 0) + " tid=" + std::to_string(CurTid)); } // [1.14.7 client replication membership 2026-10-05] Bounded, read-only server probe: does the // replication graph's per-connection actor-info map ever hold the GameState and the PlayerState? // [2026-10-05] GATED OFF BY DEFAULT. Both of these walk the whole GObjects array (with several IsA // calls per object) every couple of seconds on the hub. Measured the same day: hubs started from the // builds that had them always-on began dying with the game-side access violation // `Dauntless-Win64-Shipping.exe+0x1091D5D` about two minutes in, while the earlier builds (which had // only the cheap pointer-read probes) did not. The faulting RIP is in the game binary, so this is a // load/timing trigger rather than a fault in these functions, but a diagnostic must not destabilise // the hub it is measuring. ENABLE_HUB_REP_DIAGNOSTICS.flag turns them back on for a dedicated run. // Split gating: the route function is cheap while no client is connected (it bails before it touches // GObjects) and it carries the only fix lever this session produced, so it may run for either flag. // The membership report is the heavy one (a full GObjects walk plus an IsA pass per object, several // times a minute) and only ever runs for the diagnostics flag. const bool HubRepDiag = Globals::AmServer && MpExeRelativeFlagPresent(L"ENABLE_HUB_REP_DIAGNOSTICS.flag"); if (Globals::AmServer && (HubRepDiag || MpExeRelativeFlagPresent(L"ENABLE_BASE_ACTOR_BOOTSTRAP.flag"))) { MpRouteBaseActorsToConnections(); } if (HubRepDiag) { MpReportReplicationMembership(); } // The optional player-hosted mode may be ending the process (P2PHooks.h): skip the tick. if (Globals::AuthMode == Globals::ServerAuthMode::HuntCapability && P2PHooks::TickShutdown()) { return; } // [1.12.0] Force dedicated-server mode BEFORE the engine ticks. The client build's boot code // resets GIsServer/GIsClient back to client flags after Init() sets them once, so the net // driver was rejecting incoming connections (join -> "Failure received"). Re-assert every // frame here as the per-frame replacement for the disabled in-code patches (see InitServerHooks // #if 0 block above) so networking always ticks with server flags set. if (Globals::AmServer) { *(uint8_t*)(Globals::BaseAddress + 0x06E1EADA) = 0x1; // GIsServer *(uint8_t*)(Globals::BaseAddress + 0x06E1EAD9) = 0x0; // GIsClient } SanitizeNetDriverBeforeEngineTick(); // [2026-10-10 1.14.7 Perf] Time the native engine tick for the [Perf] line; the call itself is unchanged. LARGE_INTEGER PerfEngine0{}; if (Globals::AmServer) { MpReadableCacheNextEpoch(); // [2026-10-11 O3] the engine tick (and its GC) is its own pointer-check epoch QueryPerformanceCounter(&PerfEngine0); } reinterpret_cast(OrigGameEngineTick)(GameEngine, DeltaTime, CanRender); if (Globals::AmServer) { LARGE_INTEGER PerfEngine1{}; QueryPerformanceCounter(&PerfEngine1); if (PerfEngine1.QuadPart > PerfEngine0.QuadPart) { g_perfEngineCalls.fetch_add(1, std::memory_order_relaxed); g_perfEngineTicksSum.fetch_add(static_cast(PerfEngine1.QuadPart - PerfEngine0.QuadPart), std::memory_order_relaxed); if (MpPerfDetailEnabled()) MpPerfPhaseAdd(g_perfPhaseEngine, PerfEngine0.QuadPart, PerfEngine1.QuadPart); } MpReadableCacheNextEpoch(); // [2026-10-11 O3] nothing remembered from inside the engine tick survives it } // [1.14.7 REPLICATION-GRAPH DRIVE] The engine's own TickFlush never dispatches to // UNetDriver::ReplicationDriver on this build, so drive the Archon graph here, once per frame, // immediately after the engine tick has finished its own net pass. See MpDriveReplicationGraph. if (Globals::AmServer) { DropStalePlayerControllerChannelQuarantine(); } // [2026-10-11 1.14.7 O0] Timed for [PerfDetail]; the call itself is unchanged. const bool PerfDetail = Globals::AmServer && MpPerfDetailEnabled(); const int64_t PerfGraph0 = PerfDetail ? MpQpcNow() : 0; MpDriveReplicationGraph(DeltaTime); if (PerfDetail) { MpPerfPhaseAdd(g_perfPhaseGraph, PerfGraph0, MpQpcNow()); MpPerfSampleGraphCounts(); } // [1.14.7 FIX 2026-10-10] Resume progression components parked before the configuration loaded. MpProgressionWaitPoll(); MpArrivalTriggerServerProbe(); // read-only diagnostic, 40 s after each login MpHuntPassServerStateTick(); // read-only diagnostic, every 10 s, logs changes only // [2026-07-20] Server-side skeletal pose/socket fix (FORCE_SERVER_MESH_POSE.flag). Server-only; the // helper self-throttles + no-ops unless the flag is present. See its definition above. if (Globals::AmServer) { ForceServerMeshPose(); } // [2026-07-20 TRIALS GRACE DIAG] Throttled bleedout sampler (SampleBleedoutGrace). Flag re-checked every // 256 ticks (incl. tick 1); sampled every 8 ticks while enabled. Server-only, read-only, __try-guarded. if (Globals::AmServer) { static uint32_t s_graceTick = 0; static bool s_graceOn = false; ++s_graceTick; if ((s_graceTick & 0xFF) == 1) s_graceOn = MpExeRelativeFlagPresent(L"TRIALS_GRACE_DIAG.flag"); if (s_graceOn && (s_graceTick & 7) == 0) { SampleBleedoutGraceGuarded(); } } // [2026-07-21] Server-side timeout-retry for ApplyPlayerRole (Omnicell/PlayerRole HUD gap). // See ApplyPlayerRoleHook + TickPlayerRoleRetries below for the full writeup. Throttled the // same way as the grace sampler above; the retry itself is further throttled per-entry inside // TickPlayerRoleRetries (kPlayerRoleRetryIntervalMs), so this outer check just avoids walking // the (usually empty) pending table every single tick. if (Globals::AmServer) { static uint32_t s_roleRetryTick = 0; if ((++s_roleRetryTick & 0x3F) == 0) { // ~once/sec at 60fps TickPlayerRoleRetries(); TickPlayerRolePostActivationRefresh(); TickTempestModifierEnsure(); TickEscalationBuffChoiceSpawn(); } } // [2026-07-24 TEMPEST CHARGE/STACK DIAG] Sampled on a finer cadence than the ~1/sec block above: // a tap-V activation drops the charge for a single frame then it passively refills, so a 1/sec // sample would routinely miss the drop. Sample ~8x/sec; the function itself edge-triggers logging // (activation / CanActivate flip / 5s heartbeat) so the log stays sparse. No-ops unless // TEMPEST_CHARGE_DIAG.flag is present next to the exe. if (Globals::AmServer) { static uint32_t s_tempestDiagTick = 0; if ((++s_tempestDiagTick & 0x7) == 0) { TickTempestChargeDiag(); } } // [2026-07-24] The ArchonInputActivate timeout fallback and the weapon-XP HUD refresh retry // that used to be gated on "!Globals::AmServer" right here NEVER RAN, on any process: this // hook's own MH_CreateHook is only called from InitServerHooks (see Init() near the bottom of // this file), so a real client process never installs this hook at all and the branch was dead // code — proven by a live capture showing zero [WeaponXP][HudInit] lines, not even the // unconditional heartbeat added specifically to prove the function was being reached. Both are // now driven from ProcessEventClientHook's existing client-local retry pump instead, which // already correctly works around this exact fact (see its 2026-07-22 OMNICELL comment). // [1.12.0] The pre-tick write above gets wiped out somewhere inside OrigGameEngineTick itself // (confirmed via [NetDiag]: it still read GIsServer=0/GIsClient=1 AFTER the original tick // returned, even though we forced 1/0 right before calling it). Re-force here too, immediately // before TickNetworking/TickDispatch actually process the connection, so the net driver sees // server flags at the moment it matters. if (Globals::AmServer) { *(uint8_t*)(Globals::BaseAddress + 0x06E1EADA) = 0x1; // GIsServer *(uint8_t*)(Globals::BaseAddress + 0x06E1EAD9) = 0x0; // GIsClient } // [Group B cleanup 2026-07-13] Timed server LoadManager/lifecycle GObjects-dump block removed // (DumpLifecycleState + DumpLoadManagerViaGObjects — obsolete Phase-0 readiness scanners, gated off). // [2026-10-10 1.14.7 CPU] F7 GObjects dump: client-only. GetAsyncKeyState reads the shared desktop key state, so // F7 pressed in ANY window made every local hub/hunt walk all ~208k objects and then busy-wait on its game thread // until the key was released (no tick, no net, no graph drive). This hook is installed only by InitServerHooks, // so on today's builds the handler never runs at all. if (!Globals::AmServer && GetAsyncKeyState(VK_F7)) { for (int i = 0; i < SDK::UObject::GObjects->Num(); i++) { SDK::UObject* Obj = SDK::UObject::GObjects->GetByIndex(i); if (!Obj) continue; if (Obj->IsA(SDK::AActor::StaticClass())) { AActor* Quest = (AActor*)Obj; if (Quest->Role != ENetRole::ROLE_Authority) { std::cout << (int)(uint8_t)Quest->Role << std::endl; std::cout << Quest->GetFullName() << std::endl; } } } while (GetAsyncKeyState(VK_F7)) { } } if (Globals::Listening) { // [1.12.0 player-host roster gate] Keep ExpectedPlayerCount synchronized with the // concrete launch roster. The previous blanket zero forced a two-player hunt to enter // InProgress as soon as P1 arrived; an SDR joiner arriving a few seconds later then hit // the fragile late-join PlayerController-channel path and closed control channel 0. // A launch string with two `uid:huntId` entries must therefore remain at 2 until both // initial party members arrive. Persistent hubs carry NO_EXPECTED_PLAYERS and remain 0. // // Verified per SDK/Archon_classes.hpp (static_asserted in Assertions.inl): // AArchonGameMode::ExpectedPlayerCount at +0x0498 (Archon_classes.hpp:16653) // AArchonGameState::ExpectedPlayerCount at +0x02E8 (Archon_classes.hpp:18683, Net-replicated) // Ramsgate/Dojo use AArchonGameMode_City and AArchonGameMode_Island, both of which // inherit from AArchonGameMode, so IsA(AArchonGameMode::StaticClass()) covers them. // // Rewritten every frame as belt-and-braces against InitGame or replication restoring the native // default of 1. We keep the existing UArchonLoadManager::LoadFailed compatibility hook unchanged. // [2026-10-10 1.14.7 CPU] The objects now come from the world itself: GWorld->AuthorityGameMode (+0x118) // and GWorld->GameState (+0x120), the only GameMode the engine consults and the only GameState it // replicates. The previous lookup walked every GObjects entry (~208k on Ramsgate) with two IsA calls each // on EVERY frame (the old comment called that "cheap, ~one-time"; it was neither). Same values, same // fields, same compare-then-write, same one-time log text. A frame with no readable world writes nothing // and the next frame writes. MpExpectedPlayerWorldCensus logs a one-shot cross-check (10 s after listen // and at the first K2_PostLogin). LEGACY_EXPECTED_PLAYER_WALK.flag (read once) restores the full walk. // [A/B 2026-07-16] DISABLE_EXPECTED_PLAYER_ZERO.flag — when present next to the game .exe, // skip synchronization so the native session/load-manager state is left intact for an // A/B. Cached once (this runs every tick). Default OFF (absent flag = roster sync). static int s_disableExpectedZero = -1; static int s_launchExpectedPlayers = -1; if (s_disableExpectedZero < 0) { s_disableExpectedZero = MpExeRelativeFlagPresent(L"DISABLE_EXPECTED_PLAYER_ZERO.flag") ? 1 : 0; s_launchExpectedPlayers = LaunchExpectedPlayerCount(); MpLog(std::string("[ExpectedPlayerCount] DISABLE_EXPECTED_PLAYER_ZERO.flag ") + (s_disableExpectedZero ? "PRESENT -> roster sync DISABLED (native session state preserved)" : "absent -> roster sync ENABLED (default)") + " launchRosterCount=" + std::to_string(s_launchExpectedPlayers)); } static int s_legacyExpectedWalk = -1; if (s_legacyExpectedWalk < 0) { s_legacyExpectedWalk = MpExeRelativeFlagPresent(L"LEGACY_EXPECTED_PLAYER_WALK.flag") ? 1 : 0; MpLog(std::string("[ExpectedPlayerCount] lookup = ") + (s_legacyExpectedWalk ? "full GObjects walk every frame (LEGACY_EXPECTED_PLAYER_WALK.flag present)" : "world pointers GWorld+0x118 / +0x120 (default)")); } static bool s_loggedGameMode = false; static bool s_loggedGameState = false; if (s_disableExpectedZero == 0 && s_legacyExpectedWalk == 0) { SDK::UWorld* World = SDK::UWorld::GetWorld(); // Offsets::GWorld 0x06FC7F58 if (World && IsReadablePointer(World, 0x128)) { const uintptr_t W = reinterpret_cast(World); MpSyncExpectedPlayerField(*reinterpret_cast(W + 0x118), SDK::AArchonGameMode::StaticClass(), 0x0498, "GameMode", "+0x0498", s_loggedGameMode, s_launchExpectedPlayers); MpSyncExpectedPlayerField(*reinterpret_cast(W + 0x120), SDK::AArchonGameState::StaticClass(), 0x02E8, "GameState", "+0x02E8", s_loggedGameState, s_launchExpectedPlayers); } } // One-shot read-only census (both lookups): 10 s after the first listening frame, and once after the // first K2_PostLogin (g_postLoginTimeMs is set by the server ProcessEvent hook). { static uint64_t s_censusListenMs = 0; static bool s_censusListenDone = false; static bool s_censusLoginDone = false; const uint64_t NowMs = GetTickCount64(); if (s_censusListenMs == 0) s_censusListenMs = NowMs; if (!s_censusListenDone && NowMs - s_censusListenMs >= 10000) { s_censusListenDone = true; MpExpectedPlayerWorldCensus("listen+10s"); } if (!s_censusLoginDone && g_postLoginTimeMs.load(std::memory_order_relaxed) != 0) { s_censusLoginDone = true; MpExpectedPlayerWorldCensus("first-postlogin"); } } if (s_disableExpectedZero == 0 && s_legacyExpectedWalk == 1 && SDK::UObject::GObjects) { const int Count = SDK::UObject::GObjects->Num(); for (int i = 0; i < Count; i++) { SDK::UObject* Obj = SDK::UObject::GObjects->GetByIndex(i); if (!Obj) continue; if (Obj->IsDefaultObject()) continue; if (Obj->IsA(SDK::AArchonGameMode::StaticClass())) { int32_t* Field = reinterpret_cast( reinterpret_cast(Obj) + 0x0498); if (IsReadablePointer(Field, sizeof(int32_t))) { int32_t Before = *Field; if (Before != s_launchExpectedPlayers) { *Field = s_launchExpectedPlayers; if (!s_loggedGameMode) { s_loggedGameMode = true; MpLog("[ExpectedPlayerCount@GameMode] synchronized instance=" + MpPtr(Obj) + " class=" + Obj->Class->GetName() + " before=" + std::to_string(Before) + " after=" + std::to_string(s_launchExpectedPlayers) + " offset=+0x0498"); } } } } if (Obj->IsA(SDK::AArchonGameState::StaticClass())) { int32_t* Field = reinterpret_cast( reinterpret_cast(Obj) + 0x02E8); if (IsReadablePointer(Field, sizeof(int32_t))) { int32_t Before = *Field; if (Before != s_launchExpectedPlayers) { *Field = s_launchExpectedPlayers; if (!s_loggedGameState) { s_loggedGameState = true; MpLog("[ExpectedPlayerCount@GameState] synchronized instance=" + MpPtr(Obj) + " class=" + Obj->Class->GetName() + " before=" + std::to_string(Before) + " after=" + std::to_string(s_launchExpectedPlayers) + " offset=+0x02E8"); } } } } } } static int netTickTraceBudget = 180; int32_t preConnectionCount = -1; int32_t preConnectionMax = -1; if (Networking::NetDriver && MpReadableThisFrame(reinterpret_cast(reinterpret_cast(Networking::NetDriver) + 0x98), sizeof(int32_t) * 2)) { preConnectionCount = *reinterpret_cast(reinterpret_cast(Networking::NetDriver) + 0x98); preConnectionMax = *reinterpret_cast(reinterpret_cast(Networking::NetDriver) + 0x9C); } // [A/B 2026-07-16] Edge-triggered connection-count trace. The budget trace below only // covers the first 180 boot ticks (all connCount=0), so it never captured the 2-player // join. Log ONLY when the client-connection count changes, so every join/leave is visible // for the whole session without the per-tick spam removed in Phase 0k. This is the // primary evidence for the A/B: does connCount actually reach 2, and when? // Publish the remote connection count for the empty-instance watchdog (see g_wdConnCount). if (preConnectionCount >= 0) { g_wdConnCount.store(preConnectionCount, std::memory_order_relaxed); const uint64_t ConnNowMs = GetTickCount64(); g_wdConnStampMs.store(ConnNowMs, std::memory_order_relaxed); if (preConnectionCount > 0) { g_wdHadRemoteClient.store(true, std::memory_order_relaxed); g_wdLastRemoteConnMs.store(ConnNowMs, std::memory_order_relaxed); // [2026-10-10 RAM] trim re-arm } } { static int32_t s_lastConnCount = -2; // -2 = uninitialized (distinct from -1 = unreadable) if (preConnectionCount != s_lastConnCount) { MpLog("[NetConnEdge] tick=" + std::to_string(tickCounter) + " connCount " + std::to_string(s_lastConnCount) + " -> " + std::to_string(preConnectionCount) + " connMax=" + std::to_string(preConnectionMax) + " NetDriver=" + MpPtr(Networking::NetDriver)); // [2026-07-16] Snapshot game-mode/session/state at every join/leave edge so the // P1-leave -> P2-enable transition (and how the one-expected-player init gates P2) // is visible in the log. // [2026-10-11 O2] World-pointer lookup now (LogArchonLifecycle); off with PRODUCTION_LOG_PROFILE.flag. if (!MpProductionLogProfile()) { LogArchonLifecycle(("ConnEdge " + std::to_string(s_lastConnCount) + "->" + std::to_string(preConnectionCount)).c_str()); } s_lastConnCount = preConnectionCount; } } // [1.14.7 2026-10-05] Self-throttled to one line per 2s and only while a client connection // exists; runs on both hub ports but logs nothing until someone joins. MpSendPathDiag(); // [2026-07-16] MatchState-change trace: catch GameMode MatchState transitions between // connection edges (e.g. WaitingToStart -> InProgress, and whatever flips at P1-leave to // enable P2). Cheap: once the GameMode is cached, each tick compares one int (the FName // ComparisonIndex at +0x2C0); only on a change do we ToString + snapshot. The GObjects walk // to (re)find the GameMode is throttled to ~1/sec and only runs while it is missing/stale. { static AArchonGameMode* s_gm = nullptr; static uint64_t s_lastFindMs = 0; static int32_t s_lastMatchIdx = -0x7fffffff; uint64_t nowMs = static_cast(GetTickCount64()); // Readability alone does not establish UObject liveness: after a world transition, // a stale GameMode pointer can still reference mapped memory while its Class field // has already been invalidated. Validate both UObject slots before IsA dereferences // the class/superclass chain; a stale cache then falls through to the GObjects lookup. bool gmOk = s_gm && IsRegisteredLiveObject(s_gm) && IsReadablePointer(s_gm, 0x4A0) && IsRegisteredLiveObject(s_gm->Class) && s_gm->IsA(AArchonGameMode::StaticClass()); if (!gmOk && (nowMs - s_lastFindMs) > 1000) { s_lastFindMs = nowMs; s_gm = nullptr; // [2026-10-11 1.14.7 O2] The world's AuthorityGameMode (+0x118) instead of a ~208k-object walk once a // second while it is missing (boot, map change). LEGACY_LIFECYCLE_WALK.flag keeps the walk. if (!MpLegacyLifecycleWalk()) { SDK::UObject* WorldGm = nullptr; SDK::UObject* WorldGs = nullptr; MpWorldGameModeAndState(WorldGm, WorldGs); s_gm = reinterpret_cast(WorldGm); } else if (SDK::UObject::GObjects) { const int n = SDK::UObject::GObjects->Num(); for (int i = 0; i < n; i++) { SDK::UObject* o = SDK::UObject::GObjects->GetByIndex(i); if (!o || o->IsDefaultObject()) { continue; } if (o->IsA(AArchonGameMode::StaticClass())) { s_gm = reinterpret_cast(o); break; } } } gmOk = s_gm && IsReadablePointer(s_gm, 0x4A0); } if (gmOk) { int32_t idx = *reinterpret_cast(reinterpret_cast(s_gm) + 0x02C0); if (idx != s_lastMatchIdx) { std::string ms = reinterpret_cast(reinterpret_cast(s_gm) + 0x02C0)->ToString(); MpLog("[MatchStateChange] tick=" + std::to_string(tickCounter) + " -> " + ms + " (idx " + std::to_string(s_lastMatchIdx) + "->" + std::to_string(idx) + ")"); if (!MpProductionLogProfile()) LogArchonLifecycle(("MatchState=" + ms).c_str()); // [2026-10-11 O2/O3] s_lastMatchIdx = idx; } } } // [2026-07-19] VERBOSE_DIAG-gated: budget alone still emitted a burst every launch. bool traceNetTick = VerboseDiag() && netTickTraceBudget > 0; // [Phase 0k] budget-only; removed "|| preConnectionCount>0" which kept it on every tick forever (21k lines/min, contaminated lag) if (traceNetTick && netTickTraceBudget > 0) { --netTickTraceBudget; } if (traceNetTick) { MpLog("[NetTickTrace] tick=" + std::to_string(tickCounter) + " before TickNetworking NetDriver=" + MpPtr(Networking::NetDriver) + " connCount=" + std::to_string(preConnectionCount) + " connMax=" + std::to_string(preConnectionMax) + " GIsServer=" + std::to_string(*(uint8_t*)(Globals::BaseAddress + 0x06E1EADA)) + " GIsClient=" + std::to_string(*(uint8_t*)(Globals::BaseAddress + 0x06E1EAD9))); } Networking::TickNetworking(); if (traceNetTick) { MpLog("[NetTickTrace] tick=" + std::to_string(tickCounter) + " after TickNetworking"); } if (traceNetTick) { MpLog("[NetTickTrace] tick=" + std::to_string(tickCounter) + " before TickDispatch fn=" + MpAddress(reinterpret_cast(Globals::BaseAddress + 0x00A82CF0))); } // [2026-07-19] MANUAL_TICK_HALF_RATE: skip the manual drive on odd frames so the graph is // invoked ~once per network frame. Counter is per-process and only advances here. static uint64_t s_manualTickFrame = 0; const bool SkipManualDriveThisFrame = ManualTickHalfRate() && ((++s_manualTickFrame & 1ull) != 0ull); bool tickDispatchOk = true; if (SkipManualDriveThisFrame) { tickDispatchOk = false; // nothing dispatched this frame by us; native tick still ran } else if (!SanitizeNetDriverClientConnections(Networking::NetDriver, "GameEngineTickBeforeTickDispatch")) { MpLog("[NetTickTrace] tick=" + std::to_string(tickCounter) + " skipped TickDispatch after sanitizing unsafe NetDriver state"); tickDispatchOk = false; } // [2026-07-19] MANUAL_TICK_ZERO_DT: pass 0 so the manual call does not advance the // NetDriver's ElapsedTime a second time this frame. See ManualTickZeroDeltaTime(). else if (s_inManualTick) { // [1.14.7 FIX 2026-10-04] Re-entrant tick. The manual drive routes through the world/net path, // which can come back here. Without this the recursion is unbounded and the failure is stack // exhaustion rather than a bad pointer: "[ManualTickDispatchSEH] code=0xC0000005 // address=ucrtbase.dll+0xD8AE params=0x0,0xFFFFFFFFFFFFFFFF", whose bytes are // "66 0F 7F 85 F0 03 00 00 movdqa [rbp+0x3F0], xmm0" - a CRT routine writing a large stack frame // and faulting on the frame itself. Re-entering is never useful, so do nothing this tick. } else { // [1.14.7 CORRECTION 2026-10-04] s_inManualTick is now actually SET. It was declared and tested // but never assigned, so the guard above was inert - a correction from the audit, and the reason // "reentrancy guard added" had no observable effect. It is cleared on every path, so the call is // wrapped in an explicit block rather than folded into the condition. s_inManualTick = true; const int64_t PerfDispatch0 = PerfDetail ? MpQpcNow() : 0; // [2026-10-11 O0] timed for [PerfDetail] const bool TickOk = SafeManualTickDispatch(Networking::NetDriver, ManualTickZeroDeltaTime() ? 0.0f : DeltaTime); if (PerfDetail) MpPerfPhaseAdd(g_perfPhaseDispatch, PerfDispatch0, MpQpcNow()); MpReadableCacheNextEpoch(); // [2026-10-11 O3] the dispatch ran game code: fresh pointer checks after it s_inManualTick = false; if (!TickOk) { ReportManualNetTickFailure(false, tickCounter); tickDispatchOk = false; } else { ReportManualNetTickRecovery(false, tickCounter); } } if (traceNetTick) { int32_t postConnectionCount = -1; if (Networking::NetDriver && IsReadablePointer(reinterpret_cast(reinterpret_cast(Networking::NetDriver) + 0x98), sizeof(int32_t))) { postConnectionCount = *reinterpret_cast(reinterpret_cast(Networking::NetDriver) + 0x98); } MpLog("[NetTickTrace] tick=" + std::to_string(tickCounter) + " after TickDispatch ok=" + std::to_string(tickDispatchOk ? 1 : 0) + " connCount=" + std::to_string(postConnectionCount)); } // [1.12.0] Send-side pair for TickDispatch. Original UGameEngine::Tick path drives // TickFlush via UWorld::Tick -> UNetDriver::TickFlush, but our custom net-tick path // was only calling TickDispatch. Without this, ServerReplicateActors never runs and // per-connection outgoing queues never flush -- the client sees the loading screen // freeze because NMT_Welcome / pawn replication / "loading done" packets sit queued // on the server forever (observed as InSeq climbing while NextOutGoingSeq is frozen). if (traceNetTick) { MpLog("[NetTickTrace] tick=" + std::to_string(tickCounter) + " before TickFlush fn=" + MpAddress(reinterpret_cast(Globals::BaseAddress + 0x03FAE700))); } bool tickFlushOk = true; if (SkipManualDriveThisFrame) { tickFlushOk = false; } else if (!SanitizeNetDriverClientConnections(Networking::NetDriver, "GameEngineTickBeforeTickFlush")) { MpLog("[NetTickTrace] tick=" + std::to_string(tickCounter) + " skipped TickFlush after sanitizing unsafe NetDriver state"); tickFlushOk = false; } else { // [2026-10-11 1.14.7 O0] Timed for [PerfDetail]; same call, same failure/recovery reporting. const int64_t PerfFlush0 = PerfDetail ? MpQpcNow() : 0; const bool FlushOk = SafeManualTickFlush(Networking::NetDriver, DeltaTime); if (PerfDetail) MpPerfPhaseAdd(g_perfPhaseFlush, PerfFlush0, MpQpcNow()); MpReadableCacheNextEpoch(); // [2026-10-11 O3] if (!FlushOk) { ReportManualNetTickFailure(true, tickCounter); tickFlushOk = false; } else { ReportManualNetTickRecovery(true, tickCounter); } } if (traceNetTick) { MpLog("[NetTickTrace] tick=" + std::to_string(tickCounter) + " after TickFlush ok=" + std::to_string(tickFlushOk ? 1 : 0)); } static bool netDiagLogged = false; if (!netDiagLogged) { netDiagLogged = true; MpLog(std::string("[NetDiag] GIsServer=") + std::to_string(*(uint8_t*)(Globals::BaseAddress + 0x06E1EADA)) + " GIsClient=" + std::to_string(*(uint8_t*)(Globals::BaseAddress + 0x06E1EAD9))); } } // [1.14.7 A/B 2026-10-03] The hub logs "WorldContext requested with invalid context object" from // tick 1 onwards, immediately after this manual Listen, and that message does not exist in any // archived 1.12 hub log. Everything downstream (player-data loaders never completing, the map // reload failing, the port closing) is consistent with an invalid world context, so this flag // isolates whether OUR listen is what invalidates it: with the flag present the manual Listen is // skipped entirely and the boot can be compared. Diagnostic only. if (Globals::DoListen && MpExeRelativeFlagPresent(L"DISABLE_MANUAL_LISTEN.flag")) { Globals::DoListen = false; MpLog("[GameEngineTick] manual Networking::Listen SKIPPED (DISABLE_MANUAL_LISTEN.flag) - A/B"); } // [1.14.7 2026-10-03] Engine-listener path: the map URL carries ?listen (GetGameDefaultMap), so // the engine creates the GameNetDriver during map load. Skip the manual path and announce READY // only once UWorld::NetDriver (UWorld+0x38) actually exists; the harness independently confirms // the port is bound, so this never announces a listener that is not there. // [2026-10-11] Never on a player-hosted server (MpMapUrlListenEnabled): it listens manually below. if (Globals::DoListen && MpMapUrlListenEnabled()) { Globals::DoListen = false; Globals::WaitingForEngineListener = true; MpLog("[GameEngineTick] manual Listen skipped; waiting for the ENGINE listener (?listen map URL)"); } if (Globals::WaitingForEngineListener) { void* ListenWorld = UWorld::GetWorld(); void* EngineNetDriver = ListenWorld ? *reinterpret_cast(reinterpret_cast(ListenWorld) + 0x38) // UWorld::NetDriver : nullptr; if (EngineNetDriver != nullptr) { Globals::WaitingForEngineListener = false; Globals::Listening = true; MpLog("[GameEngineTick] engine NetDriver present at " + MpPtr(EngineNetDriver) + " (UWorld+0x38); announcing READY"); Networking::AnnounceEngineReady(static_cast(Globals::Port)); StartEmptyWatchdogThread(); return; } } if (Globals::DoListen) { Globals::DoListen = false; MpLog("[GameEngineTick] calling Networking::Listen(port=" + std::to_string(Globals::Port) + ")"); const bool ListenReady = Networking::Listen(UEngine::GetEngine(), Globals::Port); MpLog(std::string("[GameEngineTick] Networking::Listen returned ") + (ListenReady ? "OK" : "FAIL")); if (!ListenReady) { if (Globals::AuthMode == Globals::ServerAuthMode::HuntCapability) { P2PHooks::OnListenFailed(); } return; } Globals::Listening = true; MpLog("[GameEngineTick] Listening armed; skipping post-listen watchdog this tick"); // [2026-07-18] Kick off the tick-independent empty-server watchdog now that this instance is a // live listen server. Runs once; self-terminates the process after 50s continuous emptiness even // if the game/net tick stalls when the last player leaves (the failure that leaked hunt slots). StartEmptyWatchdogThread(); return; } if (Globals::Listening && Networking::NetDriver) { bool HasConnection = false; auto ClientConnectionData = *reinterpret_cast((uintptr_t)Networking::NetDriver + 0x90); int32_t ClientConnectionCount = *reinterpret_cast((uintptr_t)Networking::NetDriver + 0x98); int32_t ClientConnectionMax = *reinterpret_cast((uintptr_t)Networking::NetDriver + 0x9C); if (!IsSanePointerArray(ClientConnectionData, ClientConnectionCount, ClientConnectionMax, 1024)) { MpLog("[GameEngineTick] Invalid ClientConnections array; skipping watchdog/stamina"); } else { for (int32_t Index = 0; Index < ClientConnectionCount; ++Index) { UNetConnection* Connection = ClientConnectionData[Index]; if (!MpReadableThisFrame(Connection, 0x140)) { // [2026-10-11 O3] once per frame segment continue; } if (!Connection->OwningActor || *(uint32_t*)((uintptr_t)Connection + 0x134) != 3) continue; HasConnection = true; } if (EnableWatchdog) { // Empty detection: trust the authoritative GameMode player count when we have it (fixes // the zombie-connection case where HasConnection stayed stuck TRUE after players traveled // out, so the server never self-terminated). Only fall back to the connection scan while // the GameMode isn't found yet (-1). Counting travelling players keeps a still-loading // instance from being reaped. int EmptyState = GameModeEmptyState(); // 1 empty, 0 occupied, -1 unknown bool IsEmpty = (EmptyState == 1) || (EmptyState == -1 && !HasConnection); if (IsEmpty) { TotalNoPlayersTime += DeltaTime; // Reap only CONFIRMED disposable instances (g_wdIsHub==0). Persistent hubs (ramsgate // BPGM_City_C / training BPGM_ArchonTrainingGrounds_C => hub==1) and not-yet-classified // (hub==-1) are NEVER self-terminated here — the DeployServer owns the hub lifecycle, // and reaping a hub mid-travel would vanish it under a returning player. (The background // thread applies the same hub==0 gate and is the reliable reaper once the tick stops.) // [2026-10-16] The same limit as the background thread (MP_WD_EMPTY_REAP_SEC): 0.2.139 changed only the // thread, so this path still reaped at 50 s (verification hunt 15 Oct 18:35:39 UTC). if (TotalNoPlayersTime >= MP_WD_EMPTY_REAP_SEC && g_wdIsHub.load(std::memory_order_relaxed) == 0) { // Empty for 50s CONTINUOUS -> self-terminate so the DeployServer reclaims the // port + ~1GB of RAM. Fires ONLY when no one is connected/incoming, so an active // hunt never triggers it no matter how long or how big the map. // Same teardown reasoning as the background thread's reap — see MpReapExit. MpReapExit(("tick-path: disposable instance empty " + std::to_string(static_cast(MP_WD_EMPTY_REAP_SEC)) + "s continuous (gmState=" + std::to_string(EmptyState) + ")").c_str()); } } else { // Reset on ANY occupancy signal so the 50s window measures CONTINUOUS emptiness, not // cumulative. Without this, brief empty moments summed across a long hunt could reach // 50s and kill an ACTIVE hunt mid-fight (big maps / long hunts) - the failure to avoid. TotalNoPlayersTime = 0.0f; } } // [2026-10-10 1.14.7] Tick-filter opt-in modes (TICK_FILTER_CENSUS.flag / SERVER_TICK_FILTER.flag): one // [EdgeRecoveryProbe] round every 10 s. Nothing here runs when neither flag installed the hook. bool EdgeProbeRound = false; if (MpTickFilterHookActive()) { static ULONGLONG s_edgeProbeLast = 0; const ULONGLONG NowMs = GetTickCount64(); if (NowMs - s_edgeProbeLast >= 10000) { s_edgeProbeLast = NowMs; EdgeProbeRound = true; } } for (int32_t Index = 0; Index < ClientConnectionCount; ++Index) { UNetConnection* Conn = ClientConnectionData[Index]; if (!MpReadableThisFrame(Conn, 0x140)) { // [2026-10-11 O3] once per frame segment continue; } if (Conn->PlayerController && Conn->PlayerController->Pawn && Conn->PlayerController->Pawn->IsA(ABP_PlayerCharacter_C::StaticClass())) { // [2026-10-10 1.14.7] With SERVER_TICK_FILTER.flag the character's own tick runs TickStamina once per // actor tick (its ServerOnly/LocalOrServer branch now passes); calling it here as well would evaluate // stamina twice per frame. The manual call stays until the player class is cached (the fix answers // like the game before that), and always without the flag. MpTickFilterCachePlayerClass(ABP_PlayerCharacter_C::StaticClass()); if (!(g_ServerTickFilterActive.load(std::memory_order_relaxed) && g_TickFilterPlayerClass.load(std::memory_order_relaxed))) { ((ABP_PlayerCharacter_C*)Conn->PlayerController->Pawn)->TickStamina(ECityExecFilter::Both, ERemoteExecFilter::All); } else { static bool s_staminaSkipLogged = false; if (!s_staminaSkipLogged) { s_staminaSkipLogged = true; MpLog("[TickFilter] manual TickStamina skipped (SERVER_TICK_FILTER.flag): the character's own tick runs it"); } } if (EdgeProbeRound) MpEdgeRecoveryProbe(Conn->PlayerController->Pawn, Index); // [2026-07-17 co-op smoothness] Measured: player pawns replicate ~10Hz (period 3) vs the // behemoth ~30Hz. Raise the pawn's NetUpdateFrequency (+0x108) / MinNetUpdateFrequency // (+0x10C) so the graph gives it period 1 (~30Hz, capped by NetServerMaxTickRate). Every // player pawn is boosted (loop covers all connections). Flag-gated A/B. if (PlayerRepBoost()) { AActor* pawn = Conn->PlayerController->Pawn; if (IsReadablePointer(reinterpret_cast(reinterpret_cast(pawn) + 0x110), 4)) { *reinterpret_cast(reinterpret_cast(pawn) + 0x108) = 60.0f; // NetUpdateFrequency *reinterpret_cast(reinterpret_cast(pawn) + 0x10C) = 30.0f; // MinNetUpdateFrequency } } } } } MpTickFilterCensusTick(); // TICK_FILTER_CENSUS.flag only; once a minute } } void InteractionCalloutHideHoldTextHook(void* Widget) { if (!IsReadablePointer(Widget, 0x3C8)) { MpLog("[InteractionCalloutHideHoldText] skipping unreadable widget=" + MpPtr(Widget)); return; } void* HoldTextController = *reinterpret_cast(reinterpret_cast(Widget) + 0x3C0); if (!IsReadablePointer(HoldTextController, sizeof(void*))) { MpLog("[InteractionCalloutHideHoldText] skipping null/unreadable widget+0x3C0 widget=" + MpPtr(Widget) + " member=" + MpPtr(HoldTextController)); return; } reinterpret_cast(OrigInteractionCalloutHideHoldText)(Widget); } // UArchonLoadingScreen::ScreenFadeIn at RVA 0x01CADE20. // // On DEDICATED SERVER (-nullrhi Ramsgate/Dojo): fade-widget slots at LoadingScreen+0x3E0 // and +0x3E8 are null (no viewport, no UI) and the original function crashes at // RVA 0x01CADEA6 trying to deref +0x3E8. We suppress in that case. // // On CLIENT: both widget pointers ARE valid. Suppressing here blocks the loading-screen // state machine from advancing (screen never finishes fading -> subsequent // "loaded, hide screen" transitions never fire -> client stuck on loading art forever). // So on client we must call through to the original. // // Decision: gate purely on widget validity - if the widget the original will deref is // non-null and readable, call the original; otherwise suppress. This works correctly // whether AmServer is 0 or 1 and doesn't rely on that global being set yet at first call. void ArchonLoadingScreenFadeInHook(void* LoadingScreen, uint8_t FadeMode) { void* PrimaryFadeWidget = nullptr; void* SecondaryFadeWidget = nullptr; if (IsReadablePointer(LoadingScreen, 0x3F0)) { PrimaryFadeWidget = *reinterpret_cast(reinterpret_cast(LoadingScreen) + 0x3E8); SecondaryFadeWidget = *reinterpret_cast(reinterpret_cast(LoadingScreen) + 0x3E0); } // Server-null-crash guard: if either widget slot is null, the original will crash // when it derefs LoadingScreen+0x3E8. Suppress in that case only. const bool WidgetsValid = LoadingScreen != nullptr && PrimaryFadeWidget != nullptr && SecondaryFadeWidget != nullptr && IsReadablePointer(PrimaryFadeWidget, sizeof(void*)) && IsReadablePointer(SecondaryFadeWidget, sizeof(void*)); if (!WidgetsValid) { MpLog("[ArchonLoadingScreenFadeIn] suppressed (null-widget guard) loadingScreen=" + MpPtr(LoadingScreen) + " primary(+0x3E8)=" + MpPtr(PrimaryFadeWidget) + " secondary(+0x3E0)=" + MpPtr(SecondaryFadeWidget) + " mode=" + std::to_string(FadeMode)); return; } // Log first pass-through per mode so we can see fade-in/out transitions on client. static bool s_loggedMode[256] = {}; if (!s_loggedMode[FadeMode]) { s_loggedMode[FadeMode] = true; MpLog("[ArchonLoadingScreenFadeIn] pass-through (widgets valid) loadingScreen=" + MpPtr(LoadingScreen) + " primary(+0x3E8)=" + MpPtr(PrimaryFadeWidget) + " secondary(+0x3E0)=" + MpPtr(SecondaryFadeWidget) + " mode=" + std::to_string(FadeMode) + " -> calling original"); } reinterpret_cast(OrigArchonLoadingScreenFadeIn)(LoadingScreen, FadeMode); } struct RawPointerArray { void** Data; int32_t Num; int32_t Max; }; // [2026-10-11 1.14.7 O3] Runs three times a frame on a server (the engine's TickDispatch hook, before the manual dispatch // and before the manual flush); the NetDriver and per-connection pointer checks now go through MpReadableThisFrame, so // each pointer costs one VirtualQuery per frame segment instead of one per call. Same checks, same repairs. static bool SanitizeNetDriverClientConnections(void* NetDriver, const char* Tag) { if (!NetDriver || !MpReadableThisFrame(NetDriver, 0xA0)) { MpLog(std::string("[") + Tag + "] invalid NetDriver=" + MpPtr(NetDriver)); return false; } RawPointerArray* ClientConnections = reinterpret_cast( reinterpret_cast(NetDriver) + 0x90); if (!MpReadableThisFrame(ClientConnections, sizeof(RawPointerArray))) { MpLog(std::string("[") + Tag + "] unreadable ClientConnections metadata NetDriver=" + MpPtr(NetDriver)); return false; } if (!IsSanePointerArray(ClientConnections->Data, ClientConnections->Num, ClientConnections->Max, 1024)) { MpLog(std::string("[") + Tag + "] resetting invalid ClientConnections" + " data=" + MpPtr(ClientConnections->Data) + " num=" + std::to_string(ClientConnections->Num) + " max=" + std::to_string(ClientConnections->Max)); ClientConnections->Data = nullptr; ClientConnections->Num = 0; ClientConnections->Max = 0; return false; } int32_t WriteIndex = 0; for (int32_t ReadIndex = 0; ReadIndex < ClientConnections->Num; ++ReadIndex) { void* Connection = ClientConnections->Data[ReadIndex]; if (!MpReadableThisFrame(Connection, 0x138)) { MpLog(std::string("[") + Tag + "] dropping invalid connection" + " index=" + std::to_string(ReadIndex) + " ptr=" + MpPtr(Connection)); continue; } ClientConnections->Data[WriteIndex++] = Connection; } for (int32_t Index = WriteIndex; Index < ClientConnections->Num; ++Index) { ClientConnections->Data[Index] = nullptr; } if (WriteIndex != ClientConnections->Num) { MpLog(std::string("[") + Tag + "] compacted ClientConnections" + " oldNum=" + std::to_string(ClientConnections->Num) + " newNum=" + std::to_string(WriteIndex)); ClientConnections->Num = WriteIndex; return false; } RawPointerArray* PendingCleanup = reinterpret_cast( reinterpret_cast(NetDriver) + 0xF0); if (!MpReadableThisFrame(PendingCleanup, sizeof(RawPointerArray))) { MpLog(std::string("[") + Tag + "] unreadable NetDriver+0xF0 cleanup metadata NetDriver=" + MpPtr(NetDriver)); return false; } if (PendingCleanup->Num < 0 || PendingCleanup->Max < 0 || PendingCleanup->Num > PendingCleanup->Max || PendingCleanup->Num > 1024 || (PendingCleanup->Num > 0 && !IsReadablePointer(PendingCleanup->Data, static_cast(PendingCleanup->Num) * 0x18))) { MpLog(std::string("[") + Tag + "] resetting invalid cleanup array" + " data=" + MpPtr(PendingCleanup->Data) + " num=" + std::to_string(PendingCleanup->Num) + " max=" + std::to_string(PendingCleanup->Max)); PendingCleanup->Data = nullptr; PendingCleanup->Num = 0; PendingCleanup->Max = 0; return false; } return true; } void* OrigNetDriverTickDispatchInner = nullptr; void NetDriverTickDispatchInnerHook(void* NetDriver, float DeltaTime) { // [1.14.7 DIAG 2026-10-04] Is the ENGINE actually calling UNetDriver::TickDispatch at all? // The hub's socket is never read with the native tick, and UWorld::NetDriver (verified +0x38, // Engine_classes.hpp:7414) is now set to our named driver, so either the engine never calls this or the // socket read lives in the UIpNetDriver override rather than here. A counter answers it. { static std::atomic s_calls{ 0 }; const int n = s_calls.fetch_add(1, std::memory_order_relaxed); // [2026-10-11 O3] The periodic heartbeat (every 600th call) is off with PRODUCTION_LOG_PROFILE.flag. if (n < 3 || ((n % 600) == 0 && !MpProductionLogProfile())) { MpLog("[TickDispatchInner] call #" + std::to_string(n + 1) + " role=" + (Globals::AmServer ? "server" : "client") + " driver=" + MpPtr(NetDriver)); } } if (!SanitizeNetDriverClientConnections(NetDriver, "NetDriverTickDispatchInner")) { MpLog("[NetDriverTickDispatchInner] skipped original after sanitizing unsafe connection state"); return; } reinterpret_cast(OrigNetDriverTickDispatchInner)(NetDriver, DeltaTime); } void* OrigFixupNetworkNotify = nullptr; void* FixupNetworkNotifyHook(void* a1) { if(UWorld::GetWorld()) *(void**)((uintptr_t)a1 + 0x208) = (void*)((uintptr_t)UWorld::GetWorld() + 0x28); // UWorld::NetworkNotify: unnamed in 1.12.0 SDK; slot @ +0x28 (before PersistentLevel@0x30, stable 1.4.4->1.12.0) return reinterpret_cast(OrigFixupNetworkNotify)(a1); } void* OrigNotifyClientDisconnected = nullptr; void NotifyClientDisconnectedHook(void* NetDriver, void* Connection) { MpLog("[NotifyClientDisconnected] hook entry"); if (!NetDriver || !IsReadablePointer(NetDriver, 0x240)) { MpLog("[NotifyClientDisconnected] invalid NetDriver pointer"); return; } if (Connection && !IsReadablePointer(Connection, 0x140)) { MpLog("[NotifyClientDisconnected] skipping invalid connection pointer"); return; } // [1.12.0] The original starts with VeryVerbose name formatting and then calls // RemoveSingleSwap on a UIpNetDriver-private TArray at +0x238. In our forced // server path that array can be half-torn during disconnect, producing crashes // in FName->FString (logging) or in the remove helper's `cmp [rax], rdx`. // // Replace only the fragile bookkeeping: validate the raw array before reading, // remove by swap-with-last if possible, and skip shrink/callback side effects. RawPointerArray* DisconnectedClients = reinterpret_cast( reinterpret_cast(NetDriver) + 0x238); if (!IsSanePointerArray(DisconnectedClients->Data, DisconnectedClients->Num, DisconnectedClients->Max, 1024)) { MpLog("[NotifyClientDisconnected] invalid NetDriver+0x238 array; skipping original"); return; } for (int32_t Index = 0; Index < DisconnectedClients->Num; ++Index) { if (DisconnectedClients->Data[Index] != Connection) { continue; } const int32_t LastIndex = DisconnectedClients->Num - 1; if (Index != LastIndex) { DisconnectedClients->Data[Index] = DisconnectedClients->Data[LastIndex]; } DisconnectedClients->Data[LastIndex] = nullptr; DisconnectedClients->Num = LastIndex; MpLog("[NotifyClientDisconnected] removed connection from NetDriver+0x238"); return; } MpLog("[NotifyClientDisconnected] connection not present in NetDriver+0x238"); } // [2026-07-21 DISCONNECT-CLEANUP DIAG] Read-only observation hook on the REAL native disconnect // entry point, UNetConnection::Close (RVA 0x03D477C0 - located via its own log string // "UNetConnection::Close: %s, Channels: %i, Time: %s"). This is NOT NotifyClientDisconnectedHook // above: a full log scan across a real quit-and-rejoin-while-server-stays-up repro showed // NotifyClientDisconnectedHook's own "hook entry" line NEVER fires for a normal client quit - // only its install confirmation appears. So whatever the game does on disconnect happens through // THIS function instead, and it has zero DLL visibility today. // // Close() makes two virtual calls we don't yet know the identity of: // plVar1 = *(Connection + 0x1318); if (plVar1) (*plVar1->vtable[0x288])(plVar1, 0); // (conditionally) (*Connection->vtable[0x2c0])(Connection, 0); // Connection+0x1318 is deliberately NOT interpreted here: an earlier version assumed it was a // UObject and crashed during name resolution. We only observe verified scalar fields and the // native caller stack. That makes this safe to use on the short P2P post-login disconnect. void* OrigNetConnectionClose = nullptr; void NetConnectionCloseHook(void* Connection) { if (!Connection || !IsReadablePointer(Connection, 0x1D8)) { MpLog("[NetConnectionClose] invalid connection=" + MpPtr(Connection)); reinterpret_cast(OrigNetConnectionClose)(Connection); return; } // Keep this diagnostic useful during a failed join without allowing ordinary // connection churn to grow the session log without bound. static std::atomic s_ncCloseSeq{ 0 }; const int Seq = s_ncCloseSeq.fetch_add(1, std::memory_order_relaxed); if (Seq >= 16) { reinterpret_cast(OrigNetConnectionClose)(Connection); return; } const uintptr_t Base = reinterpret_cast(Connection); const uint8_t State = *reinterpret_cast(Base + 0x134); const double LastReceiveTime = *reinterpret_cast(Base + 0x1D0); void* Frames[12]{}; const USHORT FrameCount = CaptureStackBackTrace(1, static_cast(std::size(Frames)), Frames, nullptr); std::string Stack; for (USHORT Index = 0; Index < FrameCount; ++Index) { if (Index != 0) Stack += " "; Stack += MpAddress(Frames[Index]); } MpLog(std::string(Globals::AmServer ? "[NetConnectionClose server #" : "[NetConnectionClose client #") + std::to_string(Seq) + "] ENTER conn=" + MpPtr(Connection) + " state=" + std::to_string(State) + " lastReceive=" + std::to_string(LastReceiveTime) + " stack=" + Stack); reinterpret_cast(OrigNetConnectionClose)(Connection); MpLog(std::string(Globals::AmServer ? "[NetConnectionClose server #" : "[NetConnectionClose client #") + std::to_string(Seq) + "] EXIT conn=" + MpPtr(Connection)); } void* OrigProcessRequest = nullptr; static bool MpRewriteRequestUrl(void* Request); static void MpProbeCharacterRequestAssociation(void* Request); static bool MpSplitUrl(const std::wstring& Url, std::wstring& OutHost, std::wstring& OutTail); static std::string MpGrantTraceCallChain(); // [GrantTrace]: native call chain with .pdata function starts // [1.14.7 2026-10-10] RtlGenRandom (advapi32!SystemFunction036) is the CSPRNG behind the grant id below. advapi32 is in // this project's default link set (the vcxproj adds no AdditionalDependencies and bcrypt.lib is not linked), and the // declaration is spelled out here instead of pulling (LSA types) in next to the UE SDK. extern "C" BOOLEAN NTAPI SystemFunction036(PVOID RandomBuffer, ULONG RandomBufferLength); // [1.14.7 2026-10-10] FMemory::Free for engine buffers handed back to this DLL (the FString GetHeader copies out). // GMalloc is DAT_146e1eb58 (RVA 0x06E1EB58): FMemory::Realloc (0x02564AB0, see EngineRealloc) dispatches its vtable // +0x20, and every inlined FString destructor in 1.14.7 frees through +0x30 on the same object (FUN_142564b60: // "if (!GMalloc) init; GMalloc->vt[0x30](GMalloc, Data)"). A buffer we received was allocated through that GMalloc, // so a null GMalloc means there is nothing of ours to free. static void MpEngineFree(void* Ptr) { if (!Ptr || !Globals::BaseAddress) return; void* Malloc = *reinterpret_cast(Globals::BaseAddress + 0x06E1EB58); if (!Malloc || !IsReadablePointer(Malloc, sizeof(void*))) return; void** Vtable = *reinterpret_cast(Malloc); if (!Vtable || !IsReadablePointer(Vtable, 7 * sizeof(void*))) return; reinterpret_cast(Vtable[6])(Malloc, Ptr); } // [1.14.7 2026-10-10] The path of a dedicated-server XP grant, "/progression///", or empty for // any other URL. Scheme, host and query are ignored. MpRewriteRequestUrl has already run, so an allowlisted host now // reads "/__origin//progression/..."; the backend strips that prefix before routing (app.ts), and so // does this. Exactly three non-empty segments, the last all digits - anything else gets no grant id, which the backend // answers with 400 (fail-closed). // Plain arrays, no function-local std::wstring statics: their lazy construction faulted once on a curl worker thread // (see the g_UrlObs* note below), and this runs on whatever thread issues the request. static std::wstring MpProgressionGrantPath(const std::wstring& Url) { std::wstring Host; std::wstring Tail; if (!MpSplitUrl(Url, Host, Tail)) return L""; std::wstring Path = Tail.substr(0, Tail.find_first_of(L"?#")); constexpr wchar_t kOrigin[] = L"/__origin/"; constexpr size_t kOriginLen = (sizeof(kOrigin) / sizeof(wchar_t)) - 1; if (Path.compare(0, kOriginLen, kOrigin) == 0) { const size_t HostEnd = Path.find(L'/', kOriginLen); if (HostEnd == std::wstring::npos || HostEnd == kOriginLen) return L""; Path = Path.substr(HostEnd); } constexpr wchar_t kPrefix[] = L"/progression/"; constexpr size_t kPrefixLen = (sizeof(kPrefix) / sizeof(wchar_t)) - 1; if (Path.compare(0, kPrefixLen, kPrefix) != 0) return L""; const size_t First = kPrefixLen; const size_t Slash1 = Path.find(L'/', First); if (Slash1 == std::wstring::npos || Slash1 == First) return L""; const size_t Slash2 = Path.find(L'/', Slash1 + 1); if (Slash2 == std::wstring::npos || Slash2 == Slash1 + 1) return L""; const std::wstring Amount = Path.substr(Slash2 + 1); if (Amount.empty() || Amount.size() > 9) return L""; for (wchar_t C : Amount) { if (C < L'0' || C > L'9') return L""; } return Path; } // [1.14.7 2026-10-10] Idempotent grant id on dedicated-server XP grants. The backend accepts // POST /progression/{account}/{track}/{amount} only with x-mysticparadox-grant-id (^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$) // and applies each id once; the game sends none (no grant/idempotency string in the exe). UE's HTTP retry re-issues // ProcessRequest on the SAME FCurlHttpRequest, so the header is read back first: a retry keeps the id it was given on // the first attempt and the backend replays instead of applying the XP twice. New ids are "gs-" + 128 random bits. // FCurlHttpRequest vtable (1.14.7, 0x145B62FE0): [2] GetHeader 0x1432BC040 = FString* (this, FString* Out /*hidden // return*/, const FString* Name) - zeroes Out, looks Name up in the header map at this+0xC8 and copies the value; // [16] SetHeader 0x1432C59E0, ignored while the request is in flight (+0xC4 == 1). // The URL is read in place at this+0x70 (the FString ProcessRequest itself consumes, same guards as // MpRewriteRequestUrl) rather than through GetURL [0], which would hand back one more engine copy to free. // Logs one [ProgressionGrantReq] line per grant request (process budget 2000). Never logs the API key or other headers. static void MpEnsureProgressionGrantId(void* Request) { if (!Request || !IsReadablePointer(Request, 0xD0)) return; struct RawFString { wchar_t* Data; int32_t Num; int32_t Max; }; const auto* Url = reinterpret_cast(reinterpret_cast(Request) + 0x70); if (!Url->Data || Url->Num < 2 || Url->Max < Url->Num || Url->Num > 32768 || !IsReadablePointer(Url->Data, static_cast(Url->Num) * sizeof(wchar_t))) { return; } const std::wstring Path = MpProgressionGrantPath(std::wstring(Url->Data, static_cast(Url->Num - 1))); if (Path.empty()) return; static std::atomic s_logBudget{ 0 }; const bool LogIt = s_logBudget.fetch_add(1, std::memory_order_relaxed) < 2000; if (*reinterpret_cast(reinterpret_cast(Request) + 0xC4) == 1) { if (LogIt) MpLog("[ProgressionGrantReq] path=" + MpNarrow(Path) + " in flight: header untouched (the native call fails it)"); return; } void** Vtable = *reinterpret_cast(Request); if (!Vtable || !IsReadablePointer(Vtable, 17 * sizeof(void*))) return; FString HeaderName(L"x-mysticparadox-grant-id"); RawFString Existing{}; using GetHeaderFn = void*(*)(void*, void*, const void*); reinterpret_cast(Vtable[2])(Request, &Existing, &HeaderName); // Any non-empty value already on the request is kept, even one this code cannot read back: replacing it on a retry // would turn one grant into two ids. const bool Reused = Existing.Data != nullptr && Existing.Num > 1; std::wstring GrantId; if (Reused) { if (Existing.Num <= 256 && IsReadablePointer(Existing.Data, static_cast(Existing.Num) * sizeof(wchar_t))) { GrantId.assign(Existing.Data, static_cast(Existing.Num - 1)); } else { GrantId = L""; } } MpEngineFree(Existing.Data); // GetHeader's copy was allocated with FMemory (FUN_1409fb5f0 -> 0x02564AB0) if (!Reused) { unsigned char Bytes[16] = {}; if (!SystemFunction036(Bytes, static_cast(sizeof(Bytes)))) { // Fail-closed: without an id the backend refuses the grant (400) rather than risk applying it twice. if (LogIt) MpLog("[ProgressionGrantReq] path=" + MpNarrow(Path) + " RtlGenRandom FAILED err=" + std::to_string(GetLastError()) + ": no grant id set, the backend will refuse this grant"); return; } static const wchar_t kHexDigits[] = L"0123456789abcdef"; GrantId = L"gs-"; for (unsigned char B : Bytes) { GrantId += kHexDigits[B >> 4]; GrantId += kHexDigits[B & 0x0F]; } FString GrantValue(GrantId.c_str()); using SetHeaderFn = void(*)(void*, FString*, FString*); reinterpret_cast(Vtable[16])(Request, &HeaderName, &GrantValue); } if (LogIt) { MpLog("[ProgressionGrantReq] path=" + MpNarrow(Path) + " grant=" + MpNarrow(GrantId.substr(0, 128)) + " reused=" + (Reused ? "1" : "0") + " tid=" + std::to_string(GetCurrentThreadId()) + " chain=" + MpGrantTraceCallChain()); } } char ProcessRequest(void* Request) { // 1.14.7 Ghidra confirms ProcessRequest reads FCurlHttpRequest's FString URL // at this+0x70 before it configures the native request. Rewrite at this // pre-dispatch boundary; +0x032C5B40 is only the one-argument in-flight // SetURL diagnostic and cannot accept a replacement FString. MpRewriteRequestUrl(Request); // [1.14.7 2026-10-10] Dedicated-server XP grants carry an idempotent grant id (see MpEnsureProgressionGrantId). // Globals::AuthMode defaults to GlobalGameserver in every process (only Init() on a server changes it), so the // server role is checked as well. // [2026-10-14 realms] A player host (HuntCapability: realm hubs and hunts) needs the same id: the backend refuses // every XP grant without one, so a realm member's kill XP was never saved (VPS, 14 Oct 11:29). if (Globals::AmServer && (Globals::AuthMode == Globals::ServerAuthMode::GlobalGameserver || Globals::AuthMode == Globals::ServerAuthMode::HuntCapability)) { MpEnsureProgressionGrantId(Request); } // Only a DeployServer gameserver carries the global gameserver credential. The optional // player-hosted mode never receives it and supplies its own scoped header (P2PHooks.h). if (Globals::AuthMode == Globals::ServerAuthMode::GlobalGameserver && Globals::ServerAPIKey) { FString APIHeader(L"x-mysticparadox-gameserver-apikey"); FString APIKey(Globals::ServerAPIKey); // SetHeader is virtual: vtable BYTE offset 0x80 == INDEX 16 (0x80/8). NOT index 0x80. using SetHeaderFn = void(*)(void*, FString*, FString*); void* setHeader = (*reinterpret_cast(Request))[16]; reinterpret_cast(setHeader)(Request, &APIHeader, &APIKey); } else if (Globals::AuthMode == Globals::ServerAuthMode::HuntCapability) { std::wstring HeaderName; std::wstring HeaderValue; P2PHooks::RequestHeader(HeaderName, HeaderValue); if (!HeaderName.empty()) { FString ModuleHeader(HeaderName.c_str()); FString ModuleValue(HeaderValue.c_str()); using SetHeaderFn = void(*)(void*, FString*, FString*); void* setHeader = (*reinterpret_cast(Request))[16]; reinterpret_cast(setHeader)(Request, &ModuleHeader, &ModuleValue); } } return reinterpret_cast(OrigProcessRequest)(Request); } // ============================================================================ // [1.12.0] In-process HTTPS redirect — FCurlHttpRequest::SetURL (RVA 0x03102740). // // Replaces the external WinDivert interceptor for core backend HTTP(S) traffic. // SetURL receives the complete URL (RCX=this, RDX=FString* url) before the // request becomes in-flight and assigns it into the request at this+0x70. We // rewrite allowlisted official hostnames to the owned domain here, encoding the // original host in the path so the backend can route WITHOUT nginx and without // depending on the outgoing Host header: // // https://auth-prod.steelyard.ca/tags?x=1 // -> https://paradox.mysticfox.dev/__origin/auth-prod.steelyard.ca/tags?x=1 // // Because the URL itself now names paradox.mysticfox.dev, TLS SNI + cert // validation use that hostname — no hosts file, root cert, portproxy, WinDivert // or machine-wide proxy. Only the injected process is affected. // // Two independent sentinels, both default OFF and cached on first read: // debug\URL_REWRITE.flag -> rewrite allowlisted URLs to paradox.mysticfox.dev // debug\URL_LOG.flag -> emit sanitized, deduplicated, budget-capped observations // Default (no flags) = passthrough with zero logging/overhead. Logging is gated // SEPARATELY from rewrite so it never runs in production (earlier diagnostic // logging caused frame lag) and so it can be disabled after rewrite is enabled. // Installed in BOTH client and dedicated-server init (both make backend calls). // ============================================================================ // // [2026-10-13 realms S1] The backend address is no longer only compiled in. The launcher can hand the game process the // backend to use (a community realm, a self-hosted hub) in MYSTICPARADOX_BACKEND_URL = scheme://host[:port]: http or // https, a DNS name, IPv4 or [IPv6] host, no path, query, fragment or user information (BackendUrl.h has the exact // grammar). A valid value replaces the built-in https://MP_PUBLIC_HOST as the API base used by MpBuildRedirectUrl and // as the chat WebSocket address further down (ws/wss matching http/https, same host; the port goes to the XMPP // ServerPort key, because the client appends ":" + ServerPort to ServerAddr). Absent, empty or invalid means the // built-in https/wss address exactly as before; an invalid value is logged - reason and length only, it may have // carried credentials - and ignored. A DeployServer sets no such variable, so its servers keep the built-in address. // Resolved once: from Init (before any hook reading it exists) and otherwise on first use, behind an Interlocked state // word. The results sit in file-scope POD buffers (no lazy constructors, see the g_UrlObs* note below) so any thread // can read them once the state is 2. The log line is written by MainThread because Init runs under the loader lock. static const wchar_t* const kMpBuiltInApiBase = L"https://" MP_PUBLIC_HOST; static const wchar_t* const kMpBuiltInXmppWsUrl = L"wss://" MP_PUBLIC_HOST; static constexpr uint16_t kMpBuiltInXmppPort = 443; static constexpr size_t kMpBackendAddressChars = 320; // "https://" + 253-char host + ":65535" + NUL, with room to spare static wchar_t g_MpApiBase[kMpBackendAddressChars]; static wchar_t g_MpXmppWsUrl[kMpBackendAddressChars]; static uint16_t g_MpXmppPort = kMpBuiltInXmppPort; static bool g_MpBackendFromEnv = false; static char g_MpBackendEnvIgnored[96]; // why MYSTICPARADOX_BACKEND_URL was ignored; empty if used or absent static DWORD g_MpBackendEnvLength = 0; static volatile LONG g_MpBackendState = 0; // 0 = unresolved, 1 = being resolved, 2 = ready static void MpResolveBackendAddress() { if (InterlockedCompareExchange(&g_MpBackendState, 1, 0) != 0) { // Resolved, or being resolved by another thread: wait for the result (a few microseconds at most). while (InterlockedCompareExchange(&g_MpBackendState, 2, 2) != 2) { YieldProcessor(); } return; } bool FromEnv = false; const char* Ignored = ""; DWORD EnvLength = 0; std::wstring EnvApi; std::wstring EnvWs; uint16_t EnvPort = kMpBuiltInXmppPort; try { char Raw[512] = {}; const DWORD Length = GetEnvironmentVariableA("MYSTICPARADOX_BACKEND_URL", Raw, static_cast(sizeof(Raw))); if (Length >= sizeof(Raw)) { EnvLength = Length; // buffer too small: Length is the size it would need Ignored = "value too long"; } else if (Length != 0) { // 0 = not set (or set empty): the built-in address EnvLength = Length; MpBackendUrl Url; const char* Why = ""; if (MpParseBackendUrl(std::string(Raw, Length), Url, Why)) { EnvApi = MpBackendApiBase(Url); EnvWs = MpBackendXmppAddress(Url); EnvPort = Url.Port; if (EnvApi.size() < kMpBackendAddressChars && EnvWs.size() < kMpBackendAddressChars) { FromEnv = true; } else { Ignored = "value too long"; // unreachable under the parser's 300-character cap } } else { Ignored = Why; } } } catch (...) { FromEnv = false; Ignored = "internal error while reading the value"; } if (FromEnv) { wmemcpy(g_MpApiBase, EnvApi.c_str(), EnvApi.size() + 1); wmemcpy(g_MpXmppWsUrl, EnvWs.c_str(), EnvWs.size() + 1); g_MpXmppPort = EnvPort; } else { wcscpy_s(g_MpApiBase, kMpBackendAddressChars, kMpBuiltInApiBase); wcscpy_s(g_MpXmppWsUrl, kMpBackendAddressChars, kMpBuiltInXmppWsUrl); g_MpXmppPort = kMpBuiltInXmppPort; strcpy_s(g_MpBackendEnvIgnored, sizeof(g_MpBackendEnvIgnored), Ignored); g_MpBackendEnvLength = Ignored[0] ? EnvLength : 0; } g_MpBackendFromEnv = FromEnv; InterlockedExchange(&g_MpBackendState, 2); // full barrier: the buffers above are visible to readers of 2 } static const wchar_t* MpApiBase() { MpResolveBackendAddress(); return g_MpApiBase; } // The XMPP ServerAddr value: "wss://host" or "ws://host", no port and no path (the NOTE above the XMPP redirect below). static const wchar_t* MpXmppWsUrl() { MpResolveBackendAddress(); return g_MpXmppWsUrl; } // The port the chat WebSocket connects to; written to XMPP ServerPort only when the address came from the environment. static uint16_t MpXmppServerPort() { MpResolveBackendAddress(); return g_MpXmppPort; } static bool MpBackendFromEnvironment() { MpResolveBackendAddress(); return g_MpBackendFromEnv; } // One line at startup: where the address came from and what the client will use. "ws" is the address as the client // assembles it (ServerAddr + ":" + ServerPort); the built-in one is wss://MP_PUBLIC_HOST:443 as before. static void MpLogBackendAddress() { MpResolveBackendAddress(); std::string Line = std::string("[BackendAddress] source=") + (g_MpBackendFromEnv ? "env" : "built-in") + " api=" + MpNarrow(g_MpApiBase) + " ws=" + MpNarrow(g_MpXmppWsUrl) + ":" + std::to_string(g_MpXmppPort); if (g_MpBackendEnvIgnored[0]) { Line += std::string(" (MYSTICPARADOX_BACKEND_URL ignored: ") + g_MpBackendEnvIgnored + ", length=" + std::to_string(g_MpBackendEnvLength) + ")"; } MpLog(Line); } // Case-insensitive parsed-host match against the redirect allowlist: exact apex // or a dot-delimited subdomain of one. Never a blind substring match. static bool MpIsRedirectHost(const std::wstring& Host) { static const wchar_t* kApexHosts[] = { L"steelyard.ca", // *.steelyard.ca + apex (Phoenix / Steelyard backend) L"steelyard.online", // *.steelyard.online + apex L"ol.epicgames.com", // *.ol.epicgames.com (Epic account / friends / presence / OSS) L"api.epicgames.dev", // EOS SDK (kept for observation; confirm it reaches this hook) }; for (const wchar_t* Apex : kApexHosts) { const size_t Alen = wcslen(Apex); if (Host.size() < Alen) continue; if (Host.size() == Alen) { if (_wcsicmp(Host.c_str(), Apex) == 0) return true; continue; } // suffix match with a dot boundary: "