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:
@@ -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,6 +670,27 @@ 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)")
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -206,3 +298,22 @@ 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()
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user