Merge lan-event-injection-poc: opponent substitution, cop/traffic removal, debug menu (source only, not wired into the emulated build)

Brings opponent_substitution.h and cop_traffic_disable.h - the
native32/launcher branch's proven, live-confirmed multiplayer-mod hooks
(opponent car/color substitution, cop dispatcher removal with a live
before/after roadblock proof, full traffic elimination) - into this repo
as source, plus DebugFeatures.kt/DebugMenuOverlay.kt (debug-only money/menu
scaffold) and this session's GameActivityMain.kt thread-safety fix
(BroadcastReceiver.onReceive wrapping native calls in
gameGLSurfaceView.queueEvent{} instead of calling straight into JNI from
the main thread - a real cross-thread race, independently useful on both
branches).

Two real conflicts, both resolved to preserve architecture rather than
naively keep both sides:

- main.cpp: kept this branch's own structure. The new .h files, like
  lan_event_injection.h before them, are NOT #include'd or wired into
  JNI_OnLoad - every hook in both files resolves and calls raw
  "libapp_base + OFFSET" function pointers, which on a real dlopen'd
  libapp.so is a valid jump target but here is only Unicorn-backed guest
  memory - including them unmodified would crash immediately, not just
  misbehave. Left as source for the same GuestFn/CallGuestFunction porting
  session already planned for lan_event_injection.h.
- GameActivityMain.kt: trivial, a code comment disagreeing on this build's
  own package id (nfs13_arm here vs nfs13_mod on native32) - merged to
  document both.

Verified after resolving: compileTranslatedDebugKotlin,
compileNative32DebugKotlin, and externalNativeBuildDebug (arm64-v8a +
x86_64, confirmed via a fresh main.cpp.o/mpcore.so rebuild, not a cache
hit) all succeed - this merge compiles clean on both Kotlin flavors and
the native side, not just resolves textually.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-22 23:55:18 +03:00
co-authored by Claude
12 changed files with 786 additions and 9 deletions
@@ -0,0 +1,86 @@
package com.ea.ironmonkey
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import nfs.mod.mpcore.GameEventListener
import nfs.mod.mpcore.GameEvents
// See DEBUG_MENU.md for the full design. Local-only Compose state for now -
// Apply doesn't touch any game memory, no real balance getter/setter has
// been located yet (see DEBUG_MENU.md §3). Exists purely so the debug panel
// has something to edit while that RE work is pending.
object DebugMoneyState {
var amount by mutableStateOf(0L)
private set
fun apply(newAmount: Long) {
amount = newAmount
}
}
// Same GameEventListener-as-Compose-state adapter shape as CarSelectionState
// (CarSelectionOverlay.kt) - tracks whether the map has loaded at least once
// this session, which is when the debug button should appear.
object DebugMenuVisibility : GameEventListener {
var mapLoaded by mutableStateOf(false)
private set
override fun onMapLoaded() {
mapLoaded = true
}
}
// Call once, e.g. from GameActivityMain.onCreate, to start receiving
// onMapLoaded into DebugMenuVisibility.
fun registerDebugMenuListener() {
GameEvents.register(DebugMenuVisibility)
}
@Composable
fun DebugMenuButton(visible: Boolean, modifier: Modifier = Modifier) {
var dialogOpen by remember { mutableStateOf(false) }
AnimatedVisibility(visible = visible, modifier = modifier) {
FloatingActionButton(onClick = { dialogOpen = true }) {
Text("DBG")
}
}
if (dialogOpen) {
DebugMoneyDialog(onDismiss = { dialogOpen = false })
}
}
@Composable
private fun DebugMoneyDialog(onDismiss: () -> Unit) {
var text by remember { mutableStateOf(DebugMoneyState.amount.toString()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Debug: Money") },
text = {
OutlinedTextField(
value = text,
onValueChange = { input -> text = input.filter(Char::isDigit) },
label = { Text("Amount") },
singleLine = true,
)
},
confirmButton = {
TextButton(onClick = {
DebugMoneyState.apply(text.toLongOrNull() ?: DebugMoneyState.amount)
onDismiss()
}) { Text("Apply") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@@ -38,6 +38,7 @@ 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.DebugFeatures
import nfs.mod.mpcore.GameInput
import nfs.mod.mpcore.MultiplayerCore.loadCore
import nfs.mod.mpcore.MultiplayerCore.loadEmulatedLibapp
@@ -101,18 +102,30 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
// "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.
// cont.78 FIX: BroadcastReceiver.onReceive runs on the main thread, but
// every other call site in this file that touches native game state
// (nativeOnPhysicalKeyDown/Up, dispatchSyntheticTap) wraps it in
// gameGLSurfaceView.queueEvent{} to marshal onto the GL/render thread,
// where MapScreen/FlowNode/etc. actually live. These two receivers were
// the one place that called straight into JNI from the main thread -
// a real cross-thread race, reproduced live as a consistent
// "fault addr 0x8" SIGSEGV inside FireEventOutput on every single fire,
// independent of save state or any mpcore hook. Wrapping in queueEvent
// matches the pattern used everywhere else in this file.
private val carSelectTestReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
nfs.mod.mpcore.MultiplayerCore.triggerCarSelectTest()
gameGLSurfaceView.queueEvent { 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_arm`. See PROGRESS.md cont.48.
// -p com.ea.games.nfs13_arm` (nfs13_mod on the native32/launcher build -
// same trigger, different package id per build flavor). See PROGRESS.md
// cont.48, and cont.78 above for why this is wrapped in queueEvent.
private val trueDirectCarSelectTestReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
nfs.mod.mpcore.MultiplayerCore.triggerTrueDirectCarSelectJump()
gameGLSurfaceView.queueEvent { nfs.mod.mpcore.MultiplayerCore.triggerTrueDirectCarSelectJump() }
}
}
@@ -347,6 +360,12 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
mFrameLayout = FrameLayout(this)
mFrameLayout.addView(gameGLSurfaceView)
mFrameLayout.addView(buildCarSelectionOverlay())
// Explicit debug-tooling gate (see DEBUG_MENU.md) - flip
// DebugFeatures.debugMenuEnabled off and this view never gets built
// or added, not just hidden.
if (DebugFeatures.debugMenuEnabled) {
mFrameLayout.addView(buildDebugMenuOverlay())
}
setContentView(mFrameLayout)
// ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md):
// fmodex/fmodevent/Nimble/app are all armeabi-v7a-only builds (no
@@ -847,6 +866,27 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
}
}
// Debug-only menu (see DEBUG_MENU.md), same ComposeView-over-mFrameLayout
// pattern as buildCarSelectionOverlay above. Bottom-end corner,
// deliberately opposite the car-selection badge's top-start corner so
// the two never overlap. Only ever called when
// DebugFeatures.debugMenuEnabled is true (see onCreate).
private fun buildDebugMenuOverlay(): ComposeView {
registerDebugMenuListener()
return ComposeView(this).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM or Gravity.END,
).apply { setMargins(24, 24, 24, 24) }
setContent {
MaterialTheme {
DebugMenuButton(visible = DebugMenuVisibility.mapLoaded)
}
}
}
}
override fun setContentView(view: View) {
d(TAG, "setContentView($view)")