Add LAN car_select flow: real event handling, GameEvents JNI bridge, live car/upgrade/color capture, Compose UI example

Fixes the synthetic car_select jump for cold sessions, makes the loadout
exit chain safe for real (non-synthetic) events, and adds a native->Kotlin
GameEvents bridge (onMapLoaded/onRaceStarted/onRaceEnded/onUpgradesAccepted/
onCarSelected) so both the UI layer and a future native RatNet client can
learn what the player picked - car id, accepted upgrades, and paint color
(name + RGBA) are all resolved live from the game's own engine state
rather than a static extracted table, so they stay correct for any car
added later. Includes a Jetpack Compose overlay as a worked example of a
UI-side GameEventListener consumer.

Full investigation history, root causes, and the several dead ends ruled
out along the way are documented in PROGRESS.md (cont. 30-63b).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:30:21 +03:00
co-authored by Claude Sonnet 5
parent 1c6324b589
commit cde392870e
10 changed files with 3280 additions and 5 deletions
+11
View File
@@ -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")
@@ -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<CarSelection?>(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)
}
@@ -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) {
+187
View File
@@ -0,0 +1,187 @@
#pragma once
#include <jni.h>
#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();
}
}
File diff suppressed because it is too large Load Diff
+111
View File
@@ -16,6 +16,7 @@
#include <iomanip>
#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();
}
@@ -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()
}
})
}
@@ -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<GameEventListener>()
@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<GameEventListener> = 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) }
}
}
@@ -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
}
@@ -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()
}
}