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) {