mod: opponent substitution, cop removal, debug menu scaffold

Subtask 2.1 - opponent_substitution.h. Hooks
OpponentCollection::PopulateFromProperties (sub_2B649C) and overwrites
Opponent.CarDescriptionName/ColourIndex at map-load time so a real lobby
player's car and colour take an AI opponent's slot. Confirmed live: a Ford
Focus RS500 was forced into a slot that held a different car and colour
before the hook, on a real (non-synthetic) replay. The starting-grid
placement code walks a different vector entirely, so there is an untraced
intermediate spawn/resolve step - it was not needed, because whatever it is,
it reads these same fields. The header records that honestly rather than
claiming the whole chain is understood.

Subtask 2.4 - cop_traffic_disable.h. Removes police from multiplayer races.
This corrects an earlier hook that only covered sub_F7E9C: tracing callers
showed sub_F5EA4 is the per-tick dispatcher and branches on a live flag into
two schedulers, both reaching the same SpawnCopCar. Hooking one leaf left the
other unblocked, which is why cops were still appearing while the hook logged
nothing. Hooking the dispatcher covers both leaves and skips only the
spawn decision, leaving the CopManager's other per-tick bookkeeping alone.
Civilian traffic and AI opponents are untouched - both are load-bearing for
multiplayer.

Debug menu scaffold: DebugFeatures.kt is the single switch deciding whether
debug UI is built at all; DebugMenuOverlay.kt is the Compose panel. Its Apply
deliberately touches no game memory yet - no balance getter/setter has been
located (DEBUG_MENU.md section 3) - so it edits local state only rather than
pretending to work.

Stays on this feature branch, not master: toggle-flag and experimental work
does not belong on a release-ready branch.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-22 23:19:24 +03:00
co-authored by Claude
parent 74ee49881d
commit 239a9a6346
12 changed files with 768 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.SyntheticInputDispatcher
@@ -82,18 +83,29 @@ 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_mod`. See PROGRESS.md cont.48.
// -p com.ea.games.nfs13_mod`. See PROGRESS.md cont.48. See 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() }
}
}
@@ -280,6 +292,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)
System.loadLibrary("fmodex")
System.loadLibrary("fmodevent")
@@ -691,6 +709,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)")