diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 506f17a..d901043 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { @@ -37,6 +38,9 @@ android { kotlinOptions { jvmTarget = "11" } + buildFeatures { + compose = true + } } dependencies { @@ -48,6 +52,13 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.material3) + implementation(libs.androidx.activity.compose) + debugImplementation(libs.androidx.ui.tooling) + implementation("com.parse.bolts:bolts-tasks:1.4.0") implementation("org.apache.httpcomponents:httpclient-android:4.3.5.1") implementation("com.google.code.gson:gson:2.7") diff --git a/app/src/main/java/com/ea/ironmonkey/CarSelectionOverlay.kt b/app/src/main/java/com/ea/ironmonkey/CarSelectionOverlay.kt new file mode 100644 index 0000000..688f409 --- /dev/null +++ b/app/src/main/java/com/ea/ironmonkey/CarSelectionOverlay.kt @@ -0,0 +1,98 @@ +package com.ea.ironmonkey + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import nfs.mod.mpcore.GameEventListener +import nfs.mod.mpcore.GameEvents + +// Example UI-layer consumer of GameEvents.onCarSelected (see PROGRESS.md +// cont.63/63b for how carId/colorName/colorR-G-B-A get resolved natively). +// Bundles the four separate color components the JNI bridge hands over into +// one entity carrying a real Compose Color, since that's what any Composable +// actually wants to draw with - the raw ints only exist because they're what +// crosses the JNI boundary cleanly. +data class CarSelection( + val carId: String, + val colorName: String, + val color: Color, +) + +// Tiny reactive holder + GameEventListener adapter: translates the raw +// (carId, colorName, r, g, b, a) callback into a CarSelection and exposes it +// as Compose state. Kept separate from CarSelection itself so a non-Compose +// consumer (or the future native RatNet client, per the user's own +// architecture note) could register its own GameEventListener directly +// against the same raw callback without depending on this class at all. +object CarSelectionState : GameEventListener { + var current by mutableStateOf(null) + private set + + override fun onCarSelected( + carId: String, + colorName: String, + colorR: Int, + colorG: Int, + colorB: Int, + colorA: Int, + ) { + current = CarSelection( + carId = carId, + colorName = colorName, + color = Color(red = colorR, green = colorG, blue = colorB, alpha = colorA), + ) + } +} + +// Small overlay badge: car id + resolved color name + a swatch painted with +// the actual Compose Color. Shows nothing until the player has confirmed a +// car at least once this session. +@Composable +fun CarSelectionBadge(selection: CarSelection?, modifier: Modifier = Modifier) { + AnimatedVisibility(visible = selection != null, modifier = modifier) { + val s = selection ?: return@AnimatedVisibility + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + tonalElevation = 4.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(20.dp) + .background(color = s.color, shape = RoundedCornerShape(4.dp)) + ) + Text( + text = "${s.carId} – ${s.colorName}", + modifier = Modifier.padding(start = 8.dp).width(220.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +// Call once, e.g. from GameActivityMain.onCreate, to start receiving +// onCarSelected events into CarSelectionState. +fun registerCarSelectionListener() { + GameEvents.register(CarSelectionState) +} diff --git a/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt b/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt index 72f7653..2338544 100644 --- a/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt +++ b/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt @@ -4,24 +4,31 @@ import android.annotation.SuppressLint import android.app.ActivityManager import android.app.AlertDialog import android.content.Intent +import android.content.IntentFilter import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.content.res.AssetManager import android.content.res.Configuration import android.hardware.SensorManager import android.media.AudioManager +import android.view.InputDevice import android.opengl.GLES20 import android.os.Build import android.os.Bundle import android.os.Handler import android.os.PowerManager.WakeLock +import android.os.SystemClock import android.util.DisplayMetrics +import android.view.Gravity import android.view.KeyEvent +import android.view.MotionEvent import android.view.View import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.platform.ComposeView import com.ea.EAIO.EAIO import com.ea.EAMIO.StorageDirectory import com.ea.ironmonkey.Log.d @@ -31,7 +38,9 @@ import com.ea.ironmonkey.ObbHelper.getObbFileName import com.ea.ironmonkey.domain.AssetLocationType import com.ea.nimble.ApplicationLifecycle import com.ea.nimble.Global +import nfs.mod.mpcore.GameInput import nfs.mod.mpcore.MultiplayerCore.loadCore +import nfs.mod.mpcore.SyntheticInputDispatcher import org.fmod.FMODAudioDevice import java.io.File import java.io.IOException @@ -67,6 +76,27 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener { private var isSleepModeEnabled = true private var mRotation = 0 + // cont.44 DEBUG: fires MultiplayerCore.triggerCarSelectTest() on + // demand via `adb shell am broadcast -a nfs.mod.mpcore.TEST_OPEN_CARSELECT + // -p com.ea.games.nfs13_mod` - stands in for a future real lobby-overlay + // "select car" button, letting the on-demand car_select-opening call be + // exercised at an arbitrary moment (not just automatically at boot) for + // live testing. See PROGRESS.md cont.44. + private val carSelectTestReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: android.content.Context?, intent: Intent?) { + nfs.mod.mpcore.MultiplayerCore.triggerCarSelectTest() + } + } + + // cont.48 DEBUG: fires the EXPERIMENTAL true-direct-jump variant via + // `adb shell am broadcast -a nfs.mod.mpcore.TEST_TRUE_DIRECT_CARSELECT + // -p com.ea.games.nfs13_mod`. See PROGRESS.md cont.48. + private val trueDirectCarSelectTestReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: android.content.Context?, intent: Intent?) { + nfs.mod.mpcore.MultiplayerCore.triggerTrueDirectCarSelectJump() + } + } + private fun updateRequestedOrientation(i: Int) { } @@ -236,12 +266,20 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener { updateRequestedOrientation(rotation) accelerometer = Accelerometer(sensorManager, defaultSensor, rotation) gameGLSurfaceView = GameGLSurfaceView(this) + // mpcore lives in a separate Gradle module with no dependency on + // `app` (app depends on mpcore, not the other way - a reverse + // reference here would be circular), so it can't call back into this + // Activity by type. Hand it a plain interface implementation instead. + GameInput.dispatcher = object : SyntheticInputDispatcher { + override fun dispatchTap(x: Float, y: Float) = dispatchSyntheticTap(x, y) + } gameRenderer = GameRenderer(this) gameRenderer.setDrawFrameListener(this) gameGLSurfaceView.setRenderer(gameRenderer) runLoop = RunLoop(gameGLSurfaceView) mFrameLayout = FrameLayout(this) mFrameLayout.addView(gameGLSurfaceView) + mFrameLayout.addView(buildCarSelectionOverlay()) setContentView(mFrameLayout) System.loadLibrary("fmodex") System.loadLibrary("fmodevent") @@ -249,6 +287,18 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener { System.loadLibrary(Global.NIMBLE_ID) System.loadLibrary("app") loadCore() + // RECEIVER_EXPORTED: needs to be reachable from an external `adb + // shell am broadcast` sender (there's no in-app sender for this + // debug-only trigger), and ContextCompat handles the API 33+ + // required-flag requirement transparently across minSdk 27. + androidx.core.content.ContextCompat.registerReceiver( + this, carSelectTestReceiver, IntentFilter("nfs.mod.mpcore.TEST_OPEN_CARSELECT"), + androidx.core.content.ContextCompat.RECEIVER_EXPORTED + ) + androidx.core.content.ContextCompat.registerReceiver( + this, trueDirectCarSelectTestReceiver, IntentFilter("nfs.mod.mpcore.TEST_TRUE_DIRECT_CARSELECT"), + androidx.core.content.ContextCompat.RECEIVER_EXPORTED + ) d(TAG, "Init EAIO/EAMIO") EAIO.Startup(this) StorageDirectory.Startup(this) @@ -414,6 +464,8 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener { public override fun onDestroy() { i(TAG, "onDestroy") + unregisterReceiver(carSelectTestReceiver) + unregisterReceiver(trueDirectCarSelectTestReceiver) super.onDestroy() if (state == 8) { ApplicationLifecycle.onActivityDestroy(this) @@ -525,6 +577,37 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener { return gameGLSurfaceView } + // Synthesizes a tap (ACTION_DOWN + ACTION_UP) directly into the game's + // own GLSurfaceView, exactly as if the player had touched the screen at + // (x, y). Used by mpcore's GameEvents-driven automation (see + // ANALYSIS/PROGRESS.md - "trigger the native car-select Flow screen + // without needing FlowManager's internal API") to replay a known-working + // tap sequence instead of reverse-engineering the transition call. + // No special permission needed: this dispatches into our own view tree, + // it isn't a system-wide input injection. + fun dispatchSyntheticTap(x: Float, y: Float) { + i(TAG, "dispatchSyntheticTap: ($x, $y) on $gameGLSurfaceView") + val downTime = SystemClock.uptimeMillis() + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0) + // GameGLSurfaceView.onTouchEvent() branches on event.getSource() (see + // its processTouchScreenEvent/processTouchPadEvent split) - a + // MotionEvent.obtain()'d event defaults to SOURCE_UNKNOWN (0), which + // matches neither branch, so the View layer happily "consumes" it + // (dispatchTouchEvent returns true) while the native touch handler + // never actually runs. Must set this explicitly. + down.source = InputDevice.SOURCE_TOUCHSCREEN + val downConsumed = gameGLSurfaceView.dispatchTouchEvent(down) + i(TAG, "dispatchSyntheticTap: ACTION_DOWN consumed=$downConsumed") + down.recycle() + handler.postDelayed({ + val upTime = SystemClock.uptimeMillis() + val up = MotionEvent.obtain(downTime, upTime, MotionEvent.ACTION_UP, x, y, 0) + up.source = InputDevice.SOURCE_TOUCHSCREEN + gameGLSurfaceView.dispatchTouchEvent(up) + up.recycle() + }, 60) + } + fun GetViewRoot() = window.decorView.getRootView().parent fun CallGC() { @@ -587,7 +670,28 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener { return displayMetrics } - + // Example UI-layer consumer for GameEvents.onCarSelected (PROGRESS.md + // cont.63b) - a small ComposeView overlay, top-start corner, drawn above + // the game's own GLSurfaceView. Registers CarSelectionState as a + // GameEventListener so it starts updating as soon as the player + // confirms a car; the badge itself only appears once that first fires. + private fun buildCarSelectionOverlay(): ComposeView { + registerCarSelectionListener() + return ComposeView(this).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.TOP or Gravity.START, + ).apply { setMargins(24, 24, 24, 24) } + setContent { + MaterialTheme { + CarSelectionBadge(selection = CarSelectionState.current) + } + } + } + } + + override fun setContentView(view: View) { d(TAG, "setContentView($view)") if (view === mFrameLayout) { diff --git a/mpcore/src/main/cpp/game_events.h b/mpcore/src/main/cpp/game_events.h new file mode 100644 index 0000000..8ef9b97 --- /dev/null +++ b/mpcore/src/main/cpp/game_events.h @@ -0,0 +1,187 @@ +#pragma once + +#include +#include "util/util.h" + +// ---- Native -> Kotlin game-lifecycle event bridge ---- +// +// Mirrors nfs.mod.mpcore.GameEvents on the Kotlin side: native hooks call +// FireMapLoaded()/FireRaceStarted()/FireRaceEnded() whenever they observe the +// corresponding moment, and this forwards to GameEvents.dispatch*() via JNI, +// which fans it out to whatever Kotlin GameEventListeners are registered. +// +// Only onMapLoaded and onUpgradesAccepted have real triggers wired up right +// now (from Hook_MapTrackHandleEvent and Hook_LayoutScreenCtor respectively, +// both in lan_event_injection.h) - FireRaceStarted/FireRaceEnded are +// scaffolded for when those hook points are found, per the user's request +// for a general onMapLoaded/onRaceStarted/onRaceEnded-shaped event class on +// both sides. See PROGRESS.md for status. + +static JavaVM* g_gameEventsJvm = nullptr; +static jclass g_gameEventsClass = nullptr; +static jmethodID g_onMapLoadedMethod = nullptr; +static jmethodID g_onRaceStartedMethod = nullptr; +static jmethodID g_onRaceEndedMethod = nullptr; +static jmethodID g_onUpgradesAcceptedMethod = nullptr; +static jmethodID g_onCarSelectedMethod = nullptr; + +// Must be called from JNI_OnLoad (a properly app-classloader-scoped thread - +// FindClass from an arbitrary AttachCurrentThread'd native thread later would +// resolve against the wrong classloader and fail to find app classes). +inline void InitGameEvents(JavaVM* vm, JNIEnv* env) { + g_gameEventsJvm = vm; + jclass localClass = env->FindClass("nfs/mod/mpcore/GameEvents"); + if (!localClass) { + Log("GameEvents: FindClass(nfs/mod/mpcore/GameEvents) failed"); + env->ExceptionClear(); + return; + } + g_gameEventsClass = (jclass)env->NewGlobalRef(localClass); + env->DeleteLocalRef(localClass); + + g_onMapLoadedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchMapLoaded", "()V"); + g_onRaceStartedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchRaceStarted", "()V"); + g_onRaceEndedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchRaceEnded", "()V"); + g_onUpgradesAcceptedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchUpgradesAccepted", "([I[I)V"); + g_onCarSelectedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchCarSelected", + "(Ljava/lang/String;Ljava/lang/String;IIII)V"); + + if (!g_onMapLoadedMethod || !g_onRaceStartedMethod || !g_onRaceEndedMethod || !g_onUpgradesAcceptedMethod || + !g_onCarSelectedMethod) { + Log("GameEvents: one or more dispatch* methods not found on GameEvents.kt"); + env->ExceptionClear(); + } else { + Log("GameEvents: JNI bridge initialised"); + } +} + +inline JNIEnv* GetJNIEnvForCurrentThread(bool* didAttach) { + *didAttach = false; + if (!g_gameEventsJvm) return nullptr; + JNIEnv* env = nullptr; + jint res = g_gameEventsJvm->GetEnv((void**)&env, JNI_VERSION_1_6); + if (res == JNI_EDETACHED) { + if (g_gameEventsJvm->AttachCurrentThread(&env, nullptr) != JNI_OK) { + Log("GameEvents: AttachCurrentThread failed"); + return nullptr; + } + *didAttach = true; + } else if (res != JNI_OK) { + Log("GameEvents: GetEnv failed (res=%d)", res); + return nullptr; + } + return env; +} + +inline void FireGameEvent(jmethodID method, const char* name) { + if (!g_gameEventsClass || !method) { + Log("GameEvents: %s fired but JNI bridge isn't ready, dropping", name); + return; + } + bool didAttach = false; + JNIEnv* env = GetJNIEnvForCurrentThread(&didAttach); + if (!env) return; + + env->CallStaticVoidMethod(g_gameEventsClass, method); + if (env->ExceptionCheck()) { + Log("GameEvents: %s dispatch threw a Java exception", name); + env->ExceptionDescribe(); + env->ExceptionClear(); + } else { + Log("GameEvents: %s dispatched to Kotlin successfully", name); + } + if (didAttach) { + g_gameEventsJvm->DetachCurrentThread(); + } +} + +inline void FireMapLoaded() { FireGameEvent(g_onMapLoadedMethod, "onMapLoaded"); } +inline void FireRaceStarted() { FireGameEvent(g_onRaceStartedMethod, "onRaceStarted"); } +inline void FireRaceEnded() { FireGameEvent(g_onRaceEndedMethod, "onRaceEnded"); } + +// Carries data (unlike the plain lifecycle events above), so it needs its +// own JNI array marshaling instead of reusing FireGameEvent. Called once, +// right as the controlled BACK-chain exit lands back on map_overworld (see +// Hook_LayoutScreenCtor in lan_event_injection.h) - slotIds/carMods mirror +// g_modSlotSelections[] at that moment: parallel arrays, one entry per slot +// the player touched this loadout session (carMod==0 means "cleared/NONE"). +inline void FireUpgradesAccepted(const int* slotIds, const int* carMods, int count) { + if (!g_gameEventsClass || !g_onUpgradesAcceptedMethod) { + Log("GameEvents: onUpgradesAccepted fired but JNI bridge isn't ready, dropping (%d slot(s))", count); + return; + } + bool didAttach = false; + JNIEnv* env = GetJNIEnvForCurrentThread(&didAttach); + if (!env) return; + + jintArray jSlotIds = env->NewIntArray(count); + jintArray jCarMods = env->NewIntArray(count); + if (!jSlotIds || !jCarMods) { + Log("GameEvents: onUpgradesAccepted failed to allocate JNI int arrays"); + env->ExceptionClear(); + } else { + env->SetIntArrayRegion(jSlotIds, 0, count, slotIds); + env->SetIntArrayRegion(jCarMods, 0, count, carMods); + + env->CallStaticVoidMethod(g_gameEventsClass, g_onUpgradesAcceptedMethod, jSlotIds, jCarMods); + if (env->ExceptionCheck()) { + Log("GameEvents: onUpgradesAccepted dispatch threw a Java exception"); + env->ExceptionDescribe(); + env->ExceptionClear(); + } else { + Log("GameEvents: onUpgradesAccepted dispatched to Kotlin successfully (%d slot(s))", count); + } + } + + if (jSlotIds) env->DeleteLocalRef(jSlotIds); + if (jCarMods) env->DeleteLocalRef(jCarMods); + if (didAttach) { + g_gameEventsJvm->DetachCurrentThread(); + } +} + +// cont.61/63b: fired once, right as car_select's own confirm checkmark +// fires "CONTINUE" (see Hook_FireOutputDiag in lan_event_injection.h). +// carId is the resource-id string read directly off the car-select state +// singleton (GetCurrentCarId), e.g. "ford_focus_rs500_2010_desc". colorName +// + colorR/G/B/A are resolved live from the game's own engine data (see +// GetCurrentCarColor in lan_event_injection.h - +// CarDescription::GetPaintJobDescription via `*(singleton+56)`) rather than +// a static extracted table, so this keeps working for any car +// added/modded into the game later, per the user's own explicit request. +inline void FireCarSelected(const char* carId, const char* colorName, + int colorR, int colorG, int colorB, int colorA) { + if (!g_gameEventsClass || !g_onCarSelectedMethod) { + Log("GameEvents: onCarSelected fired but JNI bridge isn't ready, dropping"); + return; + } + bool didAttach = false; + JNIEnv* env = GetJNIEnvForCurrentThread(&didAttach); + if (!env) return; + + jstring jCarId = env->NewStringUTF(carId ? carId : ""); + jstring jColorName = env->NewStringUTF(colorName ? colorName : ""); + if (!jCarId || !jColorName) { + Log("GameEvents: onCarSelected failed to allocate JNI string(s)"); + env->ExceptionClear(); + } else { + env->CallStaticVoidMethod(g_gameEventsClass, g_onCarSelectedMethod, jCarId, jColorName, + colorR, colorG, colorB, colorA); + if (env->ExceptionCheck()) { + Log("GameEvents: onCarSelected dispatch threw a Java exception"); + env->ExceptionDescribe(); + env->ExceptionClear(); + } else { + Log("GameEvents: onCarSelected dispatched to Kotlin successfully " + "(carId=%s, color=%s RGBA=%d,%d,%d,%d)", + carId ? carId : "(null)", colorName ? colorName : "(null)", + colorR, colorG, colorB, colorA); + } + } + if (jCarId) env->DeleteLocalRef(jCarId); + if (jColorName) env->DeleteLocalRef(jColorName); + + if (didAttach) { + g_gameEventsJvm->DetachCurrentThread(); + } +} diff --git a/mpcore/src/main/cpp/lan_event_injection.h b/mpcore/src/main/cpp/lan_event_injection.h index 0b068c7..2701266 100644 --- a/mpcore/src/main/cpp/lan_event_injection.h +++ b/mpcore/src/main/cpp/lan_event_injection.h @@ -16,15 +16,295 @@ // prefab-instance cache (sub_173350 read side / sub_7D638 insert side) to a // {RaceEvent*, tag, Actor*} triple. +#include #include #include #include #include #include #include +#include +#include +#include #include "util/util.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). +static void* g_lastSyntheticRaceEvent = nullptr; + +// Set by Hook_LayoutScreenCtor once event_details is captured (cont.35); +// gates the whole deferred-fire 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). +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. +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. +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 +// 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` +// 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. +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. +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. +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. +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. +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. +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. +static bool g_upgradesPersisted = false; +static void PersistAcceptedUpgrades(); + +// ---- Real controlled-exit chain (cont.42) ---- +// 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. +// +// 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. +#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; +static bool g_realExitChainTriggered = false; // set once, on interception - never reset; gates the persistence dispatch below independent of how many BACK attempts it actually took +static int g_realExitChainAttempts = 0; +static struct timespec g_realExitChainTargetSetAt = {0, 0}; +static bool g_realExitChainTargetPending = false; +static bool g_realUpgradesPersisted = false; // separate one-shot guard from g_upgradesPersisted (that one's tied to the old test-harness chain) + +static bool IsKnownBackableCarFlowScreen(const char* name) { + if (!name) return false; + return strcmp(name, "RestrictedGarageLoadout") == 0 || + strcmp(name, "RestrictedGarage") == 0 || + 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). +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 +}; +static const int kOutputChainLength = (int)(sizeof(kOutputChain) / sizeof(kOutputChain[0])); + +static int g_chainIndex = 0; +static void* g_chainTarget = nullptr; +static struct timespec g_chainTargetSetAt = {0, 0}; +static bool g_chainTargetPending = false; + +static long long MonotonicMillisSince(const struct timespec* start) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (long long)(now.tv_sec - start->tv_sec) * 1000 + + (now.tv_nsec - start->tv_nsec) / 1000000; +} // ---- Static IDA offsets (all relative to libapp_base) ---- #define MAPTRACK_HANDLEEVENT_OFFSET 0x368DFC @@ -37,14 +317,1075 @@ extern void* libapp_base; #define STRING_APPEND_OFFSET 0x7B524 #define INTERN_STRING_OFFSET 0x406644 +// FlowNode::FireOutput("EVENT", ctx-built-from-handle) - see PROGRESS.md +// cont.26-28. Takes the MapScreen-shaped object itself (not its +8 flow +// node - sub_1BB59C's internal LDR R0,[R0,#8] does that) and a raw event +// handle/key (the same kind of value used as the `key` argument to +// HashInsert/AddEvent below). Resolves the handle, builds the engine's own +// event-context wrapper, and queues a "EVENT" transition for FlowManager to +// process next frame - the programmatic equivalent of tapping a map pin. +#define FIRE_EVENT_OUTPUT_OFFSET 0x17A99C +typedef void (*FireEventOutputFn)(void* mapScreenThis, uint32_t eventKey); +static FireEventOutputFn FireEventOutput = nullptr; + +// FlowNode::FireOutput itself (sub_1BB59C, cont.27) - the raw primitive +// FireEventOutput calls internally. Needed directly (cont.35) to advance +// PAST event_details: its own Outputs map ("EVENT" -> garage/garage_select_car, +// confirmed from event_detail.sb's real Flow data) is fired the same way +// map_overworld's pin tap fires the first "EVENT", just with a payload-free +// ctx={0,0} like CONTINUE - by this point the engine already knows which +// event is active from screen state, no handle needs re-resolving. +// Confirmed calling convention (raw disasm, cont.27): R0=screen-shaped +// object (its +8 field is the real flow node, this thunk does that +// indirection internally), R1=pointer to an interned-name slot (via +// InternString, not a raw C-string), R2=pointer to a 2-word ctx buffer. +#define FIRE_OUTPUT_OFFSET 0x1BB59C +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; + +// GetOutputNode (sub_159700, RTTI-confirmed via its own "outputIt != +// m_Outputs.end()" assertion string and "GetOutputNode" function-name +// string - see cont.47/48) - resolves a named output edge on a given node's +// own Outputs tree to its target node, lazily loading+caching the target by +// name (via an internal sub_156F08 call) the first time it's asked for. +// This is the exact resolution sub_1573EC (FlowNode's per-frame transition +// processor) performs on a real transition; called directly here (cont.55) +// to warm up g_capturedEventDetailsTreeRef without any live screen visit. +// a1 = output result pair (2 words: resolved node data ptr, refcount +// wrapper ptr), a2 = node data whose Outputs tree to search (e.g. map's own +// "+28" value), a3 = pointer to an interned output-name slot (e.g. "EVENT"). +#define GET_OUTPUT_NODE_OFFSET 0x159700 +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; +} + +// Declared early (moved up from its original spot further down in this +// file, alongside the other resolved-function-pointer globals) so +// Hook_InternStringDiag - which needs to call this itself for the +// deferred output-chain fire, cont.35/37 - can see it. Still resolved in +// the same place, InstallMapTrackHandleEventHook. +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; +} + +// ---- TEMPORARY: InternString() call-logging diagnostic (PROGRESS.md cont.32) ---- +// The event_details screen displays our synthetic event's name wrapped in +// "XXXXX[...]XXXXX" - the engine's own missing-localization-key marker (the +// game uses ALL_CAPS_UNDERSCORE string-table keys like "UNLOCK_CARS_POPUP" +// everywhere; our raw "LAN: Test Lobby" text isn't one, so whatever text +// lookup runs on it falls back to showing the raw text with a "this wasn't +// found" marker around it). Static search for the marker-building code +// (literal "XXXXX" bytes, UTF-16 variants, nearby localization-sounding +// functions) found nothing - it's built programmatically, not from a string +// literal. Rather than keep guessing statically, log every string the +// engine interns (sub_406644 - the same function we already use ourselves +// for flow-output names and our own event name) during a short window +// around the FireEventOutput call, to see what candidate lookup key(s) the +// text-resolution code tries against our RaceEvent's fields live. Gated by +// a call budget (not a blanket hook) since InternString is a hot, generic +// utility called constantly - unbounded logging here would flood logcat +// and could visibly affect frame timing. +static volatile int g_internStringLogBudget = 0; +typedef void (*InternStringDiagFn)(void* outSlot, const char* cstr); +static InternStringDiagFn orig_InternStringDiag = nullptr; + +extern "C" void Hook_InternStringDiag(void* outSlot, const char* cstr) { + if (g_internStringLogBudget > 0) { + g_internStringLogBudget--; + Log("DIAG InternString: \"%s\"", cstr ? cstr : "(null)"); + } + + // Deferred chain fire (cont.35/37): firing FireOutput synchronously from + // inside Hook_LayoutScreenCtor (right as a screen finishes constructing) + // returned without crashing but produced no visible transition live - + // likely too early/reentrant. Each screen's own active update loop + // keeps calling InternString every ~0.25s (live-confirmed), unlike + // MapScreen::Tick which stops once it's no longer on top, so this hook + // doubles as a real-time delay source: wait >=1.5s of wall clock after + // the screen was captured, then fire the next hop in kOutputChain. + if (g_chainTargetPending && + MonotonicMillisSince(&g_chainTargetSetAt) >= 1500) { + g_chainTargetPending = false; + const char* outputName = kOutputChain[g_chainIndex]; + void* target = g_chainTarget; + g_chainIndex++; + void* nameSlot = nullptr; + InternString(&nameSlot, outputName); + void* ctx[2] = {nullptr, nullptr}; + Log("LIVE TEST CHAIN[%d]: about to FireOutput(%s) on LayoutScreen=%p (deferred)", + g_chainIndex, outputName, target); + FireOutput(target, &nameSlot, ctx); + Log("LIVE TEST CHAIN[%d]: FireOutput returned without crashing", g_chainIndex); + } + + // cont.42: same deferred-timing pattern (wait >=1.5s of wall clock) + // as the test harness above, but re-checks the LIVE current screen at + // fire time instead of a captured snapshot - see the state block near + // the top of this file for why (a real auto-cascade made a snapshot + // stale and crashed the earlier fixed-3-hop version of this chain). + if (g_realExitChainTargetPending && + MonotonicMillisSince(&g_realExitChainTargetSetAt) >= 1500) { + g_realExitChainTargetPending = false; + if (g_lastLayoutScreenName && strcmp(g_lastLayoutScreenName, "MapOverworld") == 0) { + Log("REAL EXIT CHAIN: reached MapOverworld after %d BACK attempt(s)", g_realExitChainAttempts); + g_realExitChainActive = false; + } else if (g_realExitChainAttempts >= REAL_EXIT_CHAIN_MAX_ATTEMPTS) { + Log("REAL EXIT CHAIN: giving up after %d attempts, stuck on screenName=\"%s\"", + g_realExitChainAttempts, g_lastLayoutScreenName ? g_lastLayoutScreenName : "(null)"); + g_realExitChainActive = false; + } else if (!IsKnownBackableCarFlowScreen(g_lastLayoutScreenName)) { + Log("REAL EXIT CHAIN: landed on unexpected screenName=\"%s\" (not a known car-flow " + "screen) - stopping rather than firing BACK blindly", + g_lastLayoutScreenName ? g_lastLayoutScreenName : "(null)"); + g_realExitChainActive = false; + } else { + void* target = g_lastLayoutScreenInstance; + g_realExitChainAttempts++; + void* nameSlot = nullptr; + InternString(&nameSlot, "BACK"); + void* ctx[2] = {nullptr, nullptr}; + Log("REAL EXIT CHAIN[attempt %d/%d]: FireOutput(BACK) on LayoutScreen=%p (screenName=\"%s\")", + g_realExitChainAttempts, REAL_EXIT_CHAIN_MAX_ATTEMPTS, target, g_lastLayoutScreenName); + FireOutput(target, &nameSlot, ctx); + Log("REAL EXIT CHAIN[attempt %d/%d]: FireOutput returned without crashing", g_realExitChainAttempts, REAL_EXIT_CHAIN_MAX_ATTEMPTS); + clock_gettime(CLOCK_MONOTONIC, &g_realExitChainTargetSetAt); + g_realExitChainTargetPending = true; + } + } + + // cont.46: deferred fire for the EventDetails auto-skip (see the state + // block near the top of this file) - same wall-clock-deferred pattern + // as every other fire in this file, but testing a MUCH shorter delay + // than the standard 1500ms (see AUTO_SKIP_DELAY_MS) since the user + // wants the EventDetails flash minimized/eliminated, not just + // eventually skipped - 1500ms was cont.35's empirically-chosen safe + // value, never tested for a true minimum. + if (g_autoSkipEventDetailTargetPending && + MonotonicMillisSince(&g_autoSkipEventDetailSetAt) >= AUTO_SKIP_DELAY_MS) { + g_autoSkipEventDetailTargetPending = false; + void* target = g_autoSkipEventDetailTarget; + void* nameSlot = nullptr; + InternString(&nameSlot, "EVENT"); + void* ctx[2] = {nullptr, nullptr}; + Log("AUTO-SKIP EventDetails: about to FireOutput(EVENT) on LayoutScreen=%p (deferred, opening car_select)", target); + FireOutput(target, &nameSlot, ctx); + Log("AUTO-SKIP EventDetails: FireOutput returned without crashing"); + } + + 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; +} + +// ---- TEMPORARY: sub_87738 (fatal-log) caller-tracing diagnostic (cont.32) ---- +// The InternString trace above showed that right before each raw-text +// fallback (our event name, the track name, "CAR_CLASS_ANY", etc.) the +// engine interns the raw text, then logs "Dereferencing a NULL component +// pointer."/"...actor has been deleted." via sub_87738/sub_7C94C - the +// same generic log pair used everywhere in this binary. Since sub_87738 is +// a shared leaf-ish logging utility (not specific to any one caller), the +// only way to find WHICH function is doing the text-resolution fallback is +// to log sub_87738's caller address each time it fires. Using +// __builtin_return_address(0) instead of raw LR reads because Hook_X is a +// normal C function - the compiler backs up LR for us and this intrinsic +// reads it back reliably, no inline asm needed. +static volatile int g_fatalLogCallerBudget = 0; +typedef void (*FatalLogFn)(unsigned char* category, const char* message); +static FatalLogFn orig_FatalLog = nullptr; + +extern "C" void Hook_FatalLogCallerTrace(unsigned char* category, const char* message) { + if (g_fatalLogCallerBudget > 0) { + g_fatalLogCallerBudget--; + Log("DIAG sub_87738 caller=%p msg=\"%s\"", __builtin_return_address(0), message ? message : "(null)"); + } + orig_FatalLog(category, message); +} + +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; +} + // FlowSetLayoutScreenEvent's event-type ID (checked inside MapTrack::HandleEvent // itself, confirmed live via a conditional breakpoint - §6dd). #define EVENT_TYPE_FLOW_SET_LAYOUT_SCREEN 1025 // ---- RaceEvent field offsets (228-byte object, ANALYSIS.md §6y) ---- -#define RACEEVENT_TRACKNAME_OFFSET 72 // {begin,end,capacity} eastl-style string +// {begin,end,capacity} eastl-style NARROW char string (not a StringId/ +// interned-loc-key like RACEEVENT_EVENTNAME_OFFSET) - confirmed by the +// existing debug-dump code below (Hook_MapTrackHandleEvent) that reads +// this field directly as raw displayable text via memcpy, no lookup +// involved. Never intentionally set for our synthetic event before +// cont.33/34; whatever text showed here previously was incidental +// adjacent heap memory, not real content. +#define RACEEVENT_TRACKNAME_OFFSET 72 +#define RACEEVENT_TRACKNAME_CAPACITY_OFFSET 80 #define RACEEVENT_EVENTNAME_OFFSET 88 // single interned string pointer +// RaceEventCtor (sub_2A4B58) explicitly zeroes this field - normally filled +// in later by the property-deserialization pipeline a *real*, data-loaded +// RaceEvent goes through, which our synthetic minimally-constructed one +// never does. sub_27D1EC (called deep inside FireEventOutput's call chain - +// see PROGRESS.md cont.29) reads `*(thisPtr+12)` unconditionally as a +// small 0-7 category-tag index with no null check - a real, live-tested +// SIGSEGV (fault addr 0xc) when this is left null. Fix: point it at any +// valid zeroed memory of at least 16 bytes, so the read at +12 comes back 0 +// (a valid table index) instead of crashing. +// +// cont.33: this same pointer is ALSO read by sub_162F14 (the event_details +// speedwall/category-label builder) as `*(RaceEvent+12) + 28` - a genuine +// out-of-bounds read past our original 16-byte buffer (harmless so far, +// since adjacent heap memory happened to be zero, but real UB). Enlarged +// to CATEGORYTAG_BUFFER_SIZE and now also populate +28 with a real +// interned display string, since that's exactly the pointer +// sub_40A29C/sub_40A2B0 resolve into the bottom action bar's text - this +// is what was previously showing as the empty "XXXXX[]XXXXX" marker. +#define RACEEVENT_CATEGORYTAG_OFFSET 12 +#define CATEGORYTAG_BUFFER_SIZE 64 +#define CATEGORYTAG_LABEL_OFFSET 28 + // ---- CashReward field offsets (28-byte object, ANALYSIS.md §6aa) ---- #define CASHREWARD_BRONZE_OFFSET 16 #define CASHREWARD_SILVER_OFFSET 20 @@ -74,7 +1415,7 @@ extern void* libapp_base; typedef void* (*RaceEventCtorFn)(); typedef void* (*CashRewardCtorFn)(); typedef void* (*StringAppendFn)(void* stringObj, const char* begin, const char* end); -typedef void (*InternStringFn)(void* outSlot, const char* cstr); +// InternStringFn declared earlier in this file (see FireOutput's neighbor). typedef int (*GetCacheContextFn)(int anyComponentPtr); typedef int (*HandleEventFn)(void* mapTrackThis, void* event); typedef void (*AddEventFn)(void* mapTrackThis, uint32_t* handle); @@ -138,7 +1479,7 @@ struct FakeActor { static RaceEventCtorFn RaceEventCtor = nullptr; static CashRewardCtorFn CashRewardCtor = nullptr; static StringAppendFn StringAppend = nullptr; -static InternStringFn InternString = nullptr; +// InternString declared/defined earlier in this file (see FireOutput's neighbor). static GetCacheContextFn GetCacheContext = nullptr; static ResolveHandleFn ResolveHandle = nullptr; static HashInsertFn HashInsert = nullptr; @@ -169,17 +1510,65 @@ static const char* kTargetGroupName = "region1_foothills_track1"; // request; change freely, no offset/layout implications. static const char* kSyntheticEventName = "LAN: Test Lobby"; +// cont.58: FALSE LEAD, kept only as a documented dead-end - do not revive +// without re-deriving from scratch. Originally assumed writing an index +// into RACEEVENT_CATEGORYTAG_OFFSET's own buffer would control car_select's +// class filter, based on cont.29's own comment describing sub_27D1EC as +// reading "a small 0-7 category-tag index". Re-checked sub_27D1EC directly +// via IDA and found this was a misreading: it reads `*(a1+12)` - the +// categoryTag POINTER VALUE ITSELF, not anything inside the buffer it +// points to - and uses THAT raw pointer directly as an index into an 8-slot +// table (`dword_A2E9B0[v1]` if `v1<=7` else 0). A real heap pointer is +// always >7, so this function always takes the "else 0" branch regardless +// of what's written inside the buffer - the write below had zero effect. +// 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. + static void InjectSyntheticEvent(void* mapTrackThis, const char* eventName) { void* ev = RaceEventCtor(); if (!ev) { Log("LAN injection: RaceEventCtor failed"); return; } + g_lastSyntheticRaceEvent = ev; void* internedName = nullptr; InternString(&internedName, eventName); *(void**)((uint8_t*)ev + RACEEVENT_EVENTNAME_OFFSET) = internedName; + // Fills the event_details screen's track-name line (cont.34) - a plain + // raw-text {begin,end,capacity} field, not a StringId/interned key like + // the event name above, so no InternString/lookup involved. Uses the + // real MapTrack group name this event is attached to (kTargetGroupName) + // rather than an arbitrary label, since that's the one piece of real + // track identity already on hand. Tightly sized (capacity == end), no + // spare room needed since this is never mutated after construction. + size_t trackNameLen = strlen(kTargetGroupName); + char* trackNameBuf = (char*)malloc(trackNameLen); + memcpy(trackNameBuf, kTargetGroupName, trackNameLen); + *(char**)((uint8_t*)ev + RACEEVENT_TRACKNAME_OFFSET) = trackNameBuf; + *(char**)((uint8_t*)ev + RACEEVENT_TRACKNAME_OFFSET + 4) = trackNameBuf + trackNameLen; + *(char**)((uint8_t*)ev + RACEEVENT_TRACKNAME_CAPACITY_OFFSET) = trackNameBuf + trackNameLen; + + // See RACEEVENT_CATEGORYTAG_OFFSET comment - RaceEventCtor leaves this + // null, but sub_27D1EC (reached via FireEventOutput's call chain) reads + // *(thisPtr+12) unconditionally. calloc'd and zeroed, so that read comes + // back 0 - a valid table index, not a crash. Sized for CATEGORYTAG_LABEL_OFFSET + // (see cont.33) instead of the original bare-minimum 16 bytes. + void* categoryTag = calloc(1, CATEGORYTAG_BUFFER_SIZE); + *(void**)((uint8_t*)ev + RACEEVENT_CATEGORYTAG_OFFSET) = categoryTag; + + // Fills the bottom action bar's text (cont.33) - previously read + // uninitialized/out-of-bounds memory past the old 16-byte buffer and + // resolved to a null key, showing as an empty "XXXXX[]XXXXX" marker. + void* internedCategoryLabel = nullptr; + InternString(&internedCategoryLabel, "LAN RACE"); + *(void**)((uint8_t*)categoryTag + CATEGORYTAG_LABEL_OFFSET) = internedCategoryLabel; + void* reward = CashRewardCtor(); if (!reward) { Log("LAN injection: CashRewardCtor failed"); @@ -191,6 +1580,11 @@ static void InjectSyntheticEvent(void* mapTrackThis, const char* eventName) { components[1] = reward; FakeActor* actor = (FakeActor*)calloc(1, sizeof(FakeActor)); actor->alivePtr = actor; + // Ruled out as the crash cause (PROGRESS.md cont.29) - back to 1, the + // value this class's own comment already justifies (matches + // ResolveHandle's single borrow+release pair). The real SIGSEGV was + // RaceEvent+12 being null, not this actor's refcount - see + // RACEEVENT_CATEGORYTAG_OFFSET fix in InjectSyntheticEvent below. actor->refcount = 1; actor->componentsBegin = components; actor->componentsEnd = components + 2; @@ -222,6 +1616,15 @@ extern "C" int Hook_MapTrackHandleEvent(void* mapTrackThis, void* event) { int evType = *(int*)((uint8_t*)event + 4); if (evType == EVENT_TYPE_FLOW_SET_LAYOUT_SCREEN) { + // Fires ~12 times in a burst as the map screen populates its pins - + // this is a coarse "map loaded" proxy (first call only), not a real + // screen-lifecycle hook. Good enough for GameEvents.onMapLoaded until + // a dedicated map-screen-shown hook point is found. + static bool firedMapLoadedOnce = false; + if (!firedMapLoadedOnce) { + firedMapLoadedOnce = true; + FireMapLoaded(); + } // Read the street's stable group-name identifier (see // MAPTRACK_GROUPNAMEVEC_BEGIN_OFFSET comment) - available regardless // of whether any RaceEvent has resolved yet, unlike the @@ -317,6 +1720,13 @@ static bool InstallMapTrackHandleEventHook() { ResolveHandle = (ResolveHandleFn)((uintptr_t)libapp_base + RESOLVE_HANDLE_OFFSET); HashInsert = (HashInsertFn)((uintptr_t)libapp_base + HASH_INSERT_OFFSET); AddEvent = (AddEventFn)((uintptr_t)libapp_base + MAPTRACK_ADDEVENT_OFFSET); + FireEventOutput = (FireEventOutputFn)((uintptr_t)libapp_base + FIRE_EVENT_OUTPUT_OFFSET); + FireOutput = (FireOutputFn)((uintptr_t)libapp_base + FIRE_OUTPUT_OFFSET); + GetOutputNode = (GetOutputNodeFn)((uintptr_t)libapp_base + GET_OUTPUT_NODE_OFFSET); + SingletonInitialise = (SingletonInitialiseFn)((uintptr_t)libapp_base + SINGLETON_INITIALISE_OFFSET); + 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); uintptr_t target = (uintptr_t)libapp_base + MAPTRACK_HANDLEEVENT_OFFSET; uint32_t* target32 = (uint32_t*)target; @@ -354,6 +1764,877 @@ static bool InstallMapTrackHandleEventHook() { return true; } +// ---- LayoutScreen constructor capture (cont.35) ---- +// sub_1A571C is LayoutScreen::LayoutScreen - the generic screen class used +// for every named-layout screen in the game (already fully decompiled +// during cont.32's investigation: map_overworld, event_details, +// garage_select_car, OptionsSettingsScreen, etc. all construct through +// this one function). Unlike MapScreen, event_details doesn't have its own +// dedicated Tick hook, so this is how we get a pointer to it once it +// exists - needed to fire its own "EVENT" output (event_details -> +// garage/garage_select_car) the same way FireEventOutput fires +// MapOverworld's. Captures the pointer AFTER the real constructor runs +// (not before), so it's never observed partially-built. +#define LAYOUTSCREEN_CTOR_OFFSET 0x1A571C +// g_lastLayoutScreenInstance/g_lastLayoutScreenName declared near the top +// of this file (with the other cont.42 real-exit-chain state) since +// Hook_InternStringDiag, defined earlier, reads them too now. +typedef int (*LayoutScreenCtorFn)(int a1, int a2); +static LayoutScreenCtorFn orig_LayoutScreenCtor = nullptr; + +extern "C" int Hook_LayoutScreenCtor(int a1, int a2) { + int result = orig_LayoutScreenCtor(a1, a2); + g_lastLayoutScreenInstance = (void*)(uintptr_t)a1; + + // DIAG (cont.35): sub_1A571C's own decompile reads *(const char**)(a1+4) + // as the screen's identity/name (used for its "SplashScreen"/ + // "OptionsSettingsScreen" strcmp checks) - log it to confirm which + // screen we actually captured, since the second FireOutput below fired + // without crashing but produced no visible transition. + const char* screenName = *(const char**)((uint8_t*)(uintptr_t)a1 + 4); + g_lastLayoutScreenName = screenName; // cont.42: needed by Hook_FireOutputDiag + Log("DIAG LayoutScreenCtor: a1=%p screenName=\"%s\"", (void*)(uintptr_t)a1, + screenName ? screenName : "(null)"); + + // cont.58: investigated whether "which car was picked" could be read + // off car_select's own construction hand-off to RestrictedGarageLoadout + // (a2 here) or via a live watchpoint on the state singleton - neither + // panned out (a2 turned out to be flow/layout-resource metadata, not + // car data; the singleton's own page was too noisy with unrelated + // writes to isolate cleanly). cont.59/60 then ruled out both a per-car + // button widget (doesn't exist - car_select rebinds one persistent view, + // confirmed via the widget-factory registry) and the declarative + // property-binding system (every real reader of "/Cars/Current Car" is + // QA/debug tooling, not production UI). cont.61: SOLVED - see + // GetCurrentCarId's own comment and its call site in Hook_FireOutputDiag + // (car_select's own CONTINUE). + + // 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 && + screenName && strcmp(screenName, "EventDetails") == 0) { + g_autoSkipEventDetailPending = false; + g_autoSkipEventDetailTarget = (void*)(uintptr_t)a1; + clock_gettime(CLOCK_MONOTONIC, &g_autoSkipEventDetailSetAt); + g_autoSkipEventDetailTargetPending = true; + // cont.47: capture EventDetails' own FlowNode (*(screenOwner+8), + // same relationship confirmed live in cont.41) so Hook_FlowNodeTick + // can watch it - this is the node whose own "EVENT" processing + // resolves the REAL car_select target, since event_detail's own + // Outputs tree (unlike map's) actually has that edge. + g_watchedEventDetailsFlowNode = *(void**)((uint8_t*)(uintptr_t)a1 + 8); + } + + // cont.52: latch which event is now "current" - see the state block + // near the top of this file. Every event's flow (real or synthetic) + // passes through EventDetails constructing, so this is the one common + // point to consume the arm-flag our own triggers set moments earlier. + // A real player's map-pin tap never sets that flag, so this correctly + // defaults to "real" (false) unless we explicitly armed it. + if (screenName && strcmp(screenName, "EventDetails") == 0) { + g_currentEventIsSynthetic = g_nextEventDetailsIsSynthetic; + g_nextEventDetailsIsSynthetic = false; + // cont.55: real EventDetails construction happened - see the + // comment on g_realEventDetailsVisitHappened near the top of this + // file. This is the actual safety gate for TriggerTrueDirectCarSelectJump now. + g_realEventDetailsVisitHappened = true; + } + // Reset once back on the map - a natural session boundary - so this + // can never leak into a later, unrelated real event visit. + if (screenName && strcmp(screenName, "MapOverworld") == 0) { + g_currentEventIsSynthetic = false; + } + + // cont.55: warm up g_capturedEventDetailsTreeRef the moment MapOverworld + // constructs, via a direct GetOutputNode call - see the comment on + // g_eventDetailsTreeWarmedUp near the top of this file for why (lets a + // lobby "select car" button use TriggerTrueDirectCarSelectJump + // immediately, with zero event_details flash, even if the player has + // never visited a real event this session). One-shot; if it fails for + // any reason, g_capturedEventDetailsTreeRef simply stays null and the + // existing live-capture path (Hook_FlowNodeTick, on a real "EVENT" + // transition) remains as a fallback, unchanged. + if (!g_eventDetailsTreeWarmedUp && screenName && strcmp(screenName, "MapOverworld") == 0) { + g_eventDetailsTreeWarmedUp = true; + void* mapExecutor = *(void**)((uint8_t*)(uintptr_t)a1 + 8); + void* mapNodeData = mapExecutor ? *(void**)((uint8_t*)mapExecutor + 28) : nullptr; + if (mapNodeData) { + void* eventNameSlot = nullptr; + InternString(&eventNameSlot, "EVENT"); + void* result[2] = {nullptr, nullptr}; + GetOutputNode(result, mapNodeData, &eventNameSlot); + if (result[0]) { + g_capturedEventDetailsTreeRef = result[0]; + Log("WARM-UP: captured EventDetails tree ref=%p via direct GetOutputNode call " + "(no live visit needed)", result[0]); + } else { + Log("WARM-UP: GetOutputNode returned null for map's EVENT edge - " + "live-capture path remains as fallback"); + } + } else { + Log("WARM-UP: map node data (*(flowNode+28)) unavailable yet - " + "live-capture path remains as fallback"); + } + // cont.55 live investigation (disabled by default): arming + // InstallSingletonField12Watchpoint() here found offsets +112/+164 + // written almost instantly, then a VERY long insert-loop into +156 + // (5000+ iterations without finishing - likely a large per-item + // registry, not the small "TracksToUnlock"-style lists Serialize() + // enumerates) that never reached +12 before the pool budget ran + // out. The actual fix in TriggerTrueDirectCarSelectJump (calling + // SingletonInitialise() before jumping) was confirmed live to + // prevent the crash regardless - see PROGRESS.md cont.55. Left + // here, commented out, in case this investigation is picked up + // again later; DO NOT enable casually, it's slow (~1-2 real + // minutes to exhaust its pool) and was live-confirmed to deadlock + // (real hang, required force-stop) in an earlier mmap-per-catch + // version - the current pool-based version fixed that, but it's + // still a heavy diagnostic, not something to leave armed by default. + // InstallSingletonField12Watchpoint(); + } + + // cont.42: same "landed back on MapOverworld" persistence trigger as + // cont.39/40's g_upgradesPersisted block below, but for the real, + // player-driven exit chain instead of the timer-driven test harness. + // Gated on g_realExitChainTriggered (set once, at interception time, + // never reset) rather than a hop-count - the live-tracking chain in + // Hook_InternStringDiag no longer assumes a fixed number of BACK hops, + // so "did we ever intercept a real confirm" is the only thing that + // should gate this, not "did we count exactly N hops". + if (g_realExitChainTriggered && !g_realUpgradesPersisted && + screenName && strcmp(screenName, "MapOverworld") == 0) { + g_realUpgradesPersisted = true; + PersistAcceptedUpgrades(); + } + + // Deferred chain target capture (cont.35/37): MapScreen::Tick stops + // firing once event_details is on top of the screen stack (live- + // confirmed - a tick-based fallback here never ran), so this + // constructor is the trigger for "the next screen just became ready". + // Firing FireOutput immediately from right here (synchronously, still + // inside the ctor callback) returned without crashing but produced no + // visible transition on-device - possibly too early/reentrant. Deferred + // instead: just records whichever screen is constructed first after + // the previous chain fire; the actual fire happens a bit later from + // Hook_InternStringDiag, which fires constantly on any screen's active + // update loop, unlike MapScreen::Tick. + if (g_firedEventTest && !g_chainTargetPending && g_chainIndex < kOutputChainLength) { + g_chainTarget = (void*)(uintptr_t)a1; + clock_gettime(CLOCK_MONOTONIC, &g_chainTargetSetAt); + g_chainTargetPending = true; + } + + // cont.39: the BACK x3 chain's last hop lands back on MapOverworld - + // exactly the "controlled exit to map" moment cont.37 asked for. Persist + // whatever's accumulated in g_modSlotSelections[] right here (once only + // - a player could still change any slot up until they actually leave + // the loadout screen, so this is the correct, final point to capture + // it, not any earlier hop). + if (g_firedEventTest && !g_upgradesPersisted && g_chainIndex >= kOutputChainLength && + screenName && strcmp(screenName, "MapOverworld") == 0) { + g_upgradesPersisted = true; + PersistAcceptedUpgrades(); + } + + return result; +} + +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; +} + +// ---- REAL FireOutput click-handler discovery (cont.41) ---- +// sub_1BB59C (FireOutput's real target, FIRE_OUTPUT_OFFSET above) is a thin +// 2-instruction thunk - `LDR R0,[R0,#8]; B sub_1581A0` - that can't be +// hooked with this file's usual 2-word-copy trampoline: its second +// instruction is a PC-relative branch, which resolves to the WRONG target +// once copied into a trampoline at a different address (every other hook +// in this file was deliberately picked to have a position-independent +// PUSH/ADD/MOV prologue - this thunk is the first exception). sub_1581A0 +// itself (what the thunk tail-calls into, confirmed via disasm: PUSH +// {R4-R6,R10,R11,LR}; ADD R11,SP,#0x10 - safe to relocate) is hooked +// instead - functionally identical for every caller, since the thunk +// always redirects here regardless of who calls it. That includes our own +// FireOutput pointer (which resolves to the thunk's address, not this +// one) AND every real in-game click handler (53+ call sites to the thunk +// found statically, all pre-existing UI code - our own mod is the only +// other caller of the primitive). +// +// Purpose: with kEnableAutoChainTest off, manually tapping through the +// real UI (map pin -> event card -> confirm-race checkmark -> confirm-car +// checkmark -> the loadout screen's own real checkmark) and watching which +// caller fires which output name here reveals the actual click-handler +// functions - the goal being to hook those directly for a real +// confirm-button trigger, instead of this project's timer-driven +// kOutputChain test harness. +#define FIRE_OUTPUT_REAL_OFFSET 0x1581A0 +static volatile int g_fireOutputLogBudget = 0; +// Real signature returns int (confirmed via IDA) - matters here specifically +// (unlike this file's other void-typed hooked functions) because at least +// one real caller (sub_16C660, cont.42) reads the return value and keeps +// using it afterward; a void-typed hook wouldn't reliably preserve R0 +// across the call, since the compiler has no reason to keep a value alive +// for a declared-void function. +typedef int (*FireOutputRealFn)(void* flowNode, void* nameSlotPtr, void* ctxPtr); +static FireOutputRealFn orig_FireOutputReal = nullptr; + +extern "C" int Hook_FireOutputDiag(void* flowNode, void* nameSlotPtr, void* ctxPtr) { + const char* outputName = nameSlotPtr ? *(const char**)nameSlotPtr : nullptr; + + if (g_fireOutputLogBudget > 0) { + g_fireOutputLogBudget--; + Log("DIAG FireOutput: flowNode=%p output=\"%s\" caller=%p", + flowNode, outputName ? outputName : "(null)", __builtin_return_address(0)); + } + + // cont.58: the in-place singleton watchpoint that was armed right here + // (car_select's own CONTINUE) found only unrelated neighbor-object + // noise on the shared page, twice - see InstallInPlaceSingletonWatchpoint's + // own comment and PROGRESS.md cont.58 for the full "which car was + // picked" investigation and why it was dropped. Call site removed; + // the watchpoint infrastructure itself stays in the file, unused, for + // a future attempt. + + // cont.42: intercept the REAL player-driven "CONTINUE" from the loadout + // screen (car_select_loadout, screenName "RestrictedGarageLoadout") - + // the exact caller sub_16C660 found live in cont.41 - and redirect it + // into the same proven controlled BACK x3 exit instead of letting it + // proceed into real race loading (per the user's cont.37 direction: + // confirming upgrades should return to the map, not start a race). + // Guarded on g_lastLayoutScreenName specifically (not just "any + // CONTINUE") since car_select's own confirm checkmark ALSO fires + // "CONTINUE" (cont.41) - g_lastLayoutScreenName always reflects + // whichever screen is CURRENTLY on top, so by the time the player can + // tap the loadout screen's own checkmark, it correctly reads + // "RestrictedGarageLoadout" and not the car_select screen's name. + // The flowNode == *(screenOwner+8) check is a second, redundant + // confirmation that this call really originated from the tracked + // screen instance, not a same-named-but-different one. + // cont.52: gated on g_currentEventIsSynthetic - this interception exists + // ONLY to stop OUR OWN synthetic LAN lobby event from crashing (no real + // track scene to load, per cont.36). A real player's own event must be + // allowed to proceed into actual race loading untouched - see the + // user's explicit standing instruction: never intercept indiscriminately, + // always account for both synthetic and real/normal-game events. + bool isRealLoadoutContinue = outputName && strcmp(outputName, "CONTINUE") == 0 && + g_lastLayoutScreenName && strcmp(g_lastLayoutScreenName, "RestrictedGarageLoadout") == 0 && + g_lastLayoutScreenInstance && + flowNode == *(void**)((uint8_t*)g_lastLayoutScreenInstance + 8) && + g_currentEventIsSynthetic; + + // cont.43: hooking sub_16C660 directly (to neutralize its per-frame + // auto-continue at the source) was live-tested and found to break + // touch responsiveness for reasons not understood - reproduced 5/5 + // tries with that hook installed, 1/1 without (see main.cpp's comment + // on the now-uninstalled InstallConfirmCarSelectionHook). Abandoned in + // favor of widening THIS already-proven-safe interception instead: + // once the redirect has started (g_realExitChainActive), ALSO catch + // any REPEATED "CONTINUE" fired from car_select's own per-frame + // auto-continue (root-caused in cont.42/43 - `sub_188024`, the + // car-select-family screen's Tick, calls the loadout-confirm handler + // unconditionally every frame, and it re-fires the real "CONTINUE" on + // its own the moment it judges the session "fully configured", + // independent of navigation direction) - substituting BACK only once + // isn't enough, since the same auto-continue just re-fires on the very + // next frame from wherever the previous substitution landed. + // cont.52: same gate as isRealLoadoutContinue above - only continue the + // cascade for our own synthetic event. + bool isRepeatedAutoCascadeContinue = outputName && strcmp(outputName, "CONTINUE") == 0 && + g_realExitChainActive && + g_realExitChainAttempts < REAL_EXIT_CHAIN_MAX_ATTEMPTS && + IsKnownBackableCarFlowScreen(g_lastLayoutScreenName) && + g_lastLayoutScreenInstance && + flowNode == *(void**)((uint8_t*)g_lastLayoutScreenInstance + 8) && + g_currentEventIsSynthetic; + + // cont.61/63b: car_select's OWN confirm checkmark (screenName== + // "RestrictedGarage", distinct from the loadout screen's + // "RestrictedGarageLoadout" handled below) also fires "CONTINUE" - + // purely observed here, never intercepted/substituted. This is the + // real "player confirmed a car" moment: read carId and the live- + // resolved color (name + RGBA, see GetCurrentCarColor's own comment) + // synchronously, and dispatch both to Kotlin via the established JNI + // bridge, mirroring FireUpgradesAccepted's pattern. + if (outputName && strcmp(outputName, "CONTINUE") == 0 && + g_lastLayoutScreenName && strcmp(g_lastLayoutScreenName, "RestrictedGarage") == 0) { + const char* carId = GetCurrentCarId(); + CarColor color = GetCurrentCarColor(); + Log("REAL CONFIRM: car_select CONTINUE - carId=\"%s\" color=\"%s\" RGBA=%d,%d,%d,%d", + carId ? carId : "(null)", color.name, color.r, color.g, color.b, color.a); + FireCarSelected(carId, color.name, color.r, color.g, color.b, color.a); + } + + if ((isRealLoadoutContinue && !g_realExitChainActive) || isRepeatedAutoCascadeContinue) { + bool firstTime = !g_realExitChainActive; + if (firstTime) { + Log("REAL CONFIRM: intercepted CONTINUE from RestrictedGarageLoadout (flowNode=%p) - " + "substituting BACK instead of race loading", flowNode); + g_realExitChainActive = true; + g_realExitChainTriggered = true; + g_realExitChainAttempts = 1; + } else { + g_realExitChainAttempts++; + Log("REAL EXIT CHAIN: caught a repeated auto-continue CONTINUE from screenName=\"%s\" " + "(flowNode=%p, attempt %d/%d) - substituting BACK again", + g_lastLayoutScreenName, flowNode, g_realExitChainAttempts, REAL_EXIT_CHAIN_MAX_ATTEMPTS); + } + // This BACK fires synchronously below (substituted for the real + // CONTINUE, giving the caller a well-defined return value from a + // REAL transition instead of undefined behavior from a suppressed + // call - some callers, like sub_16C660, keep running afterward + // using that return value). Resets the deferred timer too, so the + // Hook_InternStringDiag fallback (for when NO further auto-cascade + // happens on its own) doesn't double-fire against a screen this + // synchronous substitution already moved past. + void* backNameSlot = nullptr; + InternString(&backNameSlot, "BACK"); + int ret = orig_FireOutputReal(flowNode, &backNameSlot, ctxPtr); + + clock_gettime(CLOCK_MONOTONIC, &g_realExitChainTargetSetAt); + g_realExitChainTargetPending = true; + return ret; + } + + return orig_FireOutputReal(flowNode, nameSlotPtr, ctxPtr); +} + +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); + 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; +} + +// ---- FlowNode transition-processor observation (cont.47) ---- +// sub_1573EC is im::app::flow::FlowNode's own per-frame transition +// processor (name inferred, not RTTI-confirmed - but its behavior matches +// exactly: reads the pending output name from +56 - written by +// sub_1581A0/FireOutput - resolves it via GetOutputNode/sub_159700 +// (RTTI-confirmed via its own "outputIt != m_Outputs.end()" assertion +// string and "GetOutputNode" function-name string), swaps +28 (the node's +// own "current" reference) to the resolved target, performs the real +// transition via sub_1C4318/sub_1C4420, then clears the pending state +// (+52/+56/+60/+64). This IS the actual consumer cont.47's earlier +// vtable/RTTI archaeology and empirical FlowNode-field-watch experiment +// both failed to find directly - found instead by decompiling +// GetOutputNode's own (single) caller. +// +// Purely observational for now - deliberately NOT substituting anything +// yet, given how tightly this function's refcounting is coupled to the +// specific resolved values (a real risk of corrupting shared_ptr-style +// refcounts if a captured target were blindly reused later, after its +// underlying node may have already been reassigned/destroyed). Watches +// ONE specific, deliberately-armed FlowNode (EventDetails' own, captured +// via Hook_LayoutScreenCtor once it constructs) to see what a REAL, +// correctly-resolved car_select target reference actually looks like +// (shape/values of the node data at +28 after processing "EVENT") before +// attempting anything riskier. The match check itself (`a1 == +// g_watchedEventDetailsFlowNode`) is a single cheap pointer compare for +// the (overwhelmingly common) non-matching case, to minimize risk of +// repeating cont.43's still-unexplained high-frequency-hook regression. +#define FLOWNODE_TICK_OFFSET 0x1573EC +typedef char* (*FlowNodeTickFn)(int a1); +static FlowNodeTickFn orig_FlowNodeTick = nullptr; + +extern "C" char* Hook_FlowNodeTick(int a1) { + bool isWatched = g_watchedEventDetailsFlowNode && a1 == (int)(intptr_t)g_watchedEventDetailsFlowNode; + void* beforeTarget = nullptr; + const char* pendingName = nullptr; + if (isWatched && g_flowNodeTickLogBudget > 0) { + pendingName = *(const char**)(a1 + 56); + beforeTarget = *(void**)(a1 + 28); + // cont.48: capture event_detail's own real tree reference the + // FIRST time we see it about to process "EVENT" - this is exactly + // event_detail's genuine Outputs tree (the one WITH the real + // "EVENT"->car_select edge), captured for reuse by + // TriggerTrueDirectCarSelectJump. + if (!g_capturedEventDetailsTreeRef && pendingName && strcmp(pendingName, "EVENT") == 0) { + g_capturedEventDetailsTreeRef = beforeTarget; + Log("DIAG FlowNodeTick: captured EventDetails' own tree ref=%p for reuse", beforeTarget); + } + } + char* result = orig_FlowNodeTick(a1); + if (isWatched && g_flowNodeTickLogBudget > 0 && pendingName) { + g_flowNodeTickLogBudget--; + void* afterTarget = *(void**)(a1 + 28); + Log("DIAG FlowNodeTick: a1=%p pendingName=\"%s\" +28 before=%p after=%p (changed=%d)", + (void*)(uintptr_t)a1, pendingName, beforeTarget, afterTarget, beforeTarget != afterTarget); + } + return result; +} + +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; +} + +// ---- 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; +} + +// 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 +// car_select flow (default view, carousel nav, class-filter dropdown, class +// selection) - ZERO instances were ever constructed. Root-caused via IDA: +// sub_35E134 is only reachable through a factory (sub_394F78) that's +// registered by NAME into a generic widget-factory table (sub_38DA44 calls +// sub_397BD8 with a runtime-built string). Decoding that string (built +// byte-by-byte at 0x38df48-0x38df84) gives "SelectCar" - and +// NFSMW12MobileTools/layouts.sb.json's string pool has exactly ONE +// "ButtonType"/"SelectCar" pair in the whole game, sitting right next to +// "SetUnlockedCar"/"Yes", "CAR UNLOCKED!!!", "car_unlocked_info", "GARAGE" - +// i.e. CarSelectButton is the single GARAGE button on the post-race +// "car unlocked" popup, not any part of the normal car_select browsing +// screen. Confirms the constructor's own "SetUnlockedCar"=="Yes" fallback +// path decompiled in cont.58. Dead end for "which car did the player pick" +// - reverted rather than left installed with zero real hits. See +// PROGRESS.md cont.59. + // ---- Soak Test disable hook (see ANALYSIS.md §6ff/§6gg) ---- // // MapScreen's per-frame tick (sub_17C120) contains a QA-only "Soak Test" @@ -387,6 +2668,60 @@ static MapScreenTickFn orig_MapScreenTick = nullptr; extern "C" void Hook_MapScreenTick(int a1, int* deltaMs) { *(float*)((uintptr_t)libapp_base + SOAK_TEST_ACCUMULATOR_OFFSET) = 0.0f; + + // Capture the live MapScreen* once (harmless, no dereference beyond the + // pointer itself) - needed below and by other code that reads it. + g_mapScreenInstance = (void*)(uintptr_t)a1; + + // cont.47 DIAG: empirical FlowNode field watch - see the state block + // near the top of this file for what these offsets are and why. + if (g_flowNodeWatchFramesLeft > 0 && g_flowNodeWatchPtr) { + g_flowNodeWatchFramesLeft--; + uint8_t* fn = (uint8_t*)g_flowNodeWatchPtr; + Log("DIAG FlowNode watch[%d]: +44=%d +52=%d +56=0x%x +60=0x%x +64=%p +256=%d +260=%f", + g_flowNodeWatchFramesLeft, + *(int*)(fn + 44), *(uint8_t*)(fn + 52), *(uint32_t*)(fn + 56), *(uint32_t*)(fn + 60), + *(void**)(fn + 64), *(uint8_t*)(fn + 256), *(float*)(fn + 260)); + } + + // ---- LIVE TEST: fire the "EVENT" Flow output for our own synthetic LAN + // event (key 0xC0FFEE00, the first id InjectSyntheticEvent ever hands + // out - see kTargetGroupName/nextId above), the programmatic equivalent + // of tapping its map pin. See PROGRESS.md cont.20-28 for the full + // derivation chain (FlowNode::FireOutput/sub_1581A0, sub_17A99C). + // One-shot, fired a couple seconds in (tickCount > 120) - well after + // InjectSyntheticEvent has already run inside MapTrack::HandleEvent + // during the initial map-load burst, and after the scroll/content + // calibration work already confirmed this hook fires safely every + // frame. Remove/gate behind a real trigger once this is confirmed + // working end-to-end. + static int tickCount = 0; + ++tickCount; + if (kEnableAutoChainTest && !g_firedEventTest && tickCount > 120) { + g_firedEventTest = true; + g_internStringLogBudget = 8000; // see InstallInternStringDiagHook comment - cont.32; raised (cont.38) for manual mod-tap headroom + g_fatalLogCallerBudget = 8000; // see InstallFatalLogCallerTraceHook comment - cont.32; raised (cont.38) for manual mod-tap headroom + g_emptyResolveLogBudget = 3000; // see Hook_ResolveDisplayText comment - cont.33/34 + g_wrapperNullFieldLogBudget = 3000; // see Hook_ResolveDisplayTextWrapperDiag comment - cont.33/34 + Log("LIVE TEST: about to FireEventOutput(MapScreen=%p, key=0xC0FFEE00)", (void*)(uintptr_t)a1); + g_nextEventDetailsIsSynthetic = true; // cont.52 + FireEventOutput((void*)(uintptr_t)a1, 0xC0FFEE00u); + Log("LIVE TEST: FireEventOutput returned without crashing"); + } + + // cont.45: live demo, requested by the user - open car_select ~2s + // after the map first loads, then stop (no scripted continuation, + // unlike the kEnableAutoChainTest block above - see the flag's own + // comment near the top of this file). + if (kAutoOpenCarSelectAtBoot && !g_firedAutoOpenCarSelect && tickCount > 120) { + g_firedAutoOpenCarSelect = true; + TriggerOpenCarSelectOnDemand(); + } + // The rest of the chain (kOutputChain) fires from Hook_LayoutScreenCtor + // and Hook_InternStringDiag instead of here - live-confirmed (cont.35) + // that MapScreen::Tick stops firing once event_details is on top of + // the screen stack, so a tick-count-based fallback here would never run. + orig_MapScreenTick(a1, deltaMs); } @@ -426,3 +2761,225 @@ static bool InstallSoakTestDisableHook() { Log("Installed Soak Test disable hook at %p, trampoline=%p", (void*)target, tramp); return true; } + +// ---- On-demand car-select opening (cont.44) ---- +// The user's design question: does the confirm-checkmark interception work +// implemented in cont.42/43 help open car_select at an ARBITRARY moment - +// e.g. the instant a player taps "select car" in a future lobby overlay, +// not just at map-load time? Answer: yes, by reusing the exact same +// primitive already proven safe this session - FireEventOutput on the live +// MapScreen, firing our synthetic event's own key (0xC0FFEE00, the first +// id InjectSyntheticEvent hands out - see kOutputChain's own first hop in +// Hook_MapScreenTick) - just called on demand from anywhere, instead of +// automatically, once, at boot behind the kEnableAutoChainTest/tickCount +// gate. This is the exact same call FireEventOutput/sub_17A99C makes when +// a real player taps a map pin (cont.35/41) - no new native surface, no +// new risk beyond what's already been live-tested. +// +// Exposed to Kotlin via Java_..._MultiplayerCore_triggerCarSelectTest +// (main.cpp) and, for now (no real lobby-overlay button exists yet), +// triggered by a debug broadcast receiver - see +// GameActivityMain.kt/MultiplayerCore.kt - so this can be fired from `adb +// shell am broadcast` at any point after the map has loaded, to verify the +// "on demand, not just at boot" claim live. +// +// Known limitation, NOT addressed here (see PROGRESS.md cont.44): the +// one-shot guards downstream (g_upgradesPersisted/g_realUpgradesPersisted) +// mean a SECOND on-demand open in the same app session won't re-dispatch +// PersistAcceptedUpgrades - this whole flow is still fundamentally a +// one-shot test cycle, not yet a repeatable per-session flow. Out of scope +// for this specific test (which only asks "can it be opened on demand at +// all", not "can it be reopened repeatedly"). +extern "C" void TriggerOpenCarSelectOnDemand() { + if (!g_mapScreenInstance) { + Log("TriggerOpenCarSelectOnDemand: g_mapScreenInstance is null (map not loaded yet) - ignoring"); + return; + } + if (!FireEventOutput) { + Log("TriggerOpenCarSelectOnDemand: FireEventOutput not resolved yet - ignoring"); + return; + } + // cont.49 crash-hardening: same null guard TriggerTrueDirectCarSelectJump + // needed after a live crash (cont.48) - g_mapScreenInstance itself being + // non-null doesn't guarantee *(g_mapScreenInstance+8) (the shared + // FlowNode) still is; it reads back NULL once MapScreen is no longer the + // genuinely active screen (mid-transition-out or later). FireEventOutput + // would otherwise dereference that NULL flowNode internally (the same + // fault-addr-0x1c class of crash cont.48 hit directly), just one call + // frame deeper where it'd be harder to diagnose. + void* flowNode = *(void**)((uint8_t*)g_mapScreenInstance + 8); + if (!flowNode) { + Log("TriggerOpenCarSelectOnDemand: flowNode is null (g_mapScreenInstance likely stale/destroyed) - ignoring"); + return; + } + Log("TriggerOpenCarSelectOnDemand: firing FireEventOutput(MapScreen=%p, key=0xC0FFEE00) on demand", + g_mapScreenInstance); + // cont.45: arm the EventDetails auto-skip BEFORE firing, so + // Hook_LayoutScreenCtor is ready to catch it the instant it constructs + // (this call queues the transition for next frame - see cont.26-28 - + // so there's no race with arming it right here, synchronously, first). + g_autoSkipEventDetailPending = true; + // cont.52: also arm the synthetic-event flag - this call is firing our + // own synthetic LAN event (key 0xC0FFEE00), so the loadout-confirm + // interception should apply once we get there. + g_nextEventDetailsIsSynthetic = true; + // cont.47 DIAG: arm the empirical FlowNode watch BEFORE firing too, on + // MapScreen's own flow node (*(screenOwner+8), same relationship + // confirmed live in cont.41) - since MapScreen::Tick stops firing once + // event_details is on top (cont.35), this will only capture whatever + // happens in the first few frames, but that should be enough to see + // whether the pending flag (+52) gets set/cleared before that happens. + g_flowNodeWatchPtr = flowNode; + g_flowNodeWatchFramesLeft = 120; + // cont.47: log MapScreen's own flowNode pointer explicitly, to compare + // against Hook_FlowNodeTick's later "a1=..." for EventDetails - is + // *(screenOwner+8) a single shared global FlowNode-executor object + // across every screen (only +28 varies per-screen), or a distinct + // object per screen? Answers a lot about how cheap/safe a true direct + // jump could be. + Log("DIAG MapScreen flowNode=%p", g_flowNodeWatchPtr); + // cont.57: the singleton+12 watchpoint that was armed right here found + // its target on the very first catch (arming late, right before this + // real FireEventOutput call, avoided cont.55/56's problem of drowning + // in an unrelated early-boot insert-loop) - see TriggerTrueDirectCarSelectJump's + // own comment for the full writeup and the actual fix. Investigation + // complete; call disabled (was InstallSingletonField12Watchpoint();). + FireEventOutput(g_mapScreenInstance, 0xC0FFEE00u); + Log("TriggerOpenCarSelectOnDemand: FireEventOutput returned without crashing"); +} + +// cont.48: TRUE direct jump - fires "EVENT" on MAP's own screen owner, but +// with the shared FlowNode-executor's +28 TEMPORARILY pointed at +// event_detail's own captured real tree (g_capturedEventDetailsTreeRef) +// instead of map's own tree, right before firing. Since event_detail's +// tree genuinely has an "EVENT"->car_select edge (confirmed via +// event_detail.sb.json, cont.26/41) - unlike map's, which only has +// "EVENT"->event_detail - FireOutput's own output-exists check +// (sub_159684) succeeds naturally against this substituted tree, and the +// completely unmodified GetOutputNode/transition machinery (sub_1573EC) +// resolves and constructs car_select using entirely genuine, correctly- +// typed data the whole way through - map never actually constructs +// EventDetails at all. Only the SOURCE tree consulted is substituted +// (with another already-real, already-valid tree, not a fabricated one) - +// nothing about the actual target-resolution or refcounting logic is +// touched. +// +// Requires g_capturedEventDetailsTreeRef to already be populated - see +// Hook_FlowNodeTick's own capture logic, which needs at least one real +// event_detail->car_select transition to have already happened THIS +// session (e.g. via TriggerOpenCarSelectOnDemand's own auto-skip) before +// this can work. +extern "C" void TriggerTrueDirectCarSelectJump() { + if (!g_mapScreenInstance) { + Log("TriggerTrueDirectCarSelectJump: g_mapScreenInstance is null - ignoring"); + return; + } + if (!g_capturedEventDetailsTreeRef) { + Log("TriggerTrueDirectCarSelectJump: no captured EventDetails tree ref yet " + "(need at least one real event_detail->car_select transition first this session) - ignoring"); + return; + } + // cont.55: the tree-ref warm-up alone is NOT sufficient - live-tested + // and confirmed this still crashes on a genuinely cold session (no real + // EventDetails construction yet), on a car-select-family state + // singleton's own +12 field that something else, still unidentified, + // populates as a side effect of a real EventDetails construction. See + // g_realEventDetailsVisitHappened's own comment near the top of this + // file for the full story. + // cont.57: found and fixed the singleton+12 crash for real (see the + // FireEventOutput comment further down) - confirmed via repeated live + // testing that *(singleton+12) is now correctly non-null and the app + // never crashes, even fired as the very first cold call after boot. + // BUT: a second, separate issue surfaced during that same testing - + // the jump still doesn't visibly navigate (no crash, no screen change) + // when it's the FIRST call on a genuinely cold session, while the + // ALREADY-PROVEN TriggerOpenCarSelectOnDemand (same FireEventOutput + // call, no +28 substitution) reliably works as a cold first call on + // the exact same boot. So the substituted tree ref itself + // (g_capturedEventDetailsTreeRef, populated by the cont.55 warm-up's + // GetOutputNode call) is suspected to be a valid pointer but an + // incompletely-initialized node - structurally sufficient for the + // Outputs-tree lookup GetOutputNode itself needed, but possibly + // missing further side-state a REAL event_detail construction would + // also establish (the same class of gap as the singleton, just a + // second instance of it, not yet root-caused this deeply). Keeping + // this gate for that reason - it's now belt-and-suspenders for two + // different find-the-hidden-init-path problems, not just one. + if (!g_realEventDetailsVisitHappened) { + Log("TriggerTrueDirectCarSelectJump: no real EventDetails construction yet this " + "session (tree warm-up alone isn't safe - see cont.55/57) - ignoring"); + return; + } + if (!FireOutput || !InternString) { + Log("TriggerTrueDirectCarSelectJump: FireOutput/InternString not resolved yet - ignoring"); + return; + } + // cont.55: SingletonInitialise() experiment removed - live-tested and + // found the car-select state singleton (dword_AD2A08) is ALREADY + // non-null well before this point in every tested scenario, including + // a cold session at a 5s post-boot delay (matching the original crash's + // own timing) - so calling Initialise() here was always a no-op and + // never the actual fix. The crash's real cause (something on/reachable + // from that already-valid singleton, likely its own +12 field - see + // PROGRESS.md cont.55 for the full trace) is still open. See + // SINGLETON_INITIALISE_OFFSET's own comment near the top of this file. + void* singletonPtr = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET); + int32_t singletonField12 = singletonPtr ? *(int32_t*)((uint8_t*)singletonPtr + 12) : -1; + Log("TriggerTrueDirectCarSelectJump: singleton=%p *(singleton+12)=%d(0x%x)", + singletonPtr, singletonField12, singletonField12); + void* flowNode = *(void**)((uint8_t*)g_mapScreenInstance + 8); + // cont.48 crash fix: g_mapScreenInstance goes stale once enough time/ + // navigation passes since MapScreen was last actually ticking (it's + // only ever written by Hook_MapScreenTick, never invalidated on + // destruction) - live-tested crash (SIGSEGV fault 0x1c = offset 28 + // into a NULL flowNode) confirmed flowNode can genuinely read back + // NULL once the underlying MapScreen object is gone. Guard against it + // instead of blindly dereferencing +28. + if (!flowNode) { + Log("TriggerTrueDirectCarSelectJump: flowNode is null (g_mapScreenInstance likely stale/destroyed) - ignoring"); + return; + } + void* savedTree = *(void**)((uint8_t*)flowNode + 28); + Log("TriggerTrueDirectCarSelectJump: flowNode=%p, temporarily swapping +28 from %p (map's own) " + "to %p (captured EventDetails tree)", flowNode, savedTree, g_capturedEventDetailsTreeRef); + + // cont.52: this function exists specifically to open OUR OWN synthetic + // lobby's car_select on demand (its whole design intent, cont.48) - it + // bypasses EventDetails' construction entirely, so there's no screen + // construction to latch onto (unlike TriggerOpenCarSelectOnDemand); + // latch directly instead. + g_currentEventIsSynthetic = true; + + *(void**)((uint8_t*)flowNode + 28) = g_capturedEventDetailsTreeRef; + + // cont.57: found singleton+12's real writer via a live watchpoint, + // armed right before a real FireEventOutput call instead of at boot + // (cont.55/56's boot-time arming drowned in an unrelated early-boot + // insert-loop before ever reaching +12) - the very first catch landed + // exactly on it. Traced the writing instruction (IDA) to sub_240294, + // called from INSIDE FireEventOutput (sub_17A99C) itself: FireEventOutput + // resolves the raw event key via ResolveHandle (our own already-known + // primitive), then copies that resolved event data into the singleton's + // +12/+16/+20 fields via sub_240294 - a "set current event context" side + // effect - BEFORE it gets around to calling FireOutput("EVENT", ...) + // internally. Calling FireOutput directly, as this function did before, + // always skipped that whole resolve+populate step. Fix: call + // FireEventOutput itself (already a known, resolved function pointer - + // the exact primitive TriggerOpenCarSelectOnDemand already uses) with + // our own synthetic event's key - the +28 substitution above still + // governs which tree FireEventOutput's own internal FireOutput call + // resolves against (car_select's, not map's own), so this keeps the + // true one-hop jump while also getting the missing singleton side + // effect for free, using our own event's data (correct - the "current + // race context" should reflect our own synthetic event, not someone + // else's stale one). + Log("TriggerTrueDirectCarSelectJump: about to FireEventOutput(EVENT, key=0xC0FFEE00) with substituted tree"); + FireEventOutput(g_mapScreenInstance, 0xC0FFEE00u); + Log("TriggerTrueDirectCarSelectJump: FireEventOutput returned without crashing"); + // Deliberately NOT restoring +28 to savedTree here - the whole point is + // for sub_1573EC's own next tick to consume the pending "EVENT" + // against the substituted tree and swap +28 to the REAL resolved + // car_select target itself, exactly as it would for a genuine + // event_detail-sourced transition. Restoring it now would undo the + // substitution before that tick runs. +} diff --git a/mpcore/src/main/cpp/main.cpp b/mpcore/src/main/cpp/main.cpp index 4ede01c..4ba8859 100644 --- a/mpcore/src/main/cpp/main.cpp +++ b/mpcore/src/main/cpp/main.cpp @@ -16,6 +16,7 @@ #include #include "util/armhook.h" #include "util/armhooks.h" +#include "game_events.h" #include "lan_event_injection.h" void* libapp_base = NULL; @@ -173,11 +174,70 @@ static bool InstallBuildTrackScenePathHook() { return true; } +// ---- MapScreen constructor trace hook (temporary, RE discovery only) ---- +// Purpose: capture the live `im::app::flow::nfs::MapScreen` instance pointer +// so we can read its "scroll" layout-entity (found via sub_1332B8's +// FindOrCreateLayoutEntity call with the literal name "scroll" - see +// PROGRESS.md) - a Transform-shaped object whose position (offset +36/+40) +// and scale (offset +44/+48) are hypothesized to be the map's current +// pan/zoom state, needed to convert a MapTrack's world-space bounds rect +// (found earlier, offsets +0x44.."+0x50") into real screen pixels. +#define MAPSCREEN_CTOR_OFFSET 0x1781BC + +typedef void* (*MapScreenCtorFn)(void* a1); +static MapScreenCtorFn orig_MapScreenCtor = nullptr; +void* g_mapScreenInstance = nullptr; + +void* Hook_MapScreenCtor(void* a1) { + void* result = orig_MapScreenCtor(a1); + g_mapScreenInstance = a1; + Log("MapScreen constructed: %p", a1); + return result; +} + +static bool InstallMapScreenCtorTraceHook() { + uintptr_t target = (uintptr_t)libapp_base + MAPSCREEN_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("MapScreen ctor 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_MapScreenCtor = (MapScreenCtorFn)tramp; + + uintptr_t page = target & ~((uintptr_t)getpagesize() - 1); + if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { + Log("MapScreen ctor hook: mprotect target failed: %s", strerror(errno)); + return false; + } + + target32[0] = 0xE51FF004; // LDR PC, [PC, #-4] + target32[1] = (uint32_t)(uintptr_t)&Hook_MapScreenCtor; + + __builtin___clear_cache((char*)target, (char*)(target + 8)); + __builtin___clear_cache((char*)tramp, (char*)tramp + 16); + + Log("Installed MapScreen ctor trace hook at %p, trampoline=%p", (void*)target, tramp); + return true; +} + // 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; 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 // from eventually crashing the process on entries our injection adds to the // prefab cache. Independent of kEnableLanEventInjectionHook so it can be kept @@ -185,6 +245,12 @@ static constexpr bool kEnableLanEventInjectionHook = true; static constexpr bool kEnableSoakTestDisableHook = true; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { + JNIEnv* env = nullptr; + if (vm->GetEnv((void**)&env, JNI_VERSION_1_6) == JNI_OK) { + InitGameEvents(vm, env); + } else { + Log("JNI_OnLoad: GetEnv failed, GameEvents bridge not initialised"); + } if (get_libapp_base()) { if (kEnableTrackSubstitutionHook) { @@ -196,6 +262,32 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { if (kEnableSoakTestDisableHook) { InstallSoakTestDisableHook(); } + if (kEnableMapScreenCtorTraceHook) { + InstallMapScreenCtorTraceHook(); + } + InstallCopSoundsTickSkipHook(); + InstallGetComponentNameSkipHook(); + InstallStrlenNullGuardHook(); + InstallInternStringDiagHook(); + InstallFatalLogCallerTraceHook(); + InstallResolveDisplayTextHook(); + InstallResolveDisplayTextWrapperDiagHook(); + InstallLayoutScreenCtorHook(); + InstallModSlotSelectedHook(); + InstallFireOutputDiagHook(); + // NOT installed (cont.43): live-tested and found to break touch + // responsiveness on car_select once installed, for reasons not yet + // understood (sub_16C660 itself runs fine every frame through the + // hook per its own diagnostics - "returned 0" every ~16ms, no hang + // - yet taps stop registering; reproduced 5/5 tries with the hook + // 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; + // the FireOutput-level interception was widened instead (does not + // need this hook). + // InstallConfirmCarSelectionHook(); + InstallFlowNodeTickHook(); } return JNI_VERSION_1_6; @@ -205,4 +297,23 @@ extern "C" JNIEXPORT void JNICALL Java_nfs_mod_mpcore_MultiplayerCore_bumpBackTraceToLogcat(JNIEnv *env, jobject thiz) { //backtraceToLogcat(); +} + +// cont.44: lets Kotlin (eventually a real lobby-overlay button, for now a +// debug broadcast receiver - see GameActivityMain.kt) open car_select on +// demand instead of only automatically at boot. See +// TriggerOpenCarSelectOnDemand in lan_event_injection.h for the details. +extern "C" +JNIEXPORT void JNICALL +Java_nfs_mod_mpcore_MultiplayerCore_triggerCarSelectTest(JNIEnv *env, jobject thiz) { + TriggerOpenCarSelectOnDemand(); +} + +// cont.48: experimental TRUE direct jump to car_select, bypassing +// EventDetails entirely - see TriggerTrueDirectCarSelectJump in +// lan_event_injection.h for the details and the real risk involved. +extern "C" +JNIEXPORT void JNICALL +Java_nfs_mod_mpcore_MultiplayerCore_triggerTrueDirectCarSelectJump(JNIEnv *env, jobject thiz) { + TriggerTrueDirectCarSelectJump(); } \ No newline at end of file diff --git a/mpcore/src/main/java/nfs/mod/mpcore/CarSelectLoadoutTestTrigger.kt b/mpcore/src/main/java/nfs/mod/mpcore/CarSelectLoadoutTestTrigger.kt new file mode 100644 index 0000000..f9a2457 --- /dev/null +++ b/mpcore/src/main/java/nfs/mod/mpcore/CarSelectLoadoutTestTrigger.kt @@ -0,0 +1,67 @@ +package nfs.mod.mpcore + +import android.os.Handler +import android.os.Looper +import android.util.Log + +private const val TAG = "mpcore_kt" + +// Proof-of-concept only, deliberately NOT UX-friendly: a fixed delay after +// the map screen loads, then a hardcoded replay of the exact tap sequence +// already verified live (map pin -> event card -> confirm race -> confirm +// car) to land on the native car-select/upgrade-loadout Flow screen, without +// needing FlowManager's internal "goto screen by name" API (still unknown - +// see PROGRESS.md cont.20/21). +// +// Coordinates are raw device pixels captured from one specific play session +// on one specific device/resolution/save state - they WILL be wrong on a +// different map camera position, screen size, or unlocked-event set. This +// only exists to validate that synthetic-touch injection can drive the +// game's own Flow navigation at all; a real implementation needs to resolve +// these positions at runtime (e.g. from the target MapTrack widget's actual +// screen-space bounds) instead of hardcoding them. +private object CarSelectLoadoutTestSequence { + const val ENABLED = false // temporarily off - checking if the injected pin is even visible on the map first + + private const val DELAY_AFTER_MAP_LOAD_MS = 3000L + private const val DELAY_BETWEEN_TAPS_MS = 2000L + + // (x, y) in device pixels, 2220x1080 landscape - Galaxy A9 test session. + private val TAP_SEQUENCE = listOf( + 993f to 455f, // map pin (МАККЛЕЙН) + 250f to 555f, // event card (ПОБУДКА) + 1943f to 938f, // confirm race + 1943f to 938f, // confirm car -> lands on car_select_loadout + ) + + fun run() { + val dispatcher = GameInput.dispatcher + if (dispatcher == null) { + Log.w(TAG, "CarSelectLoadoutTestSequence: no GameInput.dispatcher registered yet, aborting") + return + } + Log.i(TAG, "CarSelectLoadoutTestSequence: armed, first tap in ${DELAY_AFTER_MAP_LOAD_MS}ms") + val handler = Handler(Looper.getMainLooper()) + TAP_SEQUENCE.forEachIndexed { index, (x, y) -> + handler.postDelayed({ + Log.i(TAG, "CarSelectLoadoutTestSequence: tap #$index at ($x, $y)") + dispatcher.dispatchTap(x, y) + }, DELAY_AFTER_MAP_LOAD_MS + index * DELAY_BETWEEN_TAPS_MS) + } + } +} + +/** Call once at startup (from [MultiplayerCore.loadCore]) to arm the test. */ +fun installCarSelectLoadoutTestTrigger() { + if (!CarSelectLoadoutTestSequence.ENABLED) return + Log.i(TAG, "installCarSelectLoadoutTestTrigger: registering listener") + GameEvents.register(object : GameEventListener { + private var fired = false + override fun onMapLoaded() { + Log.i(TAG, "CarSelectLoadoutTestTrigger.onMapLoaded fired (already fired before = $fired)") + if (fired) return + fired = true + CarSelectLoadoutTestSequence.run() + } + }) +} diff --git a/mpcore/src/main/java/nfs/mod/mpcore/GameEvents.kt b/mpcore/src/main/java/nfs/mod/mpcore/GameEvents.kt new file mode 100644 index 0000000..1f2da5a --- /dev/null +++ b/mpcore/src/main/java/nfs/mod/mpcore/GameEvents.kt @@ -0,0 +1,104 @@ +package nfs.mod.mpcore + +import android.util.Log + +private const val TAG = "mpcore_kt" + +/** + * Register on [GameEvents] to react to game lifecycle moments fired from the + * native side (see game_events.h). Every method has a default no-op body - + * override only what you need. + */ +interface GameEventListener { + fun onMapLoaded() {} + fun onRaceStarted() {} + fun onRaceEnded() {} + + /** + * Fired once, right as the player's controlled exit from the car-upgrade + * loadout screen lands back on the map (see Hook_LayoutScreenCtor in + * lan_event_injection.h - the BACK x3 chain out of + * garage_select_rollout). [slotIds]/[carMods] are parallel arrays, one + * entry per upgrade slot the player touched this session; a carMod of 0 + * means that slot was cleared back to "NONE". No lobby/network consumer + * exists yet - this is the hook point for whatever eventually needs to + * know which upgrades the player accepted. + */ + fun onUpgradesAccepted(slotIds: IntArray, carMods: IntArray) {} + + /** + * Fired once, right as car_select's own confirm checkmark fires + * "CONTINUE" (see Hook_FireOutputDiag in lan_event_injection.h). [carId] + * is the car's resource-id string read directly off the car-select + * state singleton (e.g. "ford_focus_rs500_2010_desc") - see PROGRESS.md + * cont.61 for how this was found and verified. [colorName]/[colorR]/ + * [colorG]/[colorB]/[colorA] are the currently-equipped paint job, + * resolved live from the game's own engine data (not a static extracted + * table, so it stays correct for any car added/modded later) - see + * PROGRESS.md cont.63b. + */ + fun onCarSelected(carId: String, colorName: String, colorR: Int, colorG: Int, colorB: Int, colorA: Int) {} +} + +/** + * Global dispatch point for game-lifecycle events. Native code (mpcore's + * game_events.h) calls the `dispatch*` methods below via JNI whenever it + * observes the corresponding moment through one of its hooks; this object + * fans that out to whatever Kotlin-side listeners are registered. + * + * Not every event has a real native trigger wired up yet - see PROGRESS.md + * for which ones actually fire today (onMapLoaded, via the existing + * MapTrack::HandleEvent hook) versus which are scaffolded for later + * (onRaceStarted/onRaceEnded). + */ +object GameEvents { + private val listeners = mutableListOf() + + @Synchronized + fun register(listener: GameEventListener) { + if (!listeners.contains(listener)) listeners.add(listener) + Log.i(TAG, "GameEvents.register: now ${listeners.size} listener(s)") + } + + @Synchronized + fun unregister(listener: GameEventListener) { + listeners.remove(listener) + } + + @Synchronized + private fun snapshot(): List = listeners.toList() + + // Called from native via CallStaticVoidMethod - keep names/signatures in + // sync with game_events.h's cached jmethodIDs. + @JvmStatic + fun dispatchMapLoaded() { + val targets = snapshot() + Log.i(TAG, "GameEvents.dispatchMapLoaded: ${targets.size} listener(s)") + targets.forEach { it.onMapLoaded() } + } + + @JvmStatic + fun dispatchRaceStarted() { + snapshot().forEach { it.onRaceStarted() } + } + + @JvmStatic + fun dispatchRaceEnded() { + snapshot().forEach { it.onRaceEnded() } + } + + @JvmStatic + fun dispatchUpgradesAccepted(slotIds: IntArray, carMods: IntArray) { + val targets = snapshot() + Log.i(TAG, "GameEvents.dispatchUpgradesAccepted: ${targets.size} listener(s), ${slotIds.size} slot(s)") + targets.forEach { it.onUpgradesAccepted(slotIds, carMods) } + } + + @JvmStatic + fun dispatchCarSelected(carId: String, colorName: String, colorR: Int, colorG: Int, colorB: Int, colorA: Int) { + val targets = snapshot() + Log.i(TAG, "GameEvents.dispatchCarSelected: ${targets.size} listener(s), carId=$carId, " + + "color=$colorName RGBA=($colorR,$colorG,$colorB,$colorA)") + targets.forEach { it.onCarSelected(carId, colorName, colorR, colorG, colorB, colorA) } + } +} diff --git a/mpcore/src/main/java/nfs/mod/mpcore/GameInput.kt b/mpcore/src/main/java/nfs/mod/mpcore/GameInput.kt new file mode 100644 index 0000000..b180864 --- /dev/null +++ b/mpcore/src/main/java/nfs/mod/mpcore/GameInput.kt @@ -0,0 +1,15 @@ +package nfs.mod.mpcore + +/** + * Implemented by the app module's Activity (GameActivityMain) so mpcore can + * synthesize touch input into the game's own view without depending on the + * `app` module by type - mpcore is a library `app` depends on, not the + * other way around, so a direct reference back would be circular. + */ +interface SyntheticInputDispatcher { + fun dispatchTap(x: Float, y: Float) +} + +object GameInput { + var dispatcher: SyntheticInputDispatcher? = null +} diff --git a/mpcore/src/main/java/nfs/mod/mpcore/MultiplayerCore.kt b/mpcore/src/main/java/nfs/mod/mpcore/MultiplayerCore.kt index b7e035a..328e177 100644 --- a/mpcore/src/main/java/nfs/mod/mpcore/MultiplayerCore.kt +++ b/mpcore/src/main/java/nfs/mod/mpcore/MultiplayerCore.kt @@ -4,6 +4,27 @@ object MultiplayerCore { external fun bumpBackTraceToLogcat() - fun loadCore() = System.loadLibrary("mpcore") + /** + * Fires the same real "open car_select" call the game's own map-pin + * tap uses (see TriggerOpenCarSelectOnDemand in + * lan_event_injection.h), but on demand instead of only at boot - + * eventually meant to be wired to a real lobby-overlay "select car" + * button. See cont.44 in PROGRESS.md. + */ + external fun triggerCarSelectTest() + + /** + * EXPERIMENTAL (cont.48): true one-hop jump straight to car_select, + * bypassing EventDetails entirely, by temporarily redirecting the + * shared FlowNode to event_detail's own already-captured real Outputs + * tree before firing "EVENT" from the map. See + * TriggerTrueDirectCarSelectJump in lan_event_injection.h. + */ + external fun triggerTrueDirectCarSelectJump() + + fun loadCore() { + System.loadLibrary("mpcore") + installCarSelectLoadoutTestTrigger() + } } \ No newline at end of file