diff --git a/mpcore/src/main/cpp/car_selection.h b/mpcore/src/main/cpp/car_selection.h new file mode 100644 index 0000000..7633ba0 --- /dev/null +++ b/mpcore/src/main/cpp/car_selection.h @@ -0,0 +1,125 @@ +#pragma once + +// Reads the player's currently-selected car and its persisted paint color +// straight from the game's own live engine state (not a baked/static table), +// so this keeps working for any car added or modded in later. See +// PROGRESS.md cont.61/63/63b/64 for the full derivation of every offset +// below. + +#include +#include +#include "util/util.h" + +extern void* libapp_base; + +// The engine's central per-session state singleton (own RTTI-confirmed +// "s_Instance"/"GetInstance" assert strings). +24 = current car's ID string +// pointer, +56 = current car's CarDescription* (used below). +// SingletonInitialise (sub_244CA8) lazily constructs it if some code path +// reads it before its normal owner does - idempotent, safe to call directly. +#define SINGLETON_INSTANCE_OFFSET 0xAD2A08 +#define SINGLETON_INITIALISE_OFFSET 0x244CA8 +typedef int (*SingletonInitialiseFn)(); +static SingletonInitialiseFn SingletonInitialise = nullptr; + +// The player's owned-car registry (own "s_Instance"/"Not initialised" assert +// pattern, separate singleton from the one above) and its hashmap lookup: +// LookupCarRecord(registryPtr, &carIdKeySlot) walks buckets keyed by the +// car-id string's own interned pointer value, returning a 24-byte persistent +// record (`+4` = saved paint index) on a hit, or a lazily-`malloc`'d +// (never-zeroed) default record on a miss. +#define GET_CAR_REGISTRY_OFFSET 0x77B70 +#define LOOKUP_CAR_RECORD_OFFSET 0x25102C +typedef int (*GetCarRegistryFn)(); +typedef int (*LookupCarRecordFn)(int registryPtr, int* carIdKeySlot); +static GetCarRegistryFn GetCarRegistry = nullptr; +static LookupCarRecordFn LookupCarRecord = nullptr; + +// CarDescription::GetPaintJobDescription(int paintJobIndex) - a plain +// vector-index accessor: `*(carDescPtr+104) + 112*paintJobIndex`. Its own +// out-of-range assert does NOT actually stop it from computing and +// dereferencing a wild pointer for a bad index - see the cont.66 fix below. +#define GET_PAINT_JOB_DESC_OFFSET 0xB3564 +typedef int (*GetPaintJobDescriptionFn)(int carDescPtr, int paintJobIndex); +static GetPaintJobDescriptionFn GetPaintJobDescription = nullptr; + +// sub_16692C - rebuilds car_select's browsable car list from the current +// SINGLETON_CARCLASS_FILTER_OFFSET value (same rebuild a live class-filter +// dropdown tap triggers). Resolved but currently unused: three attempts to +// call it programmatically (immediate post-construct, on first Tick, and +// deferred 500ms) all crashed live with a NULL component pointer - see +// PROGRESS.md cont.64. Left declared for a future, better-scoped attempt. +#define REBUILD_CAR_CLASS_LIST_OFFSET 0x16692C +typedef int (*RebuildCarClassListFn)(int screenInstance); +static RebuildCarClassListFn RebuildCarClassList = nullptr; + +// Confirmed live (A/B/A across two cars): a direct, header-free pointer to +// the car's ASCII resource-id string (e.g. "ford_focus_rs500_2010_desc"). +static const char* GetCurrentCarId() { + void* singletonPtr = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET); + if (!singletonPtr) return nullptr; + return *(const char**)((uint8_t*)singletonPtr + 24); +} + +// PaintJobDescription's 112-byte layout (matches the unpacked resource +// schema exactly): six 16-byte string fields (+0 Name, +16 +// DiffuseTextureFilePath, +32 DiffuseMaskFilePath, +48 BRDFFilePath, +64 +// BRDFSpecularResponseFilePath, +80 NumberPlateTextureFilePath), then +96 +// SwatchColor (packed RGBA, one byte each), +100 SwatchColor2, +104 Type, +// +108 UseVinylMap/padding. +struct CarColor { + const char* name; + int r, g, b, a; +}; + +// cont.66 CRITICAL FIX: GetPaintJobDescription does not actually clamp an +// out-of-range index - it logs an assert but still computes and returns +// `vectorBegin + 112*index` using the original, still out-of-range index. +// LookupCarRecord's own "not found" fallback record is `malloc`'d, never +// zeroed, so its `+4` (color index) can be uninitialized garbage - on a +// fresh save, before the car-record hashmap has any entries, this crashed +// car_select's own CONTINUE (every real race, not just this project's own +// test flow), making the game unplayable end to end. Fix: independently +// read the same vector bounds GetPaintJobDescription itself reads +// (carDescPtr+104/+108) and clamp locally before ever calling it. +static CarColor GetCurrentCarColor() { + CarColor result = {"", 0, 0, 0, 0}; + if (!GetCarRegistry || !LookupCarRecord || !GetPaintJobDescription) return result; + void* singletonPtr = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET); + if (!singletonPtr) return result; + + int registryPtr = GetCarRegistry(); + if (!registryPtr) return result; + int* carIdKeySlot = (int*)((uint8_t*)singletonPtr + 24); + int recordPtr = LookupCarRecord(registryPtr, carIdKeySlot); + if (!recordPtr) return result; + int colorIndex = *(int*)((uint8_t*)(uintptr_t)recordPtr + 4); + + int carDescPtr = *(int*)((uint8_t*)singletonPtr + 56); + if (!carDescPtr) return result; + + uint32_t vecBegin = *(uint32_t*)((uint8_t*)(uintptr_t)carDescPtr + 104); + uint32_t vecEnd = *(uint32_t*)((uint8_t*)(uintptr_t)carDescPtr + 108); + if (vecEnd < vecBegin || (vecEnd - vecBegin) % 112 != 0) { + Log("GetCurrentCarColor: implausible PaintJobDescriptions vector (begin=0x%x end=0x%x) - bailing", + vecBegin, vecEnd); + return result; + } + int paintJobCount = (int)((vecEnd - vecBegin) / 112); + if (paintJobCount <= 0) return result; + if (colorIndex < 0 || colorIndex >= paintJobCount) { + Log("GetCurrentCarColor: colorIndex=%d out of range (count=%d) - clamping to 0", colorIndex, paintJobCount); + colorIndex = 0; + } + + int pjPtr = GetPaintJobDescription(carDescPtr, colorIndex); + if (!pjPtr) return result; + + result.name = *(const char**)(uintptr_t)pjPtr; // word[0] = Name's own begin pointer + uint32_t swatch = *(const uint32_t*)((uint8_t*)(uintptr_t)pjPtr + 96); + result.r = swatch & 0xFF; + result.g = (swatch >> 8) & 0xFF; + result.b = (swatch >> 16) & 0xFF; + result.a = (swatch >> 24) & 0xFF; + return result; +} diff --git a/mpcore/src/main/cpp/crash_workarounds.h b/mpcore/src/main/cpp/crash_workarounds.h new file mode 100644 index 0000000..d400f17 --- /dev/null +++ b/mpcore/src/main/cpp/crash_workarounds.h @@ -0,0 +1,200 @@ +#pragma once + +// Narrow crash workarounds found while getting the synthetic LAN event flow +// working (PROGRESS.md cont.29-34). Each guards ONE specific null/missing- +// data condition our under-registered synthetic RaceEvent/Actor triggers - +// none of them are meant to change behavior for real, fully-registered game +// objects. See crash_workarounds.h's own per-hook comments for the exact +// condition each one guards; kEnableDiagnosticAndTestHooks-style blanket +// installs are intentionally NOT used here (see the cont.67 lesson on +// GetComponentNameSkipHook below). + +#include +#include +#include +#include +#include +#include "util/util.h" +#include "util/hook_install.h" + +extern void* libapp_base; +extern void* g_lastSyntheticRaceEvent; // declared in lan_event_injection.h + +// im::app::sounds::CopSounds::Tick reads component data our minimal +// synthetic Actor doesn't provide (live SIGSEGV). Ambient audio system, +// unrelated to the actual car-select flow - skip its body entirely rather +// than replicate its full component requirements. Cosmetic only (no +// chase-sound animation), but note this skips the tick for EVERY actor, +// not just our synthetic one - real chase audio is silently disabled too. +#define COPSOUNDS_TICK_OFFSET 0x304AA0 +typedef void (*CopSoundsTickFn)(int a1, int* deltaMs); +static CopSoundsTickFn orig_CopSoundsTick = nullptr; + +extern "C" void Hook_CopSoundsTick(int a1, int* deltaMs) { + (void)a1; + (void)deltaMs; + // Deliberately not calling orig_CopSoundsTick - see comment above. +} + +static bool InstallCopSoundsTickSkipHook() { + orig_CopSoundsTick = (CopSoundsTickFn)InstallArmTrampolineHook( + libapp_base, COPSOUNDS_TICK_OFFSET, (void*)&Hook_CopSoundsTick, "CopSoundsTick skip hook"); + return orig_CopSoundsTick != nullptr; +} + +// GetComponentName (a component-name-cache lookup): our synthetic Actor +// isn't in its hash table, so it falls into a "build an RTTI class-name +// error string" fallback that crashed (SIGSEGV). cont.67 CRITICAL LESSON: +// an earlier version of this hook unconditionally returned an empty-string +// sentinel for EVERY call (not just the crashing case), assuming the result +// was "purely cosmetic debug text" - it is not. GetComponentName's result +// gets WRITTEN into a real cache-context object field (via sub_240294, +// called from sub_17A99C - the same function real map-event processing +// uses), and blanket-replacing it silently broke the persistent per-event +// "medal earned" progress record for every real race, not just our +// synthetic test event - see PROGRESS.md cont.67 for the full live-bisected +// root-cause writeup. Left DISABLED (not installed from main.cpp) for this +// reason - only re-enable with a fix scoped to the exact crashing input/ +// object, never a blanket substitution. +#define GET_COMPONENT_NAME_OFFSET 0x240548 +#define EMPTY_STRING_SENTINEL_OFFSET 0xAC80E0 +typedef char* (*GetComponentNameFn)(int a1, int* a2); +static GetComponentNameFn orig_GetComponentName = nullptr; + +extern "C" char* Hook_GetComponentName(int a1, int* a2) { + (void)a1; + (void)a2; + return (char*)((uintptr_t)libapp_base + EMPTY_STRING_SENTINEL_OFFSET); +} + +static bool InstallGetComponentNameSkipHook() { + orig_GetComponentName = (GetComponentNameFn)InstallArmTrampolineHook( + libapp_base, GET_COMPONENT_NAME_OFFSET, (void*)&Hook_GetComponentName, "GetComponentName skip hook"); + return orig_GetComponentName != nullptr; +} + +// A hand-optimized SWAR strlen() (sub_62F340) dereferences a NULL string +// pointer (fault addr 0x0) - likely reached while building an RTTI/class- +// name debug string for our under-registered synthetic actor, same family +// as the GetComponentName crash above but a different call site. Guard the +// leaf itself: return 0 for NULL, fall through to the real implementation +// otherwise - this one only ever changes behavior for an input that would +// otherwise crash, so it's safe to leave on unconditionally. +#define STRLEN_OFFSET 0x62F340 +typedef int (*StrlenFn)(const char* s); +static StrlenFn orig_Strlen = nullptr; + +extern "C" int Hook_Strlen(const char* s) { + if (!s) { + Log("Strlen null-guard: called with NULL, returning 0 instead of crashing"); + return 0; + } + return orig_Strlen(s); +} + +static bool InstallStrlenNullGuardHook() { + orig_Strlen = (StrlenFn)InstallArmTrampolineHook( + libapp_base, STRLEN_OFFSET, (void*)&Hook_Strlen, "Strlen null-guard hook"); + return orig_Strlen != nullptr; +} + +// sub_40A2B0 is the engine's generic "resolve display text for this key" +// call. On a lookup miss it renders `"XXXXX[" + rawText + "]"` (a real, +// pre-existing missing-localization marker) instead of plain text - our +// synthetic event's name/track fields were never registered as real +// string-table keys, so they always take this path. Rather than replicate +// the string-table registration format, let the original run unmodified +// and post-process its already-allocated output buffer: strip the marker +// in place (shrink-only memmove, no realloc, allocation base untouched so +// a later free() stays safe). Generic fix, not specific to our event - any +// raw-text fallback anywhere in the game renders cleanly instead of with +// the debug marker. +typedef int* (*ResolveDisplayTextFn)(int* outStr, int context, int key); +static ResolveDisplayTextFn orig_ResolveDisplayText = nullptr; + +// Diagnostic-only: identifies which key/context resolves to genuinely empty +// content, so a synthetic RaceEvent's missing fields can be found. Budget- +// gated so a runaway caller can't flood logcat. +static volatile int g_emptyResolveLogBudget = 0; + +extern "C" int* Hook_ResolveDisplayText(int* outStr, int context, int key) { + int* result = orig_ResolveDisplayText(outStr, context, key); + if (!outStr[0] || !outStr[1] || outStr[0] == outStr[1]) { + // A second lookup-miss branch inside sub_40A2B0 returns a plain + // empty result with no "XXXXX[" marker at all - nothing to strip. + if (g_emptyResolveLogBudget > 0) { + g_emptyResolveLogBudget--; + Log("DIAG ResolveDisplayText BLANK: context=0x%x key=%p key_as_str=\"%s\" caller=%p", + context, (void*)(uintptr_t)key, + key ? (const char*)(uintptr_t)key : "(null)", + __builtin_return_address(0)); + } + return result; + } + + uint16_t* begin = (uint16_t*)outStr[0]; + uint16_t* end = (uint16_t*)outStr[1]; + static const uint16_t kPrefix[] = {'X', 'X', 'X', 'X', 'X', '['}; + ptrdiff_t len = end - begin; + if (len < 6 || memcmp(begin, kPrefix, sizeof(kPrefix)) != 0) return result; + + uint16_t* p = end; + while (p > begin + 6 && *(p - 1) != ']') p--; + if (p <= begin + 6) return result; // no closing bracket found, leave as-is + + uint16_t* closeBracket = p - 1; + ptrdiff_t innerLen = closeBracket - (begin + 6); + if (innerLen <= 0) { + if (g_emptyResolveLogBudget > 0) { + g_emptyResolveLogBudget--; + Log("DIAG ResolveDisplayText EMPTY: context=0x%x key=%p key_as_str=\"%s\" caller=%p", + context, (void*)(uintptr_t)key, + key ? (const char*)(uintptr_t)key : "(null)", + __builtin_return_address(0)); + } + return result; + } + + memmove(begin, begin + 6, innerLen * sizeof(uint16_t)); + begin[innerLen] = 0; + outStr[1] = (int)(uintptr_t)(begin + innerLen); + return result; +} + +static bool InstallResolveDisplayTextHook() { + orig_ResolveDisplayText = (ResolveDisplayTextFn)InstallArmTrampolineHook( + libapp_base, 0x40A2B0, (void*)&Hook_ResolveDisplayText, "ResolveDisplayText hook"); + return orig_ResolveDisplayText != nullptr; +} + +// sub_40A29C is the tiny wrapper - `*fieldPtr` then call sub_40A2B0(a1, a2, +// *fieldPtr) - that all real callers actually invoke (sub_40A2B0 is always +// reached via this wrapper's tail-call, so a hook on sub_40A2B0 alone can't +// see the true caller via __builtin_return_address). Hooking here instead +// exposes both the real caller and the field pointer itself, so a null +// field can be reported as "RaceEvent+N" by diffing against +// g_lastSyntheticRaceEvent. Diagnostic-only, budget-gated. +static volatile int g_wrapperNullFieldLogBudget = 0; +typedef int (*ResolveDisplayTextWrapperFn)(int a1, int a2, int* fieldPtr); +static ResolveDisplayTextWrapperFn orig_ResolveDisplayTextWrapper = nullptr; + +extern "C" int Hook_ResolveDisplayTextWrapperDiag(int a1, int a2, int* fieldPtr) { + if (g_wrapperNullFieldLogBudget > 0) { + g_wrapperNullFieldLogBudget--; + ptrdiff_t offset = g_lastSyntheticRaceEvent + ? ((uint8_t*)fieldPtr - (uint8_t*)g_lastSyntheticRaceEvent) + : -1; + int fieldVal = (fieldPtr) ? *fieldPtr : 0; + Log("DIAG sub_40A29C: fieldPtr=%p (RaceEvent+%ld) *fieldPtr=%p as_str=\"%s\" caller=%p", + (void*)fieldPtr, (long)offset, (void*)(uintptr_t)fieldVal, + fieldVal ? (const char*)(uintptr_t)fieldVal : "(null)", + __builtin_return_address(0)); + } + return orig_ResolveDisplayTextWrapper(a1, a2, fieldPtr); +} + +static bool InstallResolveDisplayTextWrapperDiagHook() { + orig_ResolveDisplayTextWrapper = (ResolveDisplayTextWrapperFn)InstallArmTrampolineHook( + libapp_base, 0x40A29C, (void*)&Hook_ResolveDisplayTextWrapperDiag, "ResolveDisplayTextWrapper diag hook"); + return orig_ResolveDisplayTextWrapper != nullptr; +} diff --git a/mpcore/src/main/cpp/lan_event_injection.h b/mpcore/src/main/cpp/lan_event_injection.h index 2701266..28ff886 100644 --- a/mpcore/src/main/cpp/lan_event_injection.h +++ b/mpcore/src/main/cpp/lan_event_injection.h @@ -27,223 +27,148 @@ #include #include #include "util/util.h" +#include "util/hook_install.h" extern void* libapp_base; extern void* g_mapScreenInstance; // set by Hook_MapScreenCtor in main.cpp -// Diagnostic-only: remembers the last synthetic RaceEvent's address so a -// null-field pointer seen elsewhere can be reported as "RaceEvent+N" - see -// Hook_ResolveDisplayTextWrapperDiag (cont.33). +// Diagnostic-only: last synthetic RaceEvent's address, so a null-field +// pointer seen elsewhere can be reported as "RaceEvent+N". static void* g_lastSyntheticRaceEvent = nullptr; -// Set by Hook_LayoutScreenCtor once event_details is captured (cont.35); -// gates the whole deferred-fire chain below. +// Set by Hook_LayoutScreenCtor once event_details is captured; gates the +// deferred-fire test chain below. static bool g_firedEventTest = false; -// cont.51: default is now FALSE. Live-tested and confirmed this timer- -// driven kOutputChain test harness actively HIJACKS real player navigation, -// not just our own synthetic event - its deferred-fire capture logic -// (Hook_LayoutScreenCtor) grabs whichever LayoutScreen constructs next with -// no check that it's actually part of OUR synthetic event's own flow, and -// since *(screenOwner+8) is a single FlowNode-executor object shared by -// EVERY screen (cont.47), firing a queued "CONTINUE"/"BACK" against it -// affects whatever screen is currently active - including a real player's -// own event_detail/car_select/loadout, reached via their own genuine taps, -// with no synthetic event involved at all. User reported exactly this: a -// real event_detail->car_select flow getting auto-advanced into loadout -// and then auto-backed-out again within about a second, entirely on its -// own, mid-normal-play. InjectSyntheticEvent/the map pin itself still fires -// normally either way (gated separately by kInjectSyntheticEvent, not this -// flag) - only the automatic timer-driven navigation is disabled. Real taps -// and the on-demand triggers (TriggerOpenCarSelectOnDemand, -// TriggerTrueDirectCarSelectJump, cont.44/48 - neither gated by this flag) -// remain fully unaffected either way. Flip to true only for a deliberate, -// isolated re-run of the original cont.35-41 automatic test/demo path (not -// during any session where a real player might also be navigating). +// Timer-driven test harness that scripts a full event->car_select->loadout +// ->exit walkthrough. Kept false by default: live-tested and confirmed it +// hijacks REAL player navigation too, not just the synthetic event - its +// deferred-fire capture (Hook_LayoutScreenCtor) grabs whichever LayoutScreen +// constructs next with no check that it's part of our own synthetic flow, +// since the FlowNode-executor object is shared by every screen. Only flip +// on for an isolated test run, never during real play. The on-demand +// triggers below (TriggerOpenCarSelectOnDemand/TriggerTrueDirectCarSelectJump) +// are independent of this flag and unaffected either way. static constexpr bool kEnableAutoChainTest = false; -// cont.45: demo scaffolding - open car_select ~2s after the map first -// loads, on a fresh launch, WITHOUT any of kOutputChain's scripted -// continuation afterward (unlike kEnableAutoChainTest above, this fires -// TriggerOpenCarSelectOnDemand - cont.44's on-demand primitive - exactly -// once and then does nothing further, so the screen stays on whatever the -// player/tester lands on instead of auto-navigating through the rest of -// the flow). Forward-declared here (defined at the bottom of this file, -// alongside the rest of cont.44's on-demand-opening code) since -// Hook_MapScreenTick, defined earlier, calls it. Back to `false` -// (production default) now that the cont.45/46/47/48 live demos are done - -// the real trigger going forward is a lobby-overlay button calling -// TriggerOpenCarSelectOnDemand()/TriggerTrueDirectCarSelectJump() via JNI -// (cont.44/48), not this boot-timer demo path. Flip back to `true` only -// for another live boot-time demo. +// Demo-only: opens car_select ~2s after the map first loads, once, with no +// scripted continuation afterward (unlike kEnableAutoChainTest). Kept off in +// production - the real trigger is a lobby-overlay button calling +// TriggerOpenCarSelectOnDemand()/TriggerTrueDirectCarSelectJump() via JNI. static constexpr bool kAutoOpenCarSelectAtBoot = false; static bool g_firedAutoOpenCarSelect = false; extern "C" void TriggerOpenCarSelectOnDemand(); -// cont.45: the user watched the live demo and pointed out // TriggerOpenCarSelectOnDemand only reaches EventDetails (the event's own -// stakes/name screen), not car_select itself - correct, that's exactly -// what a real map-pin tap does too (cont.35). Auto-skip past it with the -// same proven single-hop deferred-fire pattern as everywhere else in this -// file (capture the screen via Hook_LayoutScreenCtor, fire once >=1.5s of -// wall clock has passed via Hook_InternStringDiag - firing synchronously -// inside the ctor callback silently no-ops, see cont.35's original -// discovery) - but as its OWN minimal, independent one-hop mechanism, not -// reusing kOutputChain/g_firedEventTest, so it stops exactly at -// car_select instead of continuing on into loadout/back-out like the old -// scripted chain would. +// stakes/name screen), matching what a real map-pin tap does - this auto- +// skips past it to car_select, as its own single-hop deferred-fire (capture +// the screen via Hook_LayoutScreenCtor, fire once >=1.5s later via +// Hook_InternStringDiag - firing synchronously inside the ctor callback +// silently no-ops), independent of kOutputChain/g_firedEventTest so it +// stops exactly at car_select instead of continuing into loadout/back-out. static bool g_autoSkipEventDetailPending = false; // armed by TriggerOpenCarSelectOnDemand, consumed once EventDetails constructs static void* g_autoSkipEventDetailTarget = nullptr; static struct timespec g_autoSkipEventDetailSetAt = {0, 0}; static bool g_autoSkipEventDetailTargetPending = false; -// cont.46: EXPERIMENTAL - testing well below cont.35's proven 1500ms to -// see how much of the EventDetails flash can be eliminated. Live-test each -// value change; revert to 1500 immediately if this ever silently no-ops -// (screen just sits on EventDetails with no AUTO-SKIP log line). #define AUTO_SKIP_DELAY_MS 1500 -// cont.52: distinguishes "the event currently being viewed is OUR synthetic +// Distinguishes "the event currently being viewed is OUR synthetic // LAN-lobby event" from "a real in-game event/race" - needed because the -// loadout-confirm interception (cont.42/43, which redirects CONTINUE away -// from race-loading) must only apply to our own synthetic event; blindly -// intercepting every loadout confirm - as it did before this fix - also -// blocks real players from ever actually starting a real race through this -// screen, which is wrong. Tracked WITHOUT any new RE/hooking: every event's -// flow begins with a `FireEventOutput` call (cont.41 - the same primitive a -// real map-pin tap invokes via sub_17A99C, and the one our own -// TriggerOpenCarSelectOnDemand calls directly), and `EventDetails` +// loadout-confirm interception below (redirects CONTINUE away from race- +// loading) must only apply to our own synthetic event, never to a real +// player actually starting a real race. Tracked without any new hooking: +// every event's flow begins with a FireEventOutput call, and EventDetails // constructing right after is the one common downstream point every path -// shares (cont.35) - so arming a flag immediately before OUR OWN -// FireEventOutput calls, then latching it into the "current event" state -// the moment EventDetails next constructs, correctly distinguishes the two -// without needing to inspect any RaceEvent field or hook FireEventOutput -// itself. Defaults to `false` (real event) - a real player's own -// map-pin-tap flow never touches this flag at all, so it can never be -// mistaken for synthetic unless WE explicitly armed it moments before. -// TriggerTrueDirectCarSelectJump (cont.48) skips EventDetails' construction -// entirely, so it latches `g_currentEventIsSynthetic` directly instead of -// arming-then-waiting. Reset to `false` once back on `MapOverworld` (a -// natural session boundary already used elsewhere in this file) so it can -// never leak into a later, unrelated real event visit. +// shares - arming this flag immediately before OUR OWN FireEventOutput +// calls, then latching it once EventDetails next constructs, distinguishes +// the two. Defaults to false (real event); a real player's map-pin tap +// never touches this flag. TriggerTrueDirectCarSelectJump skips +// EventDetails' construction entirely, so it latches +// g_currentEventIsSynthetic directly. Reset to false back on MapOverworld +// so it can never leak into a later, unrelated real event visit. static volatile bool g_nextEventDetailsIsSynthetic = false; static volatile bool g_currentEventIsSynthetic = false; -// cont.47 DIAG: empirical FlowNode field watch - static RTTI/vtable -// archaeology for im::app::flow::FlowNode hit a wall (its vtable's RTTI -// slot isn't a plain absolute-address data reference in this PIE library, -// so simple immediate/data_ref search can't find it). Polling the pending- -// transition-looking fields identified in sub_1581A0/sub_15811C's decompile -// (+44, +52, +56, +60, +64 - the "pending" state FireOutput writes - and -// +256/+260 - a byte flag + float, gating both functions, likely a -// transition-in-progress flag and countdown/animation timer) every frame -// after firing "EVENT" should reveal empirically how/when the pending -// transition actually gets consumed, without needing the exact consumer -// function's address. +// Empirical FlowNode field watch (im::app::flow::FlowNode's vtable RTTI +// isn't a plain data reference in this PIE library, so static archaeology +// hit a wall) - polls the pending-transition-looking fields identified in +// sub_1581A0/sub_15811C's decompile (+44/+52/+56/+60/+64 = pending state +// FireOutput writes; +256/+260 = transition-in-progress flag + timer) every +// frame after firing "EVENT", to see empirically when the pending +// transition gets consumed. static void* g_flowNodeWatchPtr = nullptr; static int g_flowNodeWatchFramesLeft = 0; -// cont.47: moved up from next to Hook_FlowNodeTick (where these are first -// used) - Hook_LayoutScreenCtor, defined earlier, writes -// g_watchedEventDetailsFlowNode once EventDetails constructs. +// Written by Hook_LayoutScreenCtor once EventDetails constructs; read by +// Hook_FlowNodeTick. static void* g_watchedEventDetailsFlowNode = nullptr; static volatile int g_flowNodeTickLogBudget = 40; -// cont.48: TRUE direct-jump experiment. Confirmed live (cont.47) that -// *(screenOwner+8) is the SAME single shared FlowNode-executor object for -// every screen (MapScreen and EventDetails both resolved to the identical -// pointer this session) - only its own "+28" field (which static -// graph-node/Outputs-tree is currently loaded) actually varies per screen. -// So instead of fabricating a car_select "target" ourselves (risky - -// tightly refcounted, cont.47's own risk assessment), this captures -// EventDetails' own real "+28" tree reference (the value BEFORE -// sub_1573EC processes "EVENT", i.e. event_detail's own genuine Outputs -// tree, which - unlike map's - really does have an "EVENT"->car_select -// edge) once per session, then reuses it to TEMPORARILY point the shared -// FlowNode at event_detail's tree right before firing "EVENT" from map - -// letting the real, unmodified GetOutputNode/transition machinery resolve -// and construct car_select using 100% genuine, correctly-typed data the -// whole time. Never fabricates a target value; only redirects which -// already-real tree gets consulted. +// Direct-jump primitive: the shared FlowNode-executor object is identical +// for every screen (confirmed live) - only its own "+28" field (which +// Outputs-tree is currently loaded) varies per screen. Rather than +// fabricate a car_select target ourselves (risky, tightly refcounted), this +// captures EventDetails' own real "+28" tree reference once per session +// (the value BEFORE "EVENT" is processed, i.e. event_detail's genuine +// Outputs tree, which - unlike map's - has an "EVENT"->car_select edge), +// then temporarily points the shared FlowNode at it right before firing +// "EVENT" from map - letting the real, unmodified transition machinery +// resolve and construct car_select with 100% genuine data. Never +// fabricates a target value, only redirects which real tree gets consulted. static void* g_capturedEventDetailsTreeRef = nullptr; -// cont.55: instead of waiting for a REAL live "EVENT" transition to capture -// g_capturedEventDetailsTreeRef above (which requires event_detail to have -// actually constructed at least once this session - a real problem for a -// lobby "select car" button used before the player has ever opened any real -// event), warm it up directly the moment MapOverworld constructs, by calling -// GetOutputNode (sub_159700) ourselves with map's own node data and the -// interned "EVENT" key - exactly the same resolution sub_1573EC performs -// internally on a real transition, just invoked directly instead of via a -// live screen construction. Zero visible flash, no player action needed. -// One-shot per session; only relevant if it fails does the live-capture -// path (Hook_FlowNodeTick, below) still exist as a fallback. +// Warms up g_capturedEventDetailsTreeRef the moment MapOverworld +// constructs (calling GetOutputNode directly with map's own node data and +// the interned "EVENT" key - the same resolution a real transition +// performs internally), instead of waiting for a live "EVENT" transition - +// needed so a lobby "select car" button works even before the player has +// ever opened a real event this session. One-shot; Hook_FlowNodeTick's +// live-capture path remains as a fallback if this fails. static bool g_eventDetailsTreeWarmedUp = false; -// cont.55: the warm-up above only reconstructs the Outputs-TREE pointer - -// it does NOT replicate whatever else a real EventDetails construction -// establishes as a side effect. Live-tested (twice) and confirmed -// TriggerTrueDirectCarSelectJump still crashes on a genuinely cold session -// (SIGSEGV fault 0x38, "Dereferencing a NULL component pointer.", traced to -// a car-select-family state singleton's own +12 field, still 0 at crash -// time even with the tree warmed up - full trace in PROGRESS.md cont.55; -// the exact writer was NOT found despite deep static/dynamic RE - a -// software watchpoint chain caught 5000+ writes to an unrelated field -// without ever reaching +12). So the warm-up alone is NOT safe to rely on - -// this flag restores the real safety net: TriggerTrueDirectCarSelectJump -// additionally requires at least one EventDetails construction (real -// player navigation, or the cont.45/46 auto-skip flash - either fires this, -// since both construct a genuine EventDetails object) to have happened -// this session, exactly as it did before the warm-up was added. +// The warm-up above only reconstructs the Outputs-tree pointer, not +// whatever else a real EventDetails construction establishes as a side +// effect - live-tested and confirmed TriggerTrueDirectCarSelectJump still +// crashes on a genuinely cold session without it (SIGSEGV, a car-select- +// family state singleton's own +12 field still null even with the tree +// warmed up; the exact writer was never found despite deep RE - see +// PROGRESS.md cont.55). So TriggerTrueDirectCarSelectJump additionally +// requires at least one real EventDetails construction (real navigation or +// the auto-skip flash above, either sets this) to have happened first. static bool g_realEventDetailsVisitHappened = false; -// One-shot guard (cont.39) for PersistAcceptedUpgrades - set true once the -// BACK-chain's final hop lands back on MapOverworld and the player's -// accepted upgrades (g_modSlotSelections[], defined further down alongside -// Hook_ModSlotSelected) have been dispatched to Kotlin. Forward-declared -// here, defined later, since Hook_LayoutScreenCtor (which calls it) is -// defined before the mod-selection-tracking section that owns the data. +// One-shot guard for PersistAcceptedUpgrades - set once the exit chain's +// final hop lands back on MapOverworld and the accumulated +// g_modSlotSelections[] (mod_slot_tracking.h) have been dispatched to +// Kotlin. Forward-declared here since Hook_LayoutScreenCtor (which calls +// it) is defined before mod_slot_tracking.h is included. static bool g_upgradesPersisted = false; static void PersistAcceptedUpgrades(); -// ---- Real controlled-exit chain (cont.42) ---- +// ---- Real controlled-exit chain ---- // Triggered by a REAL player tap on the loadout screen's own confirm -// checkmark (intercepted in Hook_FireOutputDiag, defined much further -// down), instead of the scripted timer kOutputChain/g_chainIndex below -// (cont.35-40's test harness) uses. +// checkmark (intercepted in Hook_FireOutputDiag), separate from the +// scripted timer test harness (kOutputChain/g_chainIndex) below. // -// NOT a fixed 3-hop BACK sequence, unlike the test harness - a live crash -// (cont.42) proved that assumption unsafe: substituting "BACK" for the -// loadout's real "CONTINUE" caused car_select to auto-cascade forward on -// ITS OWN (independent of navigation direction - almost certainly because -// this test event's car class has exactly one available car, so car_select -// has nothing for the player to actually choose and skips itself), racing -// straight back through loadout into race-loading before our fixed-count -// deferred chain could catch up - by the time our 2nd scripted BACK fired -// against its captured (by-then-stale/destroyed) target, it crashed -// (SIGSEGV fault 0x74, same class of stale-pointer bug as cont.40's -// widened-delay crash, just triggered by real game behavior instead of a -// deliberately-widened test delay). -// -// Fixed by tracking the CURRENTLY DISPLAYED screen live at each step -// (g_lastLayoutScreenInstance/g_lastLayoutScreenName, always fresh, never a -// stale snapshot) instead of waiting for a specific expected screen to -// construct, and by capping attempts rather than assuming a fixed hop -// count - repeatedly fires "BACK" against whatever's on screen right now, -// checking after each ~1.5s delay whether MapOverworld has been reached -// yet, up to REAL_EXIT_CHAIN_MAX_ATTEMPTS times. Also refuses to fire BACK -// against a screen outside the known car-select-flow set (see -// IsKnownBackableCarFlowScreen below) - if some other auto-cascade lands us -// somewhere unexpected (e.g. PreRaceLoadingScreen, which may not even have -// a BACK output configured), stop and log rather than firing blindly. -// cont.43: raised from 10 - live-tested and found the tick-driven -// auto-continue re-fires much faster than actual screen navigation can -// process (~60-70/sec, all against the SAME still-stale flowNode until the -// real transition catches up), burning through a small cap in ~150ms -// before the ACTUALLY-needed later hops (once car_select genuinely -// reconstructs and its own tick starts re-firing too) ever get a chance. +// NOT a fixed 3-hop BACK sequence - a live crash proved that assumption +// unsafe: substituting "BACK" for the loadout's real "CONTINUE" made +// car_select auto-cascade forward on its own (this test event's car class +// has exactly one available car, so car_select has nothing to choose and +// skips itself), racing back through loadout into race-loading before a +// fixed-count deferred chain could catch up, and crashing on a by-then- +// stale captured target. Fixed by tracking the CURRENTLY DISPLAYED screen +// live at each step (g_lastLayoutScreenInstance/g_lastLayoutScreenName, +// never a stale snapshot) instead of assuming a fixed hop count - +// repeatedly fires "BACK" against whatever's on screen now, checking after +// each ~1.5s delay whether MapOverworld has been reached, up to +// REAL_EXIT_CHAIN_MAX_ATTEMPTS times, and refusing to fire BACK against a +// screen outside the known car-select-flow set (IsKnownBackableCarFlowScreen) +// rather than blindly continuing into an unexpected screen. The cap is 300, +// not a smaller number, because the tick-driven auto-continue re-fires much +// faster than screen navigation can process (~60-70/sec against the same +// still-stale flowNode) - a small cap burns out before the actually-needed +// later hops ever get a chance. #define REAL_EXIT_CHAIN_MAX_ATTEMPTS 300 -// Moved up from next to Hook_LayoutScreenCtor (where these are first -// written) - Hook_InternStringDiag, defined right below, reads them too. static void* g_lastLayoutScreenInstance = nullptr; static const char* g_lastLayoutScreenName = nullptr; static bool g_realExitChainActive = false; @@ -260,34 +185,17 @@ static bool IsKnownBackableCarFlowScreen(const char* name) { strcmp(name, "EventDetails") == 0; } -// Generic deferred-fire chain (cont.37) for every hop after the first -// FireEventOutput call (which is special - it resolves our synthetic -// event's own handle, not a plain named output). Each entry fires -// FireOutput(screen, name, ctx={0,0}) on whichever LayoutScreen was -// constructed right after the PREVIOUS hop's fire, once >=1.5s of real -// time has passed (the pattern proven live in cont.35/36 - firing -// synchronously inside the ctor callback silently no-ops). -// -// cont.36 found that continuing past garage_select_rollout with its own -// "CONTINUE" output crashes inside RaceLoaderTask_ResetStartingLine: real -// race loading needs actual start/finish/end-of-track scene locators our -// synthetic RaceEvent has no real track scene to provide. Per the user's -// direction (cont.37), the production behavior for confirming a car -// should NOT proceed into race loading at all - it should controllably -// exit back to the map instead, recording accepted upgrades and (later, -// not yet implemented) opening the lobby overlay. Rather than invent a new -// exit path, this reuses each screen's own real, already-battle-tested -// "BACK" output - the same chain a player backing out 3 times would take: -// garage_select_rollout -> garage_select_car -> event_detail -> -// map_overworld (each BACK target confirmed from that screen's own real -// .sb Outputs data). +// Generic deferred-fire test chain for every hop after the first +// FireEventOutput call. Continuing past garage_select_rollout with its own +// "CONTINUE" crashes inside race loading (needs real start/finish scene +// locators our synthetic RaceEvent has none of), so confirming a car +// should NOT proceed into race loading - it should controllably exit back +// to the map instead. Reuses each screen's own real "BACK" output, the +// same chain a player backing out 3 times would take: garage_select_rollout +// -> garage_select_car -> event_detail -> map_overworld. static const char* kOutputChain[] = { "EVENT", // event_detail -> garage_select_car "CONTINUE", // garage_select_car -> garage_select_rollout - // cont.38's diagnostic pause (chain halted on garage_select_rollout to - // give room for manual mod-tap testing) is over - the mod-selection - // commit path is now understood and hooked (cont.38/39), so the BACK x3 - // controlled-exit chain is restored to its intended production shape. "BACK", // garage_select_rollout -> garage_select_car (controlled exit, not CONTINUE's race-loading crash) "BACK", // garage_select_car -> event_detail "BACK", // event_detail -> map_overworld @@ -343,47 +251,7 @@ static FireEventOutputFn FireEventOutput = nullptr; typedef void (*FireOutputFn)(void* screenOwner, void* nameSlotPtr, void* ctxPtr); static FireOutputFn FireOutput = nullptr; -// cont.63: the persistent per-owned-car record (color, and whatever else -// lives alongside it) - found via `sub_246950` (called from the RESPRAY -// popup's PAINT1..6 handlers, sub_188F7C, to compare a tapped swatch -// against the car's ALREADY-SAVED color) which itself is -// `*(sub_25102C(sub_77B70(), &(singleton+24)) + 4)`. Decompiled both: -// - sub_77B70 is a second, separate no-arg GetInstance()-style singleton -// getter (own "s_Instance"/"Not initialised" assert pattern, dword_AD299C) -// - almost certainly the owned-car/garage collection manager (matches -// the garage screen's own "ПОЛУЧЕНО 55/55"). -// - sub_25102C(registryPtr, &carIdKeySlot) is a hashmap lookup: buckets at -// *(registryPtr+72)/(registryPtr+76), each node is 8 words -// {key, value[0..5], next} - keyed by the car-id string's own POINTER -// VALUE (relies on car-id strings being interned/deduplicated, so the -// same car always resolves to the same pointer), returns `node+1` (the -// 6-word/24-byte value region) on a hit, or a lazily-initialized empty -// default record on a miss. -// Reusing these two real functions directly (rather than reimplementing -// the hash walk) - much safer and PROGRESS.md-consistent with this file's -// existing pattern of calling real engine primitives instead of guessing -// their internals. -#define GET_CAR_REGISTRY_OFFSET 0x77B70 -#define LOOKUP_CAR_RECORD_OFFSET 0x25102C -typedef int (*GetCarRegistryFn)(); -typedef int (*LookupCarRecordFn)(int registryPtr, int* carIdKeySlot); -static GetCarRegistryFn GetCarRegistry = nullptr; -static LookupCarRecordFn LookupCarRecord = nullptr; - -// cont.63b: user asked for a DYNAMIC color name/RGB lookup (not a static -// table baked from unpacked resources) so it keeps working if cars are ever -// added/modded later. Found `CarDescription::GetPaintJobDescription(int -// paintJobIndex)` (own assert: "paintJobIndex >= 0 && paintJobIndex < -// (int)m_PaintJobDescriptions.size()") - a plain vector-index accessor: -// `*(carDescPtr+104) + 112*paintJobIndex` (begin pointer of a -// std::vector, 112 bytes/element). Live-testing -// whether the same object `singleton+24` points to (already confirmed to -// start with the car's own ID string) is itself usable as `carDescPtr` -// here, or merely contains a pointer to it somewhere in its own further -// fields - not yet confirmed either way. -#define GET_PAINT_JOB_DESC_OFFSET 0xB3564 -typedef int (*GetPaintJobDescriptionFn)(int carDescPtr, int paintJobIndex); -static GetPaintJobDescriptionFn GetPaintJobDescription = nullptr; +#include "car_selection.h" // GetOutputNode (sub_159700, RTTI-confirmed via its own "outputIt != // m_Outputs.end()" assertion string and "GetOutputNode" function-name @@ -400,364 +268,15 @@ static GetPaintJobDescriptionFn GetPaintJobDescription = nullptr; typedef int (*GetOutputNodeFn)(void* outResultPair, void* nodeData, void* nameSlotPtr); static GetOutputNodeFn GetOutputNode = nullptr; -// cont.55: root cause of TriggerTrueDirectCarSelectJump's cold-session crash -// (SIGSEGV fault 0x38, "Dereferencing a NULL component pointer.") - traced -// via IDA through sub_170EC8/sub_17108C -> sub_171278 -> sub_23F990 -> -// sub_2A65E4 to a single, pervasively-used global singleton accessed via -// sub_890EC ("GetInstance", RTTI-confirmed via its own "s_Instance"/ -// "GetInstance" assertion strings) - a plain no-argument getter that returns -// dword_AD2A08, warning (not crashing) "Not initialised" if that global is -// still null. Something in the REAL event_detail->car_select flow evidently -// triggers this singleton's lazy Initialise() as a side effect before -// car_select's own construction code needs it; the direct-jump shortcut -// skips whatever that is. sub_244CA8 ("Initialise", RTTI-confirmed via its -// own "!s_Instance"/"Initialise" assertion strings) is the real constructor -// - malloc(0xD0) + sub_23E6AC (builds a self-contained "CurrentState - -// Car"/"CurrentState - Race" state-tracking object, no external -// dependencies visible) - safe to call directly and idempotent (soft-warns -// "Already initialised" and no-ops if dword_AD2A08 is already set, per its -// own decompiled logic). -#define SINGLETON_INSTANCE_OFFSET 0xAD2A08 -#define SINGLETON_INITIALISE_OFFSET 0x244CA8 -typedef int (*SingletonInitialiseFn)(); -static SingletonInitialiseFn SingletonInitialise = nullptr; - -// ---- cont.55: live software watchpoint on the singleton's own +12 field ---- -// Static tracing (vtable of 5 real methods - Serialize/no-op/Reset/dtor/ -// delete - none writes +12; sub_890EC/dword_AD2A08 itself is a generic, -// 200+-call-site engine state registry, not car-select-specific, so -// exhaustively checking every caller for a "+12" store isn't practical) hit -// a wall. This is a dynamic alternative: redirect dword_AD2A08 to a -// dedicated, freshly-mmap'd shadow copy of the singleton (safe because -// every caller examined this session calls sub_890EC/GetInstance() fresh -// each time rather than caching the returned pointer - confirmed via -// decompile of sub_170EC8/sub_17108C/sub_171278 and others), mprotect that -// shadow page read-only, and install a SIGSEGV handler: the very next write -// to ANY field on the shadow object faults, the handler logs the exact -// field offset + the writing instruction's own pc/lr (straight from the -// signal's ucontext - no __builtin_return_address trick needed), restores -// write access, uninstalls itself, and returns - letting the CPU -// transparently re-execute and succeed. One-shot by design (avoids needing -// unreliable single-step support - this device's gdb/Frida watchpoints are -// already known-flaky, see project memory). Any fault NOT on our shadow -// page is chained to whatever handler was previously installed (or SIG_DFL) -// so real crashes elsewhere are never masked. -// cont.55 revision: the first version of this watchpoint called mmap() -// from inside the SIGSEGV handler to allocate each new shadow page on -// every rearm - this deadlocked for real on-device (hung requiring a -// force-stop) once the chain ran deep enough, almost certainly because the -// interrupted code (deep in a malloc-using insert-loop) already held an -// internal allocator lock that our handler's own mmap() then needed too. -// Fixed by pre-allocating a whole POOL of pages with ONE mmap call, safely -// outside any signal context, during install - the handler itself now only -// ever does memcpy (small, fixed 0xD0 bytes) + mprotect (a single syscall, -// no heap-allocator interaction) + a pointer write, none of which take the -// allocator's internal locks. -#define WATCHPOINT_POOL_PAGES 300 -static void* g_watchpointPoolBase = nullptr; -static int g_watchpointPoolIndex = 0; -static void* g_shadowSingletonPage = nullptr; -static void* g_originalSingletonInstance = nullptr; -static struct sigaction g_oldSigsegvAction; -static volatile bool g_singletonWatchpointArmed = false; -static volatile int g_singletonWatchpointCatchesLeft = 0; -static volatile long g_singletonWatchpointLastLoggedOffset = -1; - -// Shared "arm/rearm" step - takes the NEXT page out of the pre-allocated -// pool (no mmap call here), seeds it from whatever dword_AD2A08 currently -// points to (the real instance on the very first call; a previous shadow, -// now containing whatever's been written so far, on every rearm from -// inside the handler), redirects dword_AD2A08 to it, and mprotects it -// read-only. Called both by the public installer below and by the handler -// itself (rearming, cont.55's chain-of-catches extension - logs a whole -// sequence of early writers instead of just the first one, up to a budget, -// since the very first write turned out to be some unrelated field at -// +112, not the +12 this investigation actually cares about). -static bool ArmSingletonWatchpointFromCurrent() { - void* singletonPtr = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET); - if (!singletonPtr) { - Log("ArmSingletonWatchpointFromCurrent: singleton not yet valid - skipping"); - return false; - } - if (g_watchpointPoolIndex >= WATCHPOINT_POOL_PAGES) { - Log("ArmSingletonWatchpointFromCurrent: pool exhausted (%d pages used) - stopping", - WATCHPOINT_POOL_PAGES); - return false; - } - size_t pageSize = (size_t)getpagesize(); - void* newShadow = (void*)((uintptr_t)g_watchpointPoolBase + - (uintptr_t)g_watchpointPoolIndex * pageSize); - g_watchpointPoolIndex++; - memcpy(newShadow, singletonPtr, 0xD0); - *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET) = newShadow; - g_shadowSingletonPage = newShadow; - mprotect(g_shadowSingletonPage, pageSize, PROT_READ); - g_singletonWatchpointArmed = true; - return true; -} - -static void SingletonField12SigsegvHandler(int sig, siginfo_t* info, void* ucontextRaw) { - uintptr_t faultAddr = (uintptr_t)info->si_addr; - uintptr_t pageStart = (uintptr_t)g_shadowSingletonPage; - uintptr_t pageEnd = pageStart + (uintptr_t)getpagesize(); - if (g_singletonWatchpointArmed && g_shadowSingletonPage && - faultAddr >= pageStart && faultAddr < pageEnd) { - ucontext_t* ctx = (ucontext_t*)ucontextRaw; - unsigned long pc = ctx->uc_mcontext.arm_pc; - unsigned long lr = ctx->uc_mcontext.arm_lr; - long fieldOffset = (long)(faultAddr - pageStart); - // Suppress repeat-offset spam (a long insert-loop into some vector at - // one fixed offset floods this otherwise) - only log when the offset - // actually changes from the previous catch, but still count/rearm - // every single one so the chain keeps moving. - if (fieldOffset != g_singletonWatchpointLastLoggedOffset) { - g_singletonWatchpointLastLoggedOffset = fieldOffset; - Log("SINGLETON WATCHPOINT[%d left]: write to shadow singleton at +%ld (addr=%p) - " - "writingPC=%p writingLR=%p", g_singletonWatchpointCatchesLeft, fieldOffset, - (void*)faultAddr, (void*)pc, (void*)lr); - } - mprotect(g_shadowSingletonPage, (size_t)getpagesize(), PROT_READ | PROT_WRITE); - g_singletonWatchpointArmed = false; - g_singletonWatchpointCatchesLeft--; - if (g_singletonWatchpointCatchesLeft > 0) { - // Let this write complete (return re-executes it against the now-RW - // page), then rearm from the NEW state on the very next entry into - // this handler for a DIFFERENT field. Signal-handler-side, but only - // touches a pre-allocated pool page (memcpy + mprotect, no mmap/ - // malloc) - see WATCHPOINT_POOL_PAGES comment above for why the - // original mmap-per-catch version deadlocked on-device. - ArmSingletonWatchpointFromCurrent(); - } else { - Log("SINGLETON WATCHPOINT: catch budget exhausted, uninstalling handler"); - sigaction(SIGSEGV, &g_oldSigsegvAction, nullptr); - } - return; // faulting instruction re-executes and now succeeds - } - // Not our watched page - never swallow a real crash. Chain to whatever - // was previously installed (bionic/debuggerd's own handler in the - // common case), falling back to default disposition if there wasn't one. - if (g_oldSigsegvAction.sa_flags & SA_SIGINFO) { - if (g_oldSigsegvAction.sa_sigaction) { - g_oldSigsegvAction.sa_sigaction(sig, info, ucontextRaw); - return; - } - } else if (g_oldSigsegvAction.sa_handler && g_oldSigsegvAction.sa_handler != SIG_IGN && - g_oldSigsegvAction.sa_handler != SIG_DFL) { - g_oldSigsegvAction.sa_handler(sig); - return; - } - signal(SIGSEGV, SIG_DFL); - raise(SIGSEGV); -} - -static bool InstallSingletonField12Watchpoint() { - if (g_singletonWatchpointArmed) { - Log("InstallSingletonField12Watchpoint: already armed - skipping"); - return false; - } - g_originalSingletonInstance = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET); - g_singletonWatchpointCatchesLeft = WATCHPOINT_POOL_PAGES; - - size_t pageSize = (size_t)getpagesize(); - g_watchpointPoolBase = mmap(nullptr, pageSize * (size_t)WATCHPOINT_POOL_PAGES, - PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (g_watchpointPoolBase == MAP_FAILED) { - Log("InstallSingletonField12Watchpoint: pool mmap failed: %s", strerror(errno)); - g_watchpointPoolBase = nullptr; - return false; - } - g_watchpointPoolIndex = 0; - - struct sigaction newAction; - memset(&newAction, 0, sizeof(newAction)); - newAction.sa_sigaction = SingletonField12SigsegvHandler; - newAction.sa_flags = SA_SIGINFO; - sigemptyset(&newAction.sa_mask); - if (sigaction(SIGSEGV, &newAction, &g_oldSigsegvAction) != 0) { - Log("InstallSingletonField12Watchpoint: sigaction failed: %s", strerror(errno)); - return false; - } - if (!ArmSingletonWatchpointFromCurrent()) { - sigaction(SIGSEGV, &g_oldSigsegvAction, nullptr); - return false; - } - Log("InstallSingletonField12Watchpoint: armed - shadow=%p (copied from real instance=%p), " - "dword_AD2A08 redirected to shadow, chain budget=%d", - g_shadowSingletonPage, g_originalSingletonInstance, g_singletonWatchpointCatchesLeft); - return true; -} - -// cont.58: a second, simpler variant of the same idea, for a riskier arming -// site the shadow-copy version above isn't safe for. Arming FROM INSIDE -// Hook_FireOutputDiag's own processing of a "CONTINUE" call (i.e. -// reentrantly, in the middle of an already-executing call chain that may -// itself touch dword_AD2A08) with the shadow-copy version hung the app for -// real (confirmed live, required force-stop) - almost certainly because -// something in that specific call chain holds a "before" reference to the -// REAL singleton pointer and compares it against a fresh GetInstance() -// result, which now diverges once dword_AD2A08 gets redirected to a -// different (shadow) address. This variant never touches dword_AD2A08's -// VALUE at all - it protects the REAL singleton's own page in place, so -// every caller (including ones with an already-cached pointer from earlier -// in the same call chain) keeps seeing the exact same address throughout. -// True one-shot (no rearm chain, unlike the version above) to avoid needing -// unreliable single-step - if the first catch is noise from an unrelated -// neighbor object sharing the same page, just re-trigger and re-arm. -static void* g_inPlaceWatchedPage = nullptr; -static volatile bool g_inPlaceWatchpointArmed = false; - -static void InPlaceSingletonSigsegvHandler(int sig, siginfo_t* info, void* ucontextRaw) { - uintptr_t faultAddr = (uintptr_t)info->si_addr; - uintptr_t pageStart = (uintptr_t)g_inPlaceWatchedPage; - uintptr_t pageEnd = pageStart + (uintptr_t)getpagesize(); - if (g_inPlaceWatchpointArmed && g_inPlaceWatchedPage && - faultAddr >= pageStart && faultAddr < pageEnd) { - ucontext_t* ctx = (ucontext_t*)ucontextRaw; - unsigned long pc = ctx->uc_mcontext.arm_pc; - unsigned long lr = ctx->uc_mcontext.arm_lr; - void* singletonPtr = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET); - long fieldOffset = singletonPtr ? (long)(faultAddr - (uintptr_t)singletonPtr) : -1; - Log("IN-PLACE WATCHPOINT: write to singleton+%ld (addr=%p) - writingPC=%p writingLR=%p", - fieldOffset, (void*)faultAddr, (void*)pc, (void*)lr); - mprotect(g_inPlaceWatchedPage, (size_t)getpagesize(), PROT_READ | PROT_WRITE); - g_inPlaceWatchpointArmed = false; - sigaction(SIGSEGV, &g_oldSigsegvAction, nullptr); - return; // faulting instruction re-executes and now succeeds - } - if (g_oldSigsegvAction.sa_flags & SA_SIGINFO) { - if (g_oldSigsegvAction.sa_sigaction) { - g_oldSigsegvAction.sa_sigaction(sig, info, ucontextRaw); - return; - } - } else if (g_oldSigsegvAction.sa_handler && g_oldSigsegvAction.sa_handler != SIG_IGN && - g_oldSigsegvAction.sa_handler != SIG_DFL) { - g_oldSigsegvAction.sa_handler(sig); - return; - } - signal(SIGSEGV, SIG_DFL); - raise(SIGSEGV); -} - -static bool InstallInPlaceSingletonWatchpoint() { - void* singletonPtr = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET); - if (!singletonPtr) { - Log("InstallInPlaceSingletonWatchpoint: singleton not yet valid - skipping"); - return false; - } - if (g_inPlaceWatchpointArmed) { - Log("InstallInPlaceSingletonWatchpoint: already armed - skipping"); - return false; - } - size_t pageSize = (size_t)getpagesize(); - uintptr_t pageStart = (uintptr_t)singletonPtr & ~((uintptr_t)pageSize - 1); - g_inPlaceWatchedPage = (void*)pageStart; - - struct sigaction newAction; - memset(&newAction, 0, sizeof(newAction)); - newAction.sa_sigaction = InPlaceSingletonSigsegvHandler; - newAction.sa_flags = SA_SIGINFO; - sigemptyset(&newAction.sa_mask); - if (sigaction(SIGSEGV, &newAction, &g_oldSigsegvAction) != 0) { - Log("InstallInPlaceSingletonWatchpoint: sigaction failed: %s", strerror(errno)); - return false; - } - if (mprotect(g_inPlaceWatchedPage, pageSize, PROT_READ) != 0) { - Log("InstallInPlaceSingletonWatchpoint: mprotect failed: %s", strerror(errno)); - sigaction(SIGSEGV, &g_oldSigsegvAction, nullptr); - return false; - } - g_inPlaceWatchpointArmed = true; - Log("InstallInPlaceSingletonWatchpoint: armed - watching real page=%p (singleton=%p) in place, " - "no pointer swap", g_inPlaceWatchedPage, singletonPtr); - return true; -} - -// cont.61: user asked to hook the singleton's own read at the moment -// car_select's CONTINUE fires, instead of a live watchpoint (cont.58's -// watchpoint attempts both landed on unrelated, high-frequency noise on the -// singleton's shared heap page - the problem was the *technique*, not the -// target). Live A/B/A-tested a full 208-byte dump (malloc(0xD0), per -// cont.55) at car_select's own CONTINUE across two different cars with a -// revert check (Ford Focus RS500 -> Dodge Challenger SRT8 392 -> Ford Focus -// RS500 again): +24 reproducibly changed with the car and reverted exactly -// when re-selecting the same one, and is a direct pointer to a -// null-terminated ASCII resource-id string with no header/vtable prefix - -// confirmed live as "ford_focus_rs500_2010_desc" and -// "dodge_challenger_srt8_392_2011_desc" respectively. (+12/+16/+20 are the -// separate, already-known FireEventOutput-populated *event* context fields, -// per cont.57 - not this.) Simplified down from the original full-object -// diagnostic dump now that the answer is known. -static const char* GetCurrentCarId() { - void* singletonPtr = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET); - if (!singletonPtr) return nullptr; - return *(const char**)((uint8_t*)singletonPtr + 24); -} - -// cont.63b: dynamic (not statically-extracted-table) color name/RGB -// resolution, per the user's own explicit request - this keeps working for -// any car added/modded into the game later, since it reads the game's own -// live engine data every time rather than a baked snapshot. -// -// Chain: `GetCarRegistry()`/`LookupCarRecord(carId)` (cont.63) resolves the -// persisted per-car record; `record+4` is the player's saved paint index -// (live A/B/A/A-verified across two cars, three colors, and a full app -// restart - cont.63). Separately, `CarDescription::GetPaintJobDescription` -// (`sub_B3564`, own assert: "paintJobIndex >= 0 && paintJobIndex < -// (int)m_PaintJobDescriptions.size()") indexes a `std::vector< -// PaintJobDescription>` (112 bytes/element) on a `CarDescription*` - -// found via `sub_188024` (car_select's own screen Tick, which calls this -// same function to render the live swatch preview): the real call is -// `sub_B3564(*(singleton+56), paintIndex)` (`sub_890EC()` in that -// function's own decompile is a red herring - it takes no real argument, -// confirmed via its own decompile, and returns the same familiar -// `dword_AD2A08` singleton this file already reads everywhere via -// `SINGLETON_INSTANCE_OFFSET`). First guess (`singleton+24`, the same -// object the car's ID string lives on) crashed for real when used as -// `CarDescription*` - `singleton+56` is a *different* field, confirmed -// correct by live-reading a real `PaintJobDescription` and getting back -// the exact expected values for the already-known "Orange" Focus paint -// (name string "Orange", `+96` packed RGBA `0xFF0078F0` decoding to -// R=240,G=120,B=0,A=255 - matches this project's own unpacked resource -// extraction of the same car exactly, byte for byte). -// -// `PaintJobDescription`'s own 112-byte layout (matches the unpacked -// resource schema's field order exactly): 6 string fields as 16-byte -// begin/current/capacityEnd-plus-padding triples (`+0` Name, `+16` -// DiffuseTextureFilePath, `+32` DiffuseMaskFilePath, `+48` BRDFFilePath, -// `+64` BRDFSpecularResponseFilePath, `+80` NumberPlateTextureFilePath), -// then `+96` SwatchColor (packed RGBA, one byte each), `+100` SwatchColor2, -// `+104` Type (int32), `+108` UseVinylMap/padding. -struct CarColor { - const char* name; - int r, g, b, a; -}; - -static CarColor GetCurrentCarColor() { - CarColor result = {"", 0, 0, 0, 0}; - if (!GetCarRegistry || !LookupCarRecord || !GetPaintJobDescription) return result; - void* singletonPtr = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET); - if (!singletonPtr) return result; - - int registryPtr = GetCarRegistry(); - if (!registryPtr) return result; - int* carIdKeySlot = (int*)((uint8_t*)singletonPtr + 24); - int recordPtr = LookupCarRecord(registryPtr, carIdKeySlot); - if (!recordPtr) return result; - int colorIndex = *(int*)((uint8_t*)(uintptr_t)recordPtr + 4); - - int carDescPtr = *(int*)((uint8_t*)singletonPtr + 56); - if (!carDescPtr) return result; - int pjPtr = GetPaintJobDescription(carDescPtr, colorIndex); - if (!pjPtr) return result; - - result.name = *(const char**)(uintptr_t)pjPtr; // word[0] = Name's own begin pointer - uint32_t swatch = *(const uint32_t*)((uint8_t*)(uintptr_t)pjPtr + 96); - result.r = swatch & 0xFF; - result.g = (swatch >> 8) & 0xFF; - result.b = (swatch >> 16) & 0xFF; - result.a = (swatch >> 24) & 0xFF; - return result; -} +// Two experimental live SIGSEGV-based watchpoints on the state singleton +// (dword_AD2A08) used to exist here (cont.55/58) to trace which function +// writes its +12 field. Both call sites were removed after the +// investigation moved on (cont.61 found a cheaper direct-read answer +// instead - see car_selection.h's GetCurrentCarId) and the code was never called +// again, so it has been deleted rather than kept as unused dead weight. +// If that investigation needs to resume, see PROGRESS.md cont.55/58 for +// the full technique (pre-allocated shadow-page pool + mprotect + +// SIGSEGV handler, chained to the previous handler for real crashes). // Declared early (moved up from its original spot further down in this // file, alongside the other resolved-function-pointer globals) so @@ -767,191 +286,7 @@ static CarColor GetCurrentCarColor() { typedef void (*InternStringFn)(void* outSlot, const char* cstr); static InternStringFn InternString = nullptr; -// ---- TEMPORARY: CopSounds::Tick mitigation (PROGRESS.md cont.29/30) ---- -// im::app::sounds::CopSounds's per-frame Tick (vtable slot, sub_304AA0) -// reads component data from whatever actor(s) it's currently tracking that -// our minimal synthetic RaceEvent/Actor doesn't provide, live-tested -// SIGSEGV. This is an ambient, global audio system unrelated to our actual -// goal (triggering the car-select transition) - rather than reverse- -// engineering its full component requirements, skip its body entirely -// during this test, same mitigation strategy already used for the QA Soak -// Test issue elsewhere in this file. Purely cosmetic (no chase-sound -// animation for a moment), not a gameplay-affecting change. -#define COPSOUNDS_TICK_OFFSET 0x304AA0 -typedef void (*CopSoundsTickFn)(int a1, int* deltaMs); -static CopSoundsTickFn orig_CopSoundsTick = nullptr; - -extern "C" void Hook_CopSoundsTick(int a1, int* deltaMs) { - (void)a1; - (void)deltaMs; - // Deliberately not calling orig_CopSoundsTick - see comment above. -} - -// ---- TEMPORARY: sub_240548 mitigation (PROGRESS.md cont.29/30) ---- -// A component-name-cache lookup: if our synthetic RaceEvent's pointer isn't -// found in its hash table (expected - it never went through the normal -// prefab-load registration that populates this cache), it falls through to -// a "build an error string, look up RTTI class name" path that crashes -// (SIGSEGV fault addr 0x8) somewhere in that RTTI/name-cache machinery. -// The function's own early-return for a null input (`if (!*a2) return -// off_AC80E0`) is a real, already-safe code path in the shipped binary - -// rather than debug the crashing fallback branch, always take that same -// safe path by returning the same sentinel directly, skipping the original -// entirely. Purely cosmetic (an empty database-lookup string, used only for -// a debug label elsewhere), not required for the actual Flow transition. -#define GET_COMPONENT_NAME_OFFSET 0x240548 -#define EMPTY_STRING_SENTINEL_OFFSET 0xAC80E0 -typedef char* (*GetComponentNameFn)(int a1, int* a2); -static GetComponentNameFn orig_GetComponentName = nullptr; - -extern "C" char* Hook_GetComponentName(int a1, int* a2) { - (void)a1; - (void)a2; - return (char*)((uintptr_t)libapp_base + EMPTY_STRING_SENTINEL_OFFSET); -} - -static bool InstallGetComponentNameSkipHook() { - uintptr_t target = (uintptr_t)libapp_base + GET_COMPONENT_NAME_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C), same trampoline pattern as the - // other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("GetComponentName skip hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_GetComponentName = (GetComponentNameFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("GetComponentName skip hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_GetComponentName; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed GetComponentName skip hook at %p, trampoline=%p", (void*)target, tramp); - return true; -} - -static bool InstallCopSoundsTickSkipHook() { - Log("InstallCopSoundsTickSkipHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + COPSOUNDS_TICK_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4-R7,R11,LR}; ADD R11,SP,#0x10), same trampoline pattern as - // the other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("CopSoundsTick skip hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_CopSoundsTick = (CopSoundsTickFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("CopSoundsTick skip hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_CopSoundsTick; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed CopSoundsTick skip hook at %p, trampoline=%p", (void*)target, tramp); - return true; -} - -// ---- TEMPORARY: internal strlen() null-guard (PROGRESS.md cont.30/31) ---- -// Crash #4's tombstone reported "backtrace: #00 pc 002c6374", which the -// previous session mistook for a raw IDA file offset and (wrongly) -// attributed to sub_2C62A0 - that offset is relative to whatever specific -// VMA/segment mapping debuggerd's own unwinder picked, NOT to libapp_base. -// The real file offset is (runtime PC - our own resolved libapp_base): -// this run logged libapp_base=0xb9d9d000 and the tombstone's raw "pc" -// register was 0xba3cc374, giving a true offset of 0x62F374 - a completely -// different, unrelated function: a hand-optimized SWAR strlen() -// (sub_62F340). Fault addr 0x0 matches exactly: `*(_DWORD*)v1` reading the -// first word of a NULL string pointer. This is a generic leaf routine used -// everywhere in the binary (likely reached while building an RTTI/class- -// name debug string for our under-registered synthetic actor, the same -// family of issue as crash #3's sub_240548 - but a different call site not -// covered by that fix). Rather than chase every caller that might pass it -// a null name pointer, guard the leaf itself: return 0 for a NULL input -// instead of dereferencing it, then fall through to the real implementation -// for every other (real) string. -#define STRLEN_OFFSET 0x62F340 -typedef int (*StrlenFn)(const char* s); -static StrlenFn orig_Strlen = nullptr; - -extern "C" int Hook_Strlen(const char* s) { - if (!s) { - Log("Strlen null-guard: called with NULL, returning 0 instead of crashing"); - return 0; - } - return orig_Strlen(s); -} - -static bool InstallStrlenNullGuardHook() { - Log("InstallStrlenNullGuardHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + STRLEN_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (MOV R1,R0; TST R0,#3) - a true leaf function, no LR push - same - // trampoline pattern as the other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("Strlen null-guard hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_Strlen = (StrlenFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("Strlen null-guard hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_Strlen; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed Strlen null-guard hook at %p, trampoline=%p", (void*)target, tramp); - return true; -} +#include "crash_workarounds.h" // ---- TEMPORARY: InternString() call-logging diagnostic (PROGRESS.md cont.32) ---- // The event_details screen displays our synthetic event's name wrapped in @@ -1057,45 +392,25 @@ extern "C" void Hook_InternStringDiag(void* outSlot, const char* cstr) { Log("AUTO-SKIP EventDetails: FireOutput returned without crashing"); } + // cont.64: TRIED a deferred (500ms) rebuild of car_select's class- + // filtered list here - live-tested, crashed for real AGAIN: SIGSEGV, + // "Dereferencing a NULL component pointer", fault addr 0x7 this time + // (vs 0x44 for the immediate-post-construct attempt) - a DIFFERENT + // null field, same function (sub_16692C), same crash class. Two + // independent crashes at two very different timings (0ms and 500ms) + // rules out simple "not ready yet" timing as the cause - sub_16692C + // most likely depends on something our SYNTHETIC event flow never + // populates at all (matching this project's many other synthetic-vs- + // real gaps, e.g. g_realEventDetailsVisitHappened). Reverted; see + // PROGRESS.md cont.64 for the full writeup and what's still open. + orig_InternStringDiag(outSlot, cstr); } static bool InstallInternStringDiagHook() { - Log("InstallInternStringDiagHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + INTERN_STRING_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4,R10,R11,LR}; ADD R11,SP,#8), same trampoline pattern as the - // other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("InternString diag hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_InternStringDiag = (InternStringDiagFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("InternString diag hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_InternStringDiag; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed InternString diag hook at %p, trampoline=%p", (void*)target, tramp); - return true; + orig_InternStringDiag = (InternStringDiagFn)InstallArmTrampolineHook( + libapp_base, INTERN_STRING_OFFSET, (void*)&Hook_InternStringDiag, "InternString diag hook"); + return orig_InternStringDiag != nullptr; } // ---- TEMPORARY: sub_87738 (fatal-log) caller-tracing diagnostic (cont.32) ---- @@ -1123,229 +438,9 @@ extern "C" void Hook_FatalLogCallerTrace(unsigned char* category, const char* me } static bool InstallFatalLogCallerTraceHook() { - Log("InstallFatalLogCallerTraceHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + 0x87738; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4-R7,R11,LR}; ADD R11,SP,#0x10), same trampoline pattern as - // the other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("FatalLog caller-trace hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_FatalLog = (FatalLogFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("FatalLog caller-trace hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_FatalLogCallerTrace; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed FatalLog caller-trace hook at %p, trampoline=%p", (void*)target, tramp); - return true; -} - -// ---- Strip the "XXXXX[...]XXXXX" missing-localization marker (cont.32) ---- -// sub_40A2B0(outEastlWideString, context, rawTextKeyPtr) is the engine's -// generic "resolve display text for this key" call - traced live via the -// FatalLog caller-trace above: it's reached from sub_162F14 as -// sub_40A29C(&v105, ctx, RaceEvent+RACEEVENT_EVENTNAME_OFFSET), i.e. this -// is exactly what turns our synthetic event's raw name into on-screen -// text. Internally it interns the key and looks it up in a string table -// (sub_40A580); on a lookup MISS it either returns empty text (if a debug -// flag at context+32 is false) or - our case - builds -// L"XXXXX[" + rawText + "]" (the L"XXXXX[" literal is real, just missed -// by an earlier naive string search because IDA's string cache didn't -// surface it as a clean literal). Since our synthetic RaceEvent's name/ -// track/etc. were never registered as real string-table keys, they always -// take the miss path. -// Rather than replicate sub_40A580's table-lookup/registration format (its -// exact hash/prefab format is unknown) or reimplement sub_40A2B0's whole -// EASTL-wide-string-building logic, this hook lets the original function -// run entirely unmodified, then post-processes its ALREADY-allocated -// output buffer: if it starts with the "XXXXX[" marker, memmove the inner -// text over the prefix (shrinking, never growing, so no realloc needed) -// and shorten the container's end pointer - the begin pointer (the real -// allocation base) is never touched, so a later free() on it stays safe. -// This is a small, generic quality-of-life fix (not specific to our event) -// - any raw-text fallback anywhere in the game will render cleanly instead -// of with the debug marker. -typedef int* (*ResolveDisplayTextFn)(int* outStr, int context, int key); -static ResolveDisplayTextFn orig_ResolveDisplayText = nullptr; - -// Diagnostic-only: identifies which key/context resolves to genuinely empty -// content (cont.33), so we know what field to populate on the synthetic -// RaceEvent. Budget-gated like the other diagnostics in this file. -static volatile int g_emptyResolveLogBudget = 0; - -extern "C" int* Hook_ResolveDisplayText(int* outStr, int context, int key) { - int* result = orig_ResolveDisplayText(outStr, context, key); - if (!outStr[0] || !outStr[1] || outStr[0] == outStr[1]) { - // cont.34: catches the OTHER lookup-miss branch inside sub_40A2B0 - // (the `!*(a2+32)` case) - returns a plain empty result with no - // "XXXXX[" marker at all, so the strip logic below never even - // triggers. Widened net for the track-name row, which this run - // resolved via this branch instead of the XXXXX-wrapped one. - if (g_emptyResolveLogBudget > 0) { - g_emptyResolveLogBudget--; - Log("DIAG ResolveDisplayText BLANK: context=0x%x key=%p key_as_str=\"%s\" caller=%p", - context, (void*)(uintptr_t)key, - key ? (const char*)(uintptr_t)key : "(null)", - __builtin_return_address(0)); - } - return result; - } - - uint16_t* begin = (uint16_t*)outStr[0]; - uint16_t* end = (uint16_t*)outStr[1]; - static const uint16_t kPrefix[] = {'X', 'X', 'X', 'X', 'X', '['}; - ptrdiff_t len = end - begin; - if (len < 6 || memcmp(begin, kPrefix, sizeof(kPrefix)) != 0) return result; - - uint16_t* p = end; - while (p > begin + 6 && *(p - 1) != ']') p--; - if (p <= begin + 6) return result; // no closing bracket found, leave as-is - - uint16_t* closeBracket = p - 1; - ptrdiff_t innerLen = closeBracket - (begin + 6); - if (innerLen <= 0) { - if (g_emptyResolveLogBudget > 0) { - g_emptyResolveLogBudget--; - Log("DIAG ResolveDisplayText EMPTY: context=0x%x key=%p key_as_str=\"%s\" caller=%p", - context, (void*)(uintptr_t)key, - key ? (const char*)(uintptr_t)key : "(null)", - __builtin_return_address(0)); - } - return result; - } - - memmove(begin, begin + 6, innerLen * sizeof(uint16_t)); - begin[innerLen] = 0; - outStr[1] = (int)(uintptr_t)(begin + innerLen); - return result; -} - -static bool InstallResolveDisplayTextHook() { - Log("InstallResolveDisplayTextHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + 0x40A2B0; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C), same trampoline pattern as the - // other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("ResolveDisplayText hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_ResolveDisplayText = (ResolveDisplayTextFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("ResolveDisplayText hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_ResolveDisplayText; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed ResolveDisplayText hook at %p, trampoline=%p", (void*)target, tramp); - return true; -} - -// ---- TEMPORARY: sub_40A29C wrapper diagnostic (cont.33) ---- -// sub_40A29C(a1, a2, fieldPtr) is the tiny wrapper - `*fieldPtr` then call -// sub_40A2B0(a1, a2, *fieldPtr) - already used to resolve our event's name -// (cont.32). It's the DIRECT target of the real higher-level call sites -// (sub_40A2B0 itself is always called FROM this wrapper's tail-call, so -// __builtin_return_address(0) inside a sub_40A2B0-level hook only ever -// shows this wrapper's own epilogue, not the real caller). Hooking here -// instead exposes both the real caller AND the field POINTER itself -// (not just its dereferenced value), so a null field can be reported as -// "RaceEvent+N" by diffing against g_lastSyntheticRaceEvent. -static volatile int g_wrapperNullFieldLogBudget = 0; -typedef int (*ResolveDisplayTextWrapperFn)(int a1, int a2, int* fieldPtr); -static ResolveDisplayTextWrapperFn orig_ResolveDisplayTextWrapper = nullptr; - -extern "C" int Hook_ResolveDisplayTextWrapperDiag(int a1, int a2, int* fieldPtr) { - if (g_wrapperNullFieldLogBudget > 0) { - // cont.34: log every call now (not just null-field ones) so the - // track-name row's real caller/field shows up too, whatever its - // exact null/empty-string shape turns out to be. - g_wrapperNullFieldLogBudget--; - ptrdiff_t offset = g_lastSyntheticRaceEvent - ? ((uint8_t*)fieldPtr - (uint8_t*)g_lastSyntheticRaceEvent) - : -1; - int fieldVal = (fieldPtr) ? *fieldPtr : 0; - Log("DIAG sub_40A29C: fieldPtr=%p (RaceEvent+%ld) *fieldPtr=%p as_str=\"%s\" caller=%p", - (void*)fieldPtr, (long)offset, (void*)(uintptr_t)fieldVal, - fieldVal ? (const char*)(uintptr_t)fieldVal : "(null)", - __builtin_return_address(0)); - } - return orig_ResolveDisplayTextWrapper(a1, a2, fieldPtr); -} - -static bool InstallResolveDisplayTextWrapperDiagHook() { - Log("InstallResolveDisplayTextWrapperDiagHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + 0x40A29C; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R11,LR}; MOV R11,SP), same trampoline pattern as the other - // hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("ResolveDisplayTextWrapper diag hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_ResolveDisplayTextWrapper = (ResolveDisplayTextWrapperFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("ResolveDisplayTextWrapper diag hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_ResolveDisplayTextWrapperDiag; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed ResolveDisplayTextWrapper diag hook at %p, trampoline=%p", (void*)target, tramp); - return true; + orig_FatalLog = (FatalLogFn)InstallArmTrampolineHook( + libapp_base, 0x87738, (void*)&Hook_FatalLogCallerTrace, "FatalLog caller-trace hook"); + return orig_FatalLog != nullptr; } // FlowSetLayoutScreenEvent's event-type ID (checked inside MapTrack::HandleEvent @@ -1384,8 +479,62 @@ static bool InstallResolveDisplayTextWrapperDiagHook() { // is what was previously showing as the empty "XXXXX[]XXXXX" marker. #define RACEEVENT_CATEGORYTAG_OFFSET 12 #define CATEGORYTAG_BUFFER_SIZE 64 + +// cont.64: the REAL car-class-filter field, found via `sub_162F14`'s own +// switch statement (it builds car_select's class-filter label string from +// `*(*(singleton+12) + 132)` - note this is `+132` on the *resolved event +// object* itself, i.e. `*(singleton+12)`, not `+12`'s own categoryTag +// buffer contents, which is what cont.29/58's false lead assumed). Fully +// decoded enum (0 is also RaceEventCtor's own zeroed default, confirmed by +// its decompile at `sub_2A4B58`, and lines up exactly with car_select's +// real "КЛАСС" dropdown order live-observed this session: ВСЕ/МАСЛКАР/ +// СПОРТКАР/GT/КАЖДЫЙ ДЕНЬ/ВНЕДОРОЖНИК/ЭКЗОТИКА): +// 0 = CAR_CLASS_ANY ("ВСЕ") +// 1 = CAR_CLASS_MUSCLE ("МАСЛКАР") +// 2 = CAR_CLASS_SPORTS ("СПОРТКАР") +// 3 = CAR_CLASS_GT ("GT") +// 4 = CAR_CLASS_SEDAN ("КАЖДЫЙ ДЕНЬ" / Everyday) +// 5 = CAR_CLASS_SUV ("ВНЕДОРОЖНИК" / Offroad) +// 6 = CAR_CLASS_SUPER ("ЭКЗОТИКА" / Exotic) +// (there's also a "CAR_CLASS_SPECIFIC_CAR" path, taken instead of this +// switch entirely when a companion vector at the resolved event's own +// +56/+60 is non-empty - not used here, out of scope for a class filter). +// This enum is a small, fixed, game-design-level set that isn't expected +// to change - hardcoding it (both here and in any future UI/native +// consumer) is the right call, unlike car color/upgrade data which comes +// from per-car resource files and needed a dynamic read (cont.63b). +#define RACEEVENT_CARCLASS_OFFSET 132 #define CATEGORYTAG_LABEL_OFFSET 28 +// cont.64: RACEEVENT_CARCLASS_OFFSET alone updates car_select's own class +// label text (confirmed live: "КЛАСС - МАСЛКАР" appeared, dropdown shown +// locked) but does NOT actually filter which cars are browsable - live- +// tested, the carousel stayed stuck on the same non-muscle car regardless. +// Root cause, found via `sub_165630` (the real handler a live player's own +// tap on a class-filter dropdown ROW resolves to, routed there by +// `sub_16677C`'s own "CLASS_FILTER_MUSCLE"/etc. string dispatch): the +// actual list-filtering logic reads a *different* field entirely - +// `*(singleton+68)` (`*(sub_890EC()+68) = a2` is that function's very +// first line) - not anything on the RaceEvent object at all. This is a +// persistent, global "current UI class filter" slot, not per-event state - +// writing it early enough (before car_select's own construction/list-build +// code first reads it) should make the initial browsable list come up +// correctly filtered from scratch, the same way it would after a real +// player tap, without needing to also replicate `sub_165630`'s own list- +// rebuild call (`sub_16692C`) - that needs a live screen-controller object +// we don't have at synthetic-event-construction time, but shouldn't be +// necessary if the field is set before that controller ever builds its +// list in the first place. +// +// Note the UI dropdown's own index scheme (this offset) is a DIFFERENT +// numbering than RACEEVENT_CARCLASS_OFFSET's - decoded from `sub_165630`'s +// own switch statement (building the exact same "CAR_CLASS_X" strings, +// just at different case numbers): +// 0 = CAR_CLASS_MUSCLE, 1 = CAR_CLASS_SPORTS, 2 = CAR_CLASS_GT, +// 3 = CAR_CLASS_SEDAN, 4 = CAR_CLASS_SUV, 5 = CAR_CLASS_SUPER, +// 6 = CAR_CLASS_ALL +#define SINGLETON_CARCLASS_FILTER_OFFSET 68 + // ---- CashReward field offsets (28-byte object, ANALYSIS.md §6aa) ---- #define CASHREWARD_BRONZE_OFFSET 16 #define CASHREWARD_SILVER_OFFSET 20 @@ -1524,9 +673,26 @@ static const char* kSyntheticEventName = "LAN: Test Lobby"; // Live A/B tests (index 0 vs 1 vs 3) seemed to show a car-selection // difference, but that was very likely unrelated noise (heap-address- // dependent or session-state-dependent), not a real effect of this field. -// The actual "which class does car_select filter to" mechanism is still -// unidentified - needs fresh investigation into car_select's own class- -// filter-application code, not this field. +// +// cont.64: SOLVED for real - see RACEEVENT_CARCLASS_OFFSET's own comment. +// The genuine class-filter field is `+132` on the resolved event object, +// not this categoryTag buffer at all. Set via kSyntheticEventCarClass +// below, written once right after RaceEventCtor() in InjectSyntheticEvent - +// together with SINGLETON_CARCLASS_FILTER_OFFSET (see its own comment; +// +132 alone only updates the label, not the actual browsable list). +static const int kSyntheticEventCarClass = 0; // CAR_CLASS_ANY by default - see RACEEVENT_CARCLASS_OFFSET's enum + +// Converts RACEEVENT_CARCLASS_OFFSET's enum (0=ANY..6=SUPER) to +// SINGLETON_CARCLASS_FILTER_OFFSET's own different index scheme (0=MUSCLE +// ..6=ALL) - see both offsets' own comments for the full enums. Keeping +// this as one small table instead of hand-converting kSyntheticEventCarClass +// twice means a future change to that one constant stays correct for both +// fields automatically. +static int RaceClassToUiFilterIndex(int raceClass) { + static const int kTable[7] = {6, 0, 1, 2, 3, 4, 5}; // index=RaceEvent enum, value=UI index + if (raceClass < 0 || raceClass > 6) return 6; // ALL, safe fallback + return kTable[raceClass]; +} static void InjectSyntheticEvent(void* mapTrackThis, const char* eventName) { void* ev = RaceEventCtor(); @@ -1536,6 +702,27 @@ static void InjectSyntheticEvent(void* mapTrackThis, const char* eventName) { } g_lastSyntheticRaceEvent = ev; + // cont.64: preset car_select's own class filter (see + // RACEEVENT_CARCLASS_OFFSET's own comment for the full enum) - + // RaceEventCtor already zeroed this to CAR_CLASS_ANY, this just makes + // the override explicit and gives future callers/toggles a single + // constant to change instead of hunting for the field again. + *(int*)((uint8_t*)ev + RACEEVENT_CARCLASS_OFFSET) = kSyntheticEventCarClass; + + // cont.64: also set the actual list-filtering field - see + // SINGLETON_CARCLASS_FILTER_OFFSET's own comment. Written here (well + // before car_select's own screen ever constructs) so its normal + // construction-time list-build code picks this up on its own, the same + // way it would after a real player's dropdown tap - no need to + // replicate sub_165630's own list-rebuild call ourselves. + void* singletonForClassFilter = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET); + if (singletonForClassFilter) { + *(int*)((uint8_t*)singletonForClassFilter + SINGLETON_CARCLASS_FILTER_OFFSET) = + RaceClassToUiFilterIndex(kSyntheticEventCarClass); + } else { + Log("LAN injection: singleton not yet valid, class filter list may not be pre-filtered"); + } + void* internedName = nullptr; InternString(&internedName, eventName); *(void**)((uint8_t*)ev + RACEEVENT_EVENTNAME_OFFSET) = internedName; @@ -1727,41 +914,11 @@ static bool InstallMapTrackHandleEventHook() { GetCarRegistry = (GetCarRegistryFn)((uintptr_t)libapp_base + GET_CAR_REGISTRY_OFFSET); LookupCarRecord = (LookupCarRecordFn)((uintptr_t)libapp_base + LOOKUP_CAR_RECORD_OFFSET); GetPaintJobDescription = (GetPaintJobDescriptionFn)((uintptr_t)libapp_base + GET_PAINT_JOB_DESC_OFFSET); + RebuildCarClassList = (RebuildCarClassListFn)((uintptr_t)libapp_base + REBUILD_CAR_CLASS_LIST_OFFSET); - uintptr_t target = (uintptr_t)libapp_base + MAPTRACK_HANDLEEVENT_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Target confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4,R5,R11,LR}; ADD R11,SP,#8 - live-disassembled, ANALYSIS.md §6y - // intro), same trampoline pattern as the other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("MapTrackHandleEvent hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_MapTrackHandleEvent = (HandleEventFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("MapTrackHandleEvent hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_MapTrackHandleEvent; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed MapTrack::HandleEvent hook at %p, trampoline=%p", (void*)target, tramp); - return true; + orig_MapTrackHandleEvent = (HandleEventFn)InstallArmTrampolineHook( + libapp_base, MAPTRACK_HANDLEEVENT_OFFSET, (void*)&Hook_MapTrackHandleEvent, "MapTrack::HandleEvent hook"); + return orig_MapTrackHandleEvent != nullptr; } // ---- LayoutScreen constructor capture (cont.35) ---- @@ -1809,6 +966,18 @@ extern "C" int Hook_LayoutScreenCtor(int a1, int a2) { // GetCurrentCarId's own comment and its call site in Hook_FireOutputDiag // (car_select's own CONTINUE). + // cont.64: TRIED three ways to force car_select's class-filtered list + // to rebuild (RebuildCarClassList/sub_16692C) - immediately post- + // construct here, on car_select's own first Tick (sub_188024, which + // never fired even once on a real event, contradicting cont.42/43's + // "~60/sec" finding), and deferred 500ms via Hook_InternStringDiag. + // The immediate and deferred attempts BOTH crashed for real (SIGSEGV, + // "Dereferencing a NULL component pointer", different fault offsets - + // 0x44 and 0x7 - ruling out simple timing as the cause). All three + // reverted - see PROGRESS.md cont.64 for the full writeup. The class + // *label* (RACEEVENT_CARCLASS_OFFSET, above) still works and ships; + // only the actual list-filtering remains unsolved. + // cont.45: capture EventDetails for the auto-skip-to-car_select demo - // consumes the arm-flag set by TriggerOpenCarSelectOnDemand, one-shot. if (g_autoSkipEventDetailPending && !g_autoSkipEventDetailTargetPending && @@ -1940,41 +1109,9 @@ extern "C" int Hook_LayoutScreenCtor(int a1, int a2) { } static bool InstallLayoutScreenCtorHook() { - Log("InstallLayoutScreenCtorHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + LAYOUTSCREEN_CTOR_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C), same trampoline pattern as the - // other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("LayoutScreenCtor hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_LayoutScreenCtor = (LayoutScreenCtorFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("LayoutScreenCtor hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_LayoutScreenCtor; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed LayoutScreenCtor hook at %p, trampoline=%p", (void*)target, tramp); - return true; + orig_LayoutScreenCtor = (LayoutScreenCtorFn)InstallArmTrampolineHook( + libapp_base, LAYOUTSCREEN_CTOR_OFFSET, (void*)&Hook_LayoutScreenCtor, "LayoutScreenCtor hook"); + return orig_LayoutScreenCtor != nullptr; } // ---- REAL FireOutput click-handler discovery (cont.41) ---- @@ -2133,267 +1270,23 @@ extern "C" int Hook_FireOutputDiag(void* flowNode, void* nameSlotPtr, void* ctxP } static bool InstallFireOutputDiagHook() { - Log("InstallFireOutputDiagHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + FIRE_OUTPUT_REAL_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4-R6,R10,R11,LR}; ADD R11,SP,#0x10), same trampoline pattern - // as the other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("FireOutput diag hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_FireOutputReal = (FireOutputRealFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("FireOutput diag hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_FireOutputDiag; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed FireOutput diag hook at %p, trampoline=%p", (void*)target, tramp); + orig_FireOutputReal = (FireOutputRealFn)InstallArmTrampolineHook( + libapp_base, FIRE_OUTPUT_REAL_OFFSET, (void*)&Hook_FireOutputDiag, "FireOutput diag hook"); + if (!orig_FireOutputReal) return false; g_fireOutputLogBudget = 500; // live from app start, no tick-based trigger needed for this diagnostic return true; } -// ---- cont.54: find who fires the unexplained "BACK" cascade on a real event ---- -// User reported the game auto-navigating BACK through EventDetails/car_select/ -// loadout on its own, on a REAL event (КРЮГЕР АВЕНЮ -> B2), after ~34s of -// inactivity on the loadout screen - confirmed via logcat this was NOT our -// own g_realExitChainActive mechanism (no "REAL CONFIRM: intercepted..." log -// line, and no "CONTINUE" preceded the BACKs at all) and NOT kEnableAutoChainTest -// (stays false). Hook_FireOutputDiag's own __builtin_return_address(0) always -// reports the SAME address (sub_1A7920+0x38) regardless of true origin, -// because sub_1A7920 - a single generic "fire this named output" wrapper used -// by essentially all UI code, real and internal alike - tail-calls into -// FIRE_OUTPUT_OFFSET (0x1BB59C) without pushing its own LR, so every caller's -// distinguishing return address gets lost by the time it reaches -// FIRE_OUTPUT_REAL_OFFSET. Hooking sub_1A7920's OWN entry instead - one level -// higher - recovers the genuine caller. -#define GENERIC_FIRE_OUTPUT_WRAPPER_OFFSET 0x1A7920 -static volatile int g_genericOutputBackLogBudget = 0; -typedef int (*GenericFireOutputWrapperFn)(void* owner, void** nameSlotPtr); -static GenericFireOutputWrapperFn orig_GenericFireOutputWrapper = nullptr; - -extern "C" int Hook_GenericFireOutputWrapperDiag(void* owner, void** nameSlotPtr) { - const char* name = (nameSlotPtr && *nameSlotPtr) ? (const char*)(*nameSlotPtr) : nullptr; - if (name && strcmp(name, "BACK") == 0 && g_genericOutputBackLogBudget > 0) { - g_genericOutputBackLogBudget--; - Log("DIAG GenericFireOutputWrapper: owner=%p output=\"BACK\" trueCaller=%p screenName=\"%s\"", - owner, __builtin_return_address(0), - g_lastLayoutScreenName ? g_lastLayoutScreenName : "(null)"); - } - return orig_GenericFireOutputWrapper(owner, nameSlotPtr); -} - -static bool InstallGenericFireOutputWrapperDiagHook() { - Log("InstallGenericFireOutputWrapperDiagHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + GENERIC_FIRE_OUTPUT_WRAPPER_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue via static disasm - // this session (PUSH {R4,R5,R11,LR}; ADD R11,SP,#8), same trampoline - // pattern as the other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("GenericFireOutputWrapper diag hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_GenericFireOutputWrapper = (GenericFireOutputWrapperFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("GenericFireOutputWrapper diag hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_GenericFireOutputWrapperDiag; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed GenericFireOutputWrapper diag hook at %p, trampoline=%p", (void*)target, tramp); - g_genericOutputBackLogBudget = 500; - return true; -} - -// ---- cont.55: diagnose the *(a1+316)/*(a1+320) values sub_170EC8 (called -// during RestrictedGarage/car_select construction) reads, to find what -// differs between a REAL event_detail->car_select flow (working) and our -// TriggerTrueDirectCarSelectJump shortcut (crashes downstream in -// sub_23F990/sub_2A65E4 on a null pointer read from whatever *(a1+320) -// resolves to). Purely observational - does not alter behavior. -#define SUB_170EC8_OFFSET 0x170EC8 -static volatile int g_sub170EC8LogBudget = 0; -typedef int (*Sub170EC8Fn)(void* a1, void* a2); -static Sub170EC8Fn orig_Sub170EC8 = nullptr; - -extern "C" int Hook_Sub170EC8Diag(void* a1, void* a2) { - if (g_sub170EC8LogBudget > 0) { - g_sub170EC8LogBudget--; - int32_t v316 = a1 ? *(int32_t*)((uint8_t*)a1 + 316) : -1; - int32_t v320 = a1 ? *(int32_t*)((uint8_t*)a1 + 320) : -1; - Log("DIAG sub_170EC8: a1=%p a2=%p *(a1+316)=%d(0x%x) *(a1+320)=%d(0x%x)", - a1, a2, v316, v316, v320, v320); - } - return orig_Sub170EC8(a1, a2); -} - -static bool InstallSub170EC8DiagHook() { - Log("InstallSub170EC8DiagHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + SUB_170EC8_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue via static disasm - // this session (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C), same trampoline - // pattern as the other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("sub_170EC8 diag hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_Sub170EC8 = (Sub170EC8Fn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("sub_170EC8 diag hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_Sub170EC8Diag; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed sub_170EC8 diag hook at %p, trampoline=%p", (void*)target, tramp); - g_sub170EC8LogBudget = 20; - return true; -} - -// ---- Neutralize sub_16C660's auto-continue (cont.43) ---- -// cont.42's live test found that substituting "BACK" for the loadout -// screen's real "CONTINUE" at the FireOutput level doesn't actually stop -// the game from proceeding into a race - it just delays it by one screen. -// Root cause, found by checking sub_16C660's OWN xrefs: it has exactly two -// callers - the real checkmark's click-dispatch (a vtable slot, 0xa9f078) -// AND `sub_188024` (`0x188024`), the car-select-family screen's own -// per-frame Tick, which calls `sub_16C660((_DWORD*)a1)` UNCONDITIONALLY as -// its very first action, every single frame, regardless of any player -// input. sub_16C660's own internal logic (gated on `a1[111]`/`sub_1EF580()` -// - see cont.41's decompile) fires the real "CONTINUE" once it judges the -// RaceEvent/session "fully configured" (a real car chosen, no more -// decisions left) - independent of whether the screen was reached by -// tapping forward or navigating back into it. That's why cont.42's -// substitution only delayed the outcome: our own BACK fire happened once, -// synchronously, but this function keeps getting called again every frame -// by the Tick and just re-fires the REAL "CONTINUE" on its own the very -// next frame, and our FireOutput-level interception guards on -// `!g_realExitChainActive` (already true after the first intercept) so it -// doesn't catch that second, tick-driven firing. -// Fix: hook sub_16C660 directly (confirmed safe, standard prologue - PUSH -// {R4-R11,LR}; ADD R11,SP,#0x1C) and skip its entire body (no popups, no -// CONTINUE-firing, nothing) whenever our own redirect is in progress -// (g_realExitChainActive) - once we've decided this session doesn't -// proceed into a race, this function should do nothing at all, on any -// screen, tick-driven or click-driven, until our own BACK-chain finishes. -// The tick caller (sub_188024) doesn't use sub_16C660's return value at -// all, so a bare `return 0` when skipped is safe. -#define CONFIRM_CAR_SELECTION_OFFSET 0x16C660 -typedef int (*ConfirmCarSelectionFn)(int* a1); -static ConfirmCarSelectionFn orig_ConfirmCarSelection = nullptr; -static volatile int g_confirmCarSelectionLogBudget = 60; // cont.43 DIAG: isolating an unresponsiveness regression - -extern "C" int Hook_ConfirmCarSelection(int* a1) { - // cont.43 DIAG: this fires ~200x/sec from app boot onward (a much - // higher, more global call rate than expected - see cont.43 writeup) - - // gate logging on the screen we actually care about, or the budget - // burns out during early boot before ever reaching car_select. - bool interesting = g_lastLayoutScreenName && - (strcmp(g_lastLayoutScreenName, "RestrictedGarage") == 0 || - strcmp(g_lastLayoutScreenName, "RestrictedGarageLoadout") == 0); - if (interesting && g_confirmCarSelectionLogBudget > 0) { - g_confirmCarSelectionLogBudget--; - Log("DIAG ConfirmCarSelection: enter a1=%p screenName=\"%s\" g_realExitChainActive=%d", - (void*)a1, g_lastLayoutScreenName, (int)g_realExitChainActive); - } - if (g_realExitChainActive) { - return 0; - } - int ret = orig_ConfirmCarSelection(a1); - if (interesting && g_confirmCarSelectionLogBudget > 0) { - Log("DIAG ConfirmCarSelection: returned %d", ret); - } - return ret; -} - -static bool InstallConfirmCarSelectionHook() { - Log("InstallConfirmCarSelectionHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + CONFIRM_CAR_SELECTION_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C), same trampoline pattern as the - // other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("ConfirmCarSelection hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_ConfirmCarSelection = (ConfirmCarSelectionFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("ConfirmCarSelection hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_ConfirmCarSelection; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed ConfirmCarSelection hook at %p, trampoline=%p", (void*)target, tramp); - return true; -} +// Three one-shot diagnostic hooks used to have their install functions +// here (cont.43/54/55: a "who fires this BACK cascade" tracer hooking +// sub_1A7920, a field-value dumper on sub_170EC8, and an attempt to +// neutralize sub_16C660's auto-continue directly - the last one was +// live-tested and found to break touch responsiveness for reasons never +// root-caused, see main.cpp's own comment on the commented-out +// InstallConfirmCarSelectionHook call). None of the three were ever +// called from main.cpp's JNI_OnLoad, so they were deleted rather than +// kept as unused dead weight - see PROGRESS.md cont.43/54/55 if this +// investigation needs to resume. // ---- FlowNode transition-processor observation (cont.47) ---- // sub_1573EC is im::app::flow::FlowNode's own per-frame transition @@ -2454,168 +1347,12 @@ extern "C" char* Hook_FlowNodeTick(int a1) { } static bool InstallFlowNodeTickHook() { - Log("InstallFlowNodeTickHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + FLOWNODE_TICK_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C), same trampoline pattern as the - // other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("FlowNodeTick hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_FlowNodeTick = (FlowNodeTickFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("FlowNodeTick hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_FlowNodeTick; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed FlowNodeTick hook at %p, trampoline=%p", (void*)target, tramp); - return true; + orig_FlowNodeTick = (FlowNodeTickFn)InstallArmTrampolineHook( + libapp_base, FLOWNODE_TICK_OFFSET, (void*)&Hook_FlowNodeTick, "FlowNodeTick hook"); + return orig_FlowNodeTick != nullptr; } -// ---- Mod-selection tracking (cont.38/39) ---- -// sub_37BF34(slotComponent, selectedEvent) is the function that commits a -// player's pick from the loadout screen's mod-picker into that slot's UI -// (name/description/icon) - see PROGRESS.md cont.38 for the full derivation. -// Its only caller, sub_37BE74, is an im::Event dispatch handler: when an -// im::app::events::UIRolloutSelectedEvent (type 1057) arrives, it's passed -// straight into sub_37BF34 as `a2` - i.e. selection *is* commit, there is no -// separate "confirm" step. The chosen CarMod is read directly from -// `*(a2+8)` (0 = cleared/"NONE"), and `*(a1+292)` identifies which slot this -// is (the same field sub_37BE74 itself compares a -// UIRolloutSlotButtonClickEvent's clicked-slot id against). Hooking here -// records the player's accepted upgrades without touching any UI code or -// replicating the engine's own text/icon-resolution logic - the original -// function still runs unmodified afterward for its real (display) purpose. -// -// Live-tested bug found and fixed: UIRolloutSelectedEvent is broadcast to -// EVERY slot's handler, not just the one whose picker was open - both -// slots' sub_37BF34 calls read the identical *(a2+8) value, since a2 (the -// event) is shared. sub_37BF34 itself only actually applies the pick when -// its own per-slot dirty flag at `a1+289` is set (that's the condition -// wrapping its entire body, per the decompile) - the other slot's call is a -// real internal no-op. Confirmed live: selecting "ВОССТ. ШИНЫ" for the FIRST -// slot only visually updated that slot (second stayed "ПУСТО (УЛУЧШ.)"), but -// an earlier version of this hook (no gate check) recorded CarMod=0x1 for -// BOTH slot 0 and slot 1 from that single tap. Checking the same `a1+289` -// flag the engine itself checks - before the original call clears it - -// makes this hook only record for the slot the pick actually applies to. -#define MODSLOT_SELECTED_OFFSET 0x37BF34 -#define MODSLOT_DIRTY_FLAG_OFFSET 289 -#define MAX_TRACKED_MOD_SLOTS 8 - -struct ModSlotSelection { - int slotId; - int carMod; // 0 = no mod selected ("NONE"/empty slot) -}; -static ModSlotSelection g_modSlotSelections[MAX_TRACKED_MOD_SLOTS] = {}; -static int g_modSlotSelectionCount = 0; - -static void RecordModSlotSelection(int slotId, int carMod) { - for (int i = 0; i < g_modSlotSelectionCount; i++) { - if (g_modSlotSelections[i].slotId == slotId) { - g_modSlotSelections[i].carMod = carMod; - Log("ModSlot: slot %d updated -> CarMod=0x%x", slotId, carMod); - return; - } - } - if (g_modSlotSelectionCount < MAX_TRACKED_MOD_SLOTS) { - g_modSlotSelections[g_modSlotSelectionCount].slotId = slotId; - g_modSlotSelections[g_modSlotSelectionCount].carMod = carMod; - g_modSlotSelectionCount++; - Log("ModSlot: slot %d recorded -> CarMod=0x%x", slotId, carMod); - } else { - Log("ModSlot: tracking array full (%d), dropping slot %d selection", MAX_TRACKED_MOD_SLOTS, slotId); - } -} - -// Dispatches whatever's accumulated in g_modSlotSelections[] to Kotlin via -// GameEvents.onUpgradesAccepted (see game_events.h) - called once from -// Hook_LayoutScreenCtor, right as the controlled BACK-chain exit lands back -// on MapOverworld. Deliberately does NOT reset g_modSlotSelectionCount -// afterward: this whole flow (InjectSyntheticEvent's single synthetic LAN -// event -> car select -> loadout -> exit) is still a one-shot test cycle, -// not a repeatable per-session flow yet - see PROGRESS.md cont.39. -static void PersistAcceptedUpgrades() { - int slotIds[MAX_TRACKED_MOD_SLOTS]; - int carMods[MAX_TRACKED_MOD_SLOTS]; - for (int i = 0; i < g_modSlotSelectionCount; i++) { - slotIds[i] = g_modSlotSelections[i].slotId; - carMods[i] = g_modSlotSelections[i].carMod; - } - Log("PersistAcceptedUpgrades: exiting to map, dispatching %d accepted slot(s) to Kotlin", - g_modSlotSelectionCount); - FireUpgradesAccepted(slotIds, carMods, g_modSlotSelectionCount); -} - -typedef int (*ModSlotSelectedFn)(int a1, int a2); -static ModSlotSelectedFn orig_ModSlotSelected = nullptr; - -extern "C" int Hook_ModSlotSelected(int a1, int a2) { - bool applies = *(uint8_t*)((uint8_t*)(uintptr_t)a1 + MODSLOT_DIRTY_FLAG_OFFSET) != 0; - if (applies) { - int slotId = *(int*)((uint8_t*)(uintptr_t)a1 + 292); - int carMod = *(int*)((uint8_t*)(uintptr_t)a2 + 8); - RecordModSlotSelection(slotId, carMod); - } - return orig_ModSlotSelected(a1, a2); -} - -static bool InstallModSlotSelectedHook() { - Log("InstallModSlotSelectedHook: called, libapp_base=%p", libapp_base); - uintptr_t target = (uintptr_t)libapp_base + MODSLOT_SELECTED_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C), same trampoline pattern as the - // other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("ModSlotSelected hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_ModSlotSelected = (ModSlotSelectedFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("ModSlotSelected hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_ModSlotSelected; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed ModSlotSelected hook at %p, trampoline=%p", (void*)target, tramp); - return true; -} +#include "mod_slot_tracking.h" // cont.59: `im::app::ui::CarSelectButton` (sub_35E134, found via RTTI in // cont.58) was hooked at its constructor and live-tested across the entire @@ -2726,40 +1463,9 @@ extern "C" void Hook_MapScreenTick(int a1, int* deltaMs) { } static bool InstallSoakTestDisableHook() { - uintptr_t target = (uintptr_t)libapp_base + MAPSCREEN_TICK_OFFSET; - uint32_t* target32 = (uint32_t*)target; - - // Confirmed ARM-mode, position-independent prologue this session - // (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C), same trampoline pattern as the - // other hooks in this file. - void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (tramp == MAP_FAILED) { - Log("SoakTestDisable hook: mmap trampoline failed"); - return false; - } - - uint32_t* tramp32 = (uint32_t*)tramp; - tramp32[0] = target32[0]; - tramp32[1] = target32[1]; - tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] - tramp32[3] = (uint32_t)(target + 8); - orig_MapScreenTick = (MapScreenTickFn)tramp; - - uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); - if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - Log("SoakTestDisable hook: mprotect target failed: %s", strerror(errno)); - return false; - } - - target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] - target32[1] = (uint32_t)(uintptr_t)&Hook_MapScreenTick; - - __builtin___clear_cache((char*)target, (char*)(target + 8)); - __builtin___clear_cache((char*)tramp, (char*)tramp + 16); - - Log("Installed Soak Test disable hook at %p, trampoline=%p", (void*)target, tramp); - return true; + orig_MapScreenTick = (MapScreenTickFn)InstallArmTrampolineHook( + libapp_base, MAPSCREEN_TICK_OFFSET, (void*)&Hook_MapScreenTick, "Soak Test disable hook"); + return orig_MapScreenTick != nullptr; } // ---- On-demand car-select opening (cont.44) ---- diff --git a/mpcore/src/main/cpp/main.cpp b/mpcore/src/main/cpp/main.cpp index 4ba8859..acd21cd 100644 --- a/mpcore/src/main/cpp/main.cpp +++ b/mpcore/src/main/cpp/main.cpp @@ -235,7 +235,24 @@ static bool InstallMapScreenCtorTraceHook() { // Flip to false to run the game completely unmodified (e.g. to capture a // baseline/"before" comparison) - true installs the track-substitution hook. // Just edit this and rebuild, no need to touch anything else. -static constexpr bool kEnableTrackSubstitutionHook = true; +// +// cont.66 CRITICAL FIX: Hook_BuildTrackScenePath (see its own comment) +// substitutes the track/env for EVERY race load with zero gating - not +// just our own synthetic LAN test event, but any real race too, including +// the game's own scripted prologue/tutorial race. Live-tested crash on a +// genuinely fresh save (SIGSEGV, null pointer deref, fault addr 0x14, on a +// background loading thread right after "BuildTrackScenePath hook fired" +// during the prologue's own PreRaceLoadingScreen) - the prologue's other +// scripted data (checkpoints/cutscene triggers/start-finish) still expects +// the ORIGINAL track, so substituting it mismatches and null-derefs +// downstream, same crash class already documented above (region3/colorado +// case). This was never hit before because every previous test session +// used a save that had already passed the prologue - this project's own +// "Track substitution scope" note ("only regular races need to work") was +// an *intent*, never actually enforced in code. Defaulting to `false` - +// this PoC toggle should only be flipped on deliberately, for a specific +// track-substitution test, not left on as the default running state. +static constexpr bool kEnableTrackSubstitutionHook = false; static constexpr bool kEnableLanEventInjectionHook = true; static constexpr bool kEnableMapScreenCtorTraceHook = false; // TEMP: isolating a reproducible SIGSEGV, see PROGRESS.md // See ANALYSIS.md §6ff/§6gg: prevents a QA-only "Soak Test" auto-race feature @@ -266,7 +283,32 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { InstallMapScreenCtorTraceHook(); } InstallCopSoundsTickSkipHook(); - InstallGetComponentNameSkipHook(); + // cont.67 CONFIRMED ROOT CAUSE, permanently disabled: this hook made + // GetComponentName (sub_240548) unconditionally return an empty-string + // sentinel for EVERY call, not just the null-input crash case its own + // original comment described (cont.29/30 - a narrow SIGSEGV while + // triggering our synthetic LAN test event's own car-select flow). + // Live-bisected this session against a real, user-reported regression: + // real races completed and granted cash/SP correctly, but the + // per-event "medal earned" persistent record (read by + // MapTrack::AddEvent/sub_369AB0 via sub_77B70/sub_251188/sub_2515D0/ + // sub_250F34 and a reflective "medal" property - feeds the + // street-completion-% badge on the map) was never written, on the + // player's very first tutorial/career race. Decompiling this hook's + // only relevant caller chain found sub_240294 does NOT just log + // GetComponentName's result for a debug label as originally assumed - + // it WRITES it into a named-cache-context object's own +8 field (the + // same kind of cache-context, via sub_7566C, that the medal-lookup + // chain also resolves through), and sub_240294 is itself called from + // sub_17A99C, the same real map-event-processing function this + // project's own FireEventOutput uses. With every hook EXCEPT this one + // installed, medal recording worked correctly live (confirmed twice); + // with this one also installed, it silently failed every time - + // isolating it as the sole cause. Left declared/installable below in + // case the original narrow crash needs revisiting, but must not ship + // installed - it silently corrupts real save progression for every + // player, not just the one synthetic test scenario it was written for. + // InstallGetComponentNameSkipHook(); InstallStrlenNullGuardHook(); InstallInternStringDiagHook(); InstallFatalLogCallerTraceHook(); @@ -283,7 +325,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { // installed vs 1/1 without). sub_16C660 is called at a much higher, // more global frequency (~60/sec, from app boot onward) than any // other function hooked in this project - too risky to keep - // chasing blind. See lan_event_injection.h for the full writeup; + // chasing blind. See lan_event_injection.h for the full writeup; // the FireOutput-level interception was widened instead (does not // need this hook). // InstallConfirmCarSelectionHook(); diff --git a/mpcore/src/main/cpp/mod_slot_tracking.h b/mpcore/src/main/cpp/mod_slot_tracking.h new file mode 100644 index 0000000..c57eca1 --- /dev/null +++ b/mpcore/src/main/cpp/mod_slot_tracking.h @@ -0,0 +1,86 @@ +#pragma once + +// Tracks which upgrade the player picks in each loadout mod-slot picker, so +// the accepted set can be dispatched to Kotlin once the player exits back to +// the map. See PROGRESS.md cont.38/39 for the full derivation. + +#include +#include "util/util.h" +#include "util/hook_install.h" +#include "game_events.h" + +extern void* libapp_base; + +// sub_37BF34(slotComponent, selectedEvent) commits a player's pick from the +// loadout screen's mod-picker into that slot's UI. Its only caller dispatches +// a UIRolloutSelectedEvent (type 1057) to EVERY slot's handler, not just the +// one whose picker was open - all slots see the identical event data, and +// only the slot whose own dirty flag (`a1+MODSLOT_DIRTY_FLAG_OFFSET`) is set +// actually applies it (the same flag sub_37BF34 itself checks, read here +// before the original call clears it). Recording without that check was +// live-tested and found to record the SAME pick for every slot from one tap. +#define MODSLOT_SELECTED_OFFSET 0x37BF34 +#define MODSLOT_DIRTY_FLAG_OFFSET 289 +#define MAX_TRACKED_MOD_SLOTS 8 + +struct ModSlotSelection { + int slotId; + int carMod; // 0 = no mod selected ("NONE"/empty slot) +}; +static ModSlotSelection g_modSlotSelections[MAX_TRACKED_MOD_SLOTS] = {}; +static int g_modSlotSelectionCount = 0; + +static void RecordModSlotSelection(int slotId, int carMod) { + for (int i = 0; i < g_modSlotSelectionCount; i++) { + if (g_modSlotSelections[i].slotId == slotId) { + g_modSlotSelections[i].carMod = carMod; + Log("ModSlot: slot %d updated -> CarMod=0x%x", slotId, carMod); + return; + } + } + if (g_modSlotSelectionCount < MAX_TRACKED_MOD_SLOTS) { + g_modSlotSelections[g_modSlotSelectionCount].slotId = slotId; + g_modSlotSelections[g_modSlotSelectionCount].carMod = carMod; + g_modSlotSelectionCount++; + Log("ModSlot: slot %d recorded -> CarMod=0x%x", slotId, carMod); + } else { + Log("ModSlot: tracking array full (%d), dropping slot %d selection", MAX_TRACKED_MOD_SLOTS, slotId); + } +} + +// Dispatches whatever's accumulated in g_modSlotSelections[] to Kotlin via +// GameEvents.onUpgradesAccepted - called once from Hook_LayoutScreenCtor, +// right as the controlled exit chain lands back on MapOverworld. Forward- +// declared in lan_event_injection.h since that hook is defined earlier in +// the file than this one. Deliberately does not reset +// g_modSlotSelectionCount afterward - see PROGRESS.md cont.39. +static void PersistAcceptedUpgrades() { + int slotIds[MAX_TRACKED_MOD_SLOTS]; + int carMods[MAX_TRACKED_MOD_SLOTS]; + for (int i = 0; i < g_modSlotSelectionCount; i++) { + slotIds[i] = g_modSlotSelections[i].slotId; + carMods[i] = g_modSlotSelections[i].carMod; + } + Log("PersistAcceptedUpgrades: exiting to map, dispatching %d accepted slot(s) to Kotlin", + g_modSlotSelectionCount); + FireUpgradesAccepted(slotIds, carMods, g_modSlotSelectionCount); +} + +typedef int (*ModSlotSelectedFn)(int a1, int a2); +static ModSlotSelectedFn orig_ModSlotSelected = nullptr; + +extern "C" int Hook_ModSlotSelected(int a1, int a2) { + bool applies = *(uint8_t*)((uint8_t*)(uintptr_t)a1 + MODSLOT_DIRTY_FLAG_OFFSET) != 0; + if (applies) { + int slotId = *(int*)((uint8_t*)(uintptr_t)a1 + 292); + int carMod = *(int*)((uint8_t*)(uintptr_t)a2 + 8); + RecordModSlotSelection(slotId, carMod); + } + return orig_ModSlotSelected(a1, a2); +} + +static bool InstallModSlotSelectedHook() { + orig_ModSlotSelected = (ModSlotSelectedFn)InstallArmTrampolineHook( + libapp_base, MODSLOT_SELECTED_OFFSET, (void*)&Hook_ModSlotSelected, "ModSlotSelected hook"); + return orig_ModSlotSelected != nullptr; +} diff --git a/mpcore/src/main/cpp/util/hook_install.h b/mpcore/src/main/cpp/util/hook_install.h new file mode 100644 index 0000000..4780080 --- /dev/null +++ b/mpcore/src/main/cpp/util/hook_install.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "util.h" + +// Shared installer for this project's one hooking pattern: a 2-word-copy +// trampoline (preserves the target's original first two instructions, then +// jumps back past them) plus an `LDR PC, [PC, #-4]` absolute redirect at the +// target itself. Requires the target's first two instructions to be +// position-independent (e.g. `PUSH {...}; ADD/MOV ...`) - true for every +// call site in this project so far, each confirmed via IDA disasm before +// hooking. +// +// Returns a trampoline pointer (call through it, cast to the real function +// type, to reach the original behavior) or nullptr on failure. +static void* InstallArmTrampolineHook(void* libappBase, uintptr_t offset, void* hookFn, const char* debugName) { + uintptr_t target = (uintptr_t)libappBase + offset; + uint32_t* target32 = (uint32_t*)target; + + void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (tramp == MAP_FAILED) { + Log("%s: mmap trampoline failed", debugName); + return nullptr; + } + + uint32_t* tramp32 = (uint32_t*)tramp; + tramp32[0] = target32[0]; + tramp32[1] = target32[1]; + tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4] + tramp32[3] = (uint32_t)(target + 8); + + uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); + if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { + Log("%s: mprotect target failed: %s", debugName, strerror(errno)); + return nullptr; + } + + target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] + target32[1] = (uint32_t)(uintptr_t)hookFn; + + __builtin___clear_cache((char*)target, (char*)(target + 8)); + __builtin___clear_cache((char*)tramp, (char*)tramp + 16); + + Log("Installed %s at %p, trampoline=%p", debugName, (void*)target, tramp); + return tramp; +}