From 451e84beb12011bb34cf649414b8ec5209c3e2dd Mon Sep 17 00:00:00 2001 From: Jacob Babor Date: Sat, 20 Jun 2026 00:25:47 -0500 Subject: [PATCH] Probe journal route listeners --- docs/red4ext-logging-shim.md | 11 +++ docs/traffic-system-debrief.md | 8 +- red4ext/EdgeWeightGPS/src/Main.cpp | 151 +++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 3 deletions(-) diff --git a/docs/red4ext-logging-shim.md b/docs/red4ext-logging-shim.md index 45f4004..ab71bdc 100644 --- a/docs/red4ext-logging-shim.md +++ b/docs/red4ext-logging-shim.md @@ -16,6 +16,11 @@ Current behavior: - Registers Running game-state callbacks so route tests can be timestamped. - Hooks selected native wrappers and cores around `GPSSystem`, mappin tracking, world-map mappin selection, and map path framing. +- Hooks `JournalManager.TrackEntry` at RVA `0x5944fc`, which is the native + handoff used by quest/objective world-map route plotting. +- Dumps the `JournalManager` listener array at offsets `0x210`/`0x21c` and the + listener vtable slots `0x28`, `0x30`, and `0x50`, matching the indirect calls + made by the native `TrackEntry` implementation. - Resolves the native mappin system when one of those paths fires, logs relevant vtable slot addresses, and temporarily hooks the route-adjacent slots. @@ -26,6 +31,12 @@ Current route-probe focus: `0x27c49c4`, `0x27c1684` - `TrackCustomPositionMappin` wrapper/core: `0x27c4aac`, `0x27c2318` - mappin-system slots `0x1f0`, `0x280`, and `0x2f0` +- `JournalManager.TrackEntry` implementation: `0x5944fc` + +Quest/objective pins did not fire the mappin tracking hooks in live tests. The +REDscript decompile shows that those pins call `JournalManager.TrackEntry` +instead, so the current useful runtime question is which native listener reacts +to tracked-entry changes. Build and install from the Fedora toolbox: diff --git a/docs/traffic-system-debrief.md b/docs/traffic-system-debrief.md index d098a14..a0f0960 100644 --- a/docs/traffic-system-debrief.md +++ b/docs/traffic-system-debrief.md @@ -546,6 +546,8 @@ Better versions of this mod could: graph component - ship multiple archives only if a data patch is proven to affect routing -The next practical step is one more in-game route-plotting run with the current -RED4ext mappin-system slot probe installed. If it logs concrete slot RVAs, the -next static-disassembly target is no longer a guess. +The next practical runtime step is no longer broad map/mappin logging. The +installed RED4ext shim now hooks `JournalManager.TrackEntry` directly and dumps +the listener array that native `TrackEntry` calls through. A short route-plotting +run should identify which native listener consumes tracked quest/objective +changes; that listener is the next focused static-disassembly target. diff --git a/red4ext/EdgeWeightGPS/src/Main.cpp b/red4ext/EdgeWeightGPS/src/Main.cpp index 3c82a59..804c6cc 100644 --- a/red4ext/EdgeWeightGPS/src/Main.cpp +++ b/red4ext/EdgeWeightGPS/src/Main.cpp @@ -91,6 +91,9 @@ namespace { constexpr uint32_t kGameStateRunning = 2; constexpr uintptr_t kGpsSystemTickRva = 0x0E6B62C; +constexpr uintptr_t kJournalTrackEntryImplRva = 0x05944FC; +constexpr uintptr_t kJournalListenerArrayOffset = 0x210; +constexpr uintptr_t kJournalListenerCountOffset = 0x21C; constexpr uintptr_t kMappinSystemResolverRva = 0x02BB840; constexpr uintptr_t kFrameMappinPathWrapperRva = 0x27C4314; constexpr uintptr_t kFrameMappinPathCoreRva = 0x27BC1EC; @@ -120,6 +123,8 @@ const RED4ext::v1::Sdk* gSdk = nullptr; uint32_t gUpdateLogCount = 0; bool gScannedExecutable = false; uint32_t gGpsTickLogCount = 0; +uint32_t gJournalTrackEntryLogCount = 0; +uint32_t gJournalTrackEntryListenerDumpCount = 0; uint32_t gFrameMappinPathWrapperLogCount = 0; uint32_t gFrameMappinPathCoreLogCount = 0; uint32_t gSetSelectedMappinWrapperLogCount = 0; @@ -154,6 +159,7 @@ void* gMappinActiveRouteTarget = nullptr; bool gMappinSlotHookAttachAttempted = false; using ProbeFunc = void (*)(void* aThis, void* a2, void* a3, void* a4); +using JournalTrackEntryFunc = void (*)(void* aThis, void* aEntryHandle); using ScriptNativeVoidFunc = void (*)(void* aThis, void* aStack, void* aResult); using MappinSystemResolverFunc = void* (*)(void* aThis); using FrameMappinPathCoreFunc = void (*)(void* aThis, uint32_t aPathKind, void* a3, void* aPathRect); @@ -169,6 +175,7 @@ using MappinCreateCustomPositionFunc = void* (*)(void* aSystem, void* aName, voi using MappinReleaseQueryFunc = void (*)(void* aSystem, void* aHandle); using MappinActiveRouteFunc = void* (*)(void* aSystem); ProbeFunc gOriginalGpsSystemTick = nullptr; +JournalTrackEntryFunc gOriginalJournalTrackEntry = nullptr; ScriptNativeVoidFunc gOriginalFrameMappinPathWrapper = nullptr; FrameMappinPathCoreFunc gOriginalFrameMappinPathCore = nullptr; ScriptNativeVoidFunc gOriginalSetSelectedMappinWrapper = nullptr; @@ -404,6 +411,58 @@ bool IsReadablePage(DWORD aProtect) baseProtect == PAGE_EXECUTE_WRITECOPY; } +bool IsReadableMemory(const void* aPointer, size_t aSize) +{ + if (!aPointer || aSize == 0) + { + return false; + } + + const auto begin = reinterpret_cast(aPointer); + const auto end = begin + aSize; + if (end < begin) + { + return false; + } + + auto cursor = begin; + while (cursor < end) + { + MEMORY_BASIC_INFORMATION mbi{}; + if (!VirtualQuery(reinterpret_cast(cursor), &mbi, sizeof(mbi))) + { + return false; + } + + if (mbi.State != MEM_COMMIT || !IsReadablePage(mbi.Protect)) + { + return false; + } + + const auto regionEnd = reinterpret_cast(mbi.BaseAddress) + mbi.RegionSize; + if (regionEnd <= cursor) + { + return false; + } + + cursor = regionEnd; + } + + return true; +} + +template +bool TryReadMemory(const void* aPointer, T& aValue) +{ + if (!IsReadableMemory(aPointer, sizeof(T))) + { + return false; + } + + aValue = *reinterpret_cast(aPointer); + return true; +} + void ScanRegionForString(const uint8_t* aRegionBegin, size_t aRegionSize, uintptr_t aImageBase, const char* aNeedle, uint32_t& aMatches) { @@ -550,6 +609,94 @@ void DetourGpsSystemTick(void* aThis, void* a2, void* a3, void* a4) } } +void LogJournalListeners(uint32_t aCallCount, void* aThis) +{ + constexpr uint32_t kMaxListenerDumps = 16; + constexpr uint32_t kMaxListenersPerDump = 24; + constexpr uint32_t kSuspiciousListenerCount = 512; + + if (gJournalTrackEntryListenerDumpCount >= kMaxListenerDumps || !aThis) + { + return; + } + + uintptr_t listenerBase = 0; + uint32_t listenerCount = 0; + const auto objectAddress = reinterpret_cast(aThis); + const auto hasBase = TryReadMemory(reinterpret_cast(objectAddress + kJournalListenerArrayOffset), + listenerBase); + const auto hasCount = TryReadMemory(reinterpret_cast(objectAddress + kJournalListenerCountOffset), + listenerCount); + + std::ostringstream header; + header << "journal-listeners dump=" << gJournalTrackEntryListenerDumpCount << " call=" << aCallCount + << " hasBase=" << hasBase << " hasCount=" << hasCount << " count=" << listenerCount; + AppendPointerWithRva(header, "base", reinterpret_cast(listenerBase)); + LogRed4ext(header.str()); + + ++gJournalTrackEntryListenerDumpCount; + + if (!hasBase || !hasCount || !listenerBase || listenerCount > kSuspiciousListenerCount) + { + return; + } + + const auto cappedCount = std::min(listenerCount, kMaxListenersPerDump); + for (uint32_t index = 0; index < cappedCount; ++index) + { + void* listener = nullptr; + void* vtable = nullptr; + void* callback28 = nullptr; + void* callback30 = nullptr; + void* callback50 = nullptr; + + const auto listenerAddress = reinterpret_cast(listenerBase + index * sizeof(void*)); + const auto hasListener = TryReadMemory(listenerAddress, listener); + const auto hasVtable = hasListener && TryReadMemory(listener, vtable); + if (hasVtable && vtable) + { + const auto vtableAddress = reinterpret_cast(vtable); + TryReadMemory(reinterpret_cast(vtableAddress + 0x28), callback28); + TryReadMemory(reinterpret_cast(vtableAddress + 0x30), callback30); + TryReadMemory(reinterpret_cast(vtableAddress + 0x50), callback50); + } + + std::ostringstream line; + line << "journal-listener call=" << aCallCount << " index=" << index << " hasListener=" << hasListener + << " hasVtable=" << hasVtable; + AppendPointerWithRva(line, "listener", listener); + AppendPointerWithRva(line, "vtable", vtable); + AppendPointerWithRva(line, "vt28", callback28); + AppendPointerWithRva(line, "vt30", callback30); + AppendPointerWithRva(line, "vt50", callback50); + LogRed4ext(line.str()); + } +} + +void DetourJournalTrackEntry(void* aThis, void* aEntryHandle) +{ + constexpr uint32_t kMaxCalls = 96; + + const auto callCount = gJournalTrackEntryLogCount; + if (callCount < kMaxCalls) + { + std::ostringstream line; + line << "hook JournalManager::TrackEntry impl call=" << callCount; + AppendPointerWithRva(line, "this", aThis); + AppendPointerWithRva(line, "entryHandle", aEntryHandle); + AppendReturnRva(line, __builtin_return_address(0)); + LogRed4ext(line.str()); + + LogJournalListeners(callCount, aThis); + ++gJournalTrackEntryLogCount; + } + + if (gOriginalJournalTrackEntry) + { + gOriginalJournalTrackEntry(aThis, aEntryHandle); + } +} + void DetourFrameMappinPathWrapper(void* aThis, void* aStack, void* aResult) { if (gFrameMappinPathWrapperLogCount < 64) @@ -1144,6 +1291,9 @@ RED4EXT_C_EXPORT bool RED4EXT_CALL Main(RED4ext::v1::PluginHandle aHandle, RED4e AttachProbeHook("GPSSystem/Tick", kGpsSystemTickRva, reinterpret_cast(&DetourGpsSystemTick), reinterpret_cast(&gOriginalGpsSystemTick)); + AttachProbeHook("JournalManager::TrackEntry impl", kJournalTrackEntryImplRva, + reinterpret_cast(&DetourJournalTrackEntry), + reinterpret_cast(&gOriginalJournalTrackEntry)); AttachProbeHook("FrameMappinPathWrapper", kFrameMappinPathWrapperRva, reinterpret_cast(&DetourFrameMappinPathWrapper), reinterpret_cast(&gOriginalFrameMappinPathWrapper)); @@ -1184,6 +1334,7 @@ RED4EXT_C_EXPORT bool RED4EXT_CALL Main(RED4ext::v1::PluginHandle aHandle, RED4e if (aReason == RED4ext::v1::EMainReason::Unload) { DetachProbeHook("GPSSystem/Tick", kGpsSystemTickRva); + DetachProbeHook("JournalManager::TrackEntry impl", kJournalTrackEntryImplRva); DetachProbeHook("FrameMappinPathWrapper", kFrameMappinPathWrapperRva); DetachProbeHook("FrameMappinPathCore", kFrameMappinPathCoreRva); DetachProbeHook("SetSelectedMappinWrapper", kSetSelectedMappinWrapperRva);