Fix car class injection, two fresh-save crash bugs, medal-tracking regression; refactor lan_event_injection.h
- Hardcode car class enum for lobby car selection (fixed, small set by design) - Fix GetCurrentCarColor() crashing on an unvalidated color index from an unzeroed hashmap-miss fallback record on a genuinely fresh save - Default kEnableTrackSubstitutionHook to false: it substituted every race's track unconditionally, crashing the game's own scripted prologue race - Remove GetComponentNameSkipHook from JNI_OnLoad: it unconditionally replaced a real name used by the same cache-context the per-event medal progress record resolves through, silently breaking medal/street-completion tracking for every real race - Split lan_event_injection.h (3149 lines) into car_selection.h, crash_workarounds.h, mod_slot_tracking.h, and a shared util/hook_install.h trampoline helper; delete ~490 lines of confirmed-dead experimental code; trim comment-heavy sections to essential context Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <cstdint>
|
||||
#include <cstring>
|
||||
#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;
|
||||
}
|
||||
@@ -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 <cstdint>
|
||||
#include <cstring>
|
||||
#include <cerrno>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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 <cstdint>
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <cerrno>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#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;
|
||||
}
|
||||
Reference in New Issue
Block a user