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