diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index b86273d..b589d56 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index 26e186b..489add9 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -22,6 +22,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 697e79d..94d04f9 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -9,10 +9,10 @@
diff --git a/.idea/misc.xml b/.idea/misc.xml
index b2c751a..40c82cb 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,6 +1,7 @@
+
-
+
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 94a25f7..50fdfc3 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,6 +1,7 @@
+
\ No newline at end of file
diff --git a/app/src/main/java/com/ea/ironmonkey/DebugMenuOverlay.kt b/app/src/main/java/com/ea/ironmonkey/DebugMenuOverlay.kt
new file mode 100644
index 0000000..8b9f083
--- /dev/null
+++ b/app/src/main/java/com/ea/ironmonkey/DebugMenuOverlay.kt
@@ -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") }
+ },
+ )
+}
diff --git a/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt b/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt
index b6f186e..bb4f4ab 100644
--- a/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt
+++ b/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt
@@ -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)")
diff --git a/mpcore/src/main/cpp/cop_traffic_disable.h b/mpcore/src/main/cpp/cop_traffic_disable.h
new file mode 100644
index 0000000..23064fa
--- /dev/null
+++ b/mpcore/src/main/cpp/cop_traffic_disable.h
@@ -0,0 +1,284 @@
+#pragma once
+
+// Subtask 2.4 — remove police from multiplayer races. See ANALYSIS.md §6hh
+// and ARCHITECTURE.md §3b for the RE writeup this is built on.
+//
+// cont.73/74 CORRECTION: the original hook only covered sub_F7E9C. Tracing
+// callers found the real shape: sub_F5EA4 is a per-tick dispatcher (itself
+// only called from sub_F5BB4, the CopManager's broader per-tick Update,
+// which also does unrelated bookkeeping - bust timers etc. via sub_F9D34/
+// FA4F8/F6640/F67A8/F6BCC - so that outer function is too broad to skip
+// wholesale). sub_F5EA4 branches on a live byte flag (`*(a1+4036)`):
+// true -> sub_F7E9C (cooldown-timer-based single-candidate scheduler)
+// false -> sub_F8078 (distance-sorted-list scheduler, multiple candidates
+// checked per tick via the same sub_F82B4 candidate-check +
+// sub_F85B8/SpawnCopCar call)
+// Both leaves call the same SpawnCopCar (sub_F85B8, own assert string
+// confirms the name) - hooking only sub_F7E9C left sub_F8078's branch
+// completely unblocked, which is almost certainly why cops were still seen
+// live in cont.73 with the old hook enabled and 0 log lines from it (all
+// spawn activity was going through the un-hooked branch). Hooking the
+// dispatcher itself (sub_F5EA4) covers both leaves with one hook and skips
+// only the spawn-decision logic, not sub_F5BB4's other per-tick cop
+// maintenance - same "skip a periodic check function" shape as the existing
+// CopSoundsTick hook (crash_workarounds.h), just at the correct call depth.
+//
+// Gated off by default (g_enableCopSpawnSkipTest) for the same reason as
+// opponent_substitution.h's blanket test: no session/lobby flag exists yet
+// to distinguish "a multiplayer race is active" from singleplayer, so
+// leaving this on unconditionally would remove cops from singleplayer too.
+// Re-enable deliberately only for an isolated live test.
+
+#include
+#include "util/util.h"
+#include "util/hook_install.h"
+
+extern void* libapp_base;
+
+#define COP_SPAWN_DISPATCHER_OFFSET 0xF5EA4
+
+typedef int (*CopSpawnDispatcherFn)(int a1);
+static CopSpawnDispatcherFn orig_CopSpawnDispatcher = nullptr;
+
+static volatile bool g_enableCopSpawnSkipTest = true;
+static volatile int g_copSpawnSkipLogBudget = 50;
+
+extern "C" int Hook_CopSpawnDispatcher(int a1) {
+ if (g_enableCopSpawnSkipTest) {
+ if (g_copSpawnSkipLogBudget > 0) {
+ g_copSpawnSkipLogBudget--;
+ Log("DIAG CopSpawnDispatcher: skipped (g_enableCopSpawnSkipTest on) a1=%p", (void*)(uintptr_t)a1);
+ }
+ return 0;
+ }
+ return orig_CopSpawnDispatcher(a1);
+}
+
+static bool InstallCopSpawnSchedulerSkipHook() {
+ orig_CopSpawnDispatcher = (CopSpawnDispatcherFn)InstallArmTrampolineHook(
+ libapp_base, COP_SPAWN_DISPATCHER_OFFSET, (void*)&Hook_CopSpawnDispatcher,
+ "Cop spawn dispatcher skip hook");
+ return orig_CopSpawnDispatcher != nullptr;
+}
+
+// cont.70 CORRECTION: an earlier pass (ANALYSIS.md §6jj) wrongly concluded
+// TrafficCarCount needed a keyed reflective-lookup hook because sub_2A4D70
+// (RaceEvent's own deserializer) reads it via the same sub_4F99F0/sub_4F9A80
+// reflective helper used for every other field in that function. Re-reading
+// the full decompile: that reflective read happens ONCE, during
+// deserialization, exactly like RaceType/Location/ClassRestriction/etc. -
+// the resolved int is then stored at a plain, fixed offset on the live
+// RaceEvent object (`*(a1+116) = resolvedValue`), same shape as
+// ClassRestriction at +132. The original cont.69 plan (hook after
+// deserialization, overwrite the fixed offset) was correct all along.
+#define RACE_EVENT_DESERIALIZE_OFFSET 0x2A4D70
+#define RACE_EVENT_TRAFFIC_CAR_COUNT_OFFSET 116
+
+typedef uint8_t* (*RaceEventDeserializeFn)(int a1, int a2, uint32_t* a3);
+static RaceEventDeserializeFn orig_RaceEventDeserialize = nullptr;
+
+static volatile bool g_enableTrafficCarCountZeroTest = false;
+static volatile int g_trafficCarCountLogBudget = 50;
+
+extern "C" uint8_t* Hook_RaceEventDeserialize(int a1, int a2, uint32_t* a3) {
+ uint8_t* result = orig_RaceEventDeserialize(a1, a2, a3);
+ if (g_enableTrafficCarCountZeroTest) {
+ int before = *(int*)((uint8_t*)(uintptr_t)a1 + RACE_EVENT_TRAFFIC_CAR_COUNT_OFFSET);
+ *(int*)((uint8_t*)(uintptr_t)a1 + RACE_EVENT_TRAFFIC_CAR_COUNT_OFFSET) = 0;
+ if (g_trafficCarCountLogBudget > 0) {
+ g_trafficCarCountLogBudget--;
+ Log("DIAG RaceEvent::Deserialize: a1=%p TrafficCarCount %d -> 0", (void*)(uintptr_t)a1, before);
+ }
+ }
+ return result;
+}
+
+static bool InstallRaceEventTrafficCarCountHook() {
+ orig_RaceEventDeserialize = (RaceEventDeserializeFn)InstallArmTrampolineHook(
+ libapp_base, RACE_EVENT_DESERIALIZE_OFFSET, (void*)&Hook_RaceEventDeserialize,
+ "RaceEvent deserialize (TrafficCarCount) hook");
+ return orig_RaceEventDeserialize != nullptr;
+}
+
+// cont.74 CORRECTION: live A/B testing (drive an actual race, not just check
+// logcat) showed civilian traffic (a red pickup, a blue sedan, etc.) still
+// present with the TrafficCarCount=0 hook active - that field write was
+// real (confirmed by logcat) but doesn't gate ambient traffic at all. Traced
+// the real spawner: sub_33D734 is TrafficCarSpawner's populate function (own
+// assert string confirms the class name "TrafficCarSpawner"), called twice
+// per race from sub_33C020 - once per traffic direction/lane-group (a3=0/1)
+// - with its max-count parameter (a5) read from `*(a1[3]+16)`/`*(a1[3]+20)`,
+// NOT from RaceEvent.TrafficCarCount (a completely different object/offset).
+// Whatever RaceEvent.TrafficCarCount actually feeds (not traced further -
+// not needed once the real gate was found) isn't the base ambient-traffic
+// density. Hooking sub_33D734 directly and forcing a5 to 0 chokes off both
+// calls (both directions/lane-groups) at the source, regardless of where
+// their real inputs come from.
+#define TRAFFIC_CAR_SPAWNER_POPULATE_OFFSET 0x33D734
+
+typedef int* (*TrafficCarSpawnerPopulateFn)(int a1, int a2, int a3, int a4, int a5);
+static TrafficCarSpawnerPopulateFn orig_TrafficCarSpawnerPopulate = nullptr;
+
+static volatile bool g_enableTrafficSpawnerZeroTest = false;
+static volatile int g_trafficSpawnerLogBudget = 50;
+
+extern "C" int* Hook_TrafficCarSpawnerPopulate(int a1, int a2, int a3, int a4, int a5) {
+ int effectiveMax = a5;
+ if (g_enableTrafficSpawnerZeroTest) {
+ effectiveMax = 0;
+ }
+ if (g_trafficSpawnerLogBudget > 0) {
+ g_trafficSpawnerLogBudget--;
+ Log("DIAG TrafficCarSpawner::Populate: mode=%d maxCount=%d -> %d", a3, a5, effectiveMax);
+ }
+ return orig_TrafficCarSpawnerPopulate(a1, a2, a3, a4, effectiveMax);
+}
+
+static bool InstallTrafficCarSpawnerZeroHook() {
+ orig_TrafficCarSpawnerPopulate = (TrafficCarSpawnerPopulateFn)InstallArmTrampolineHook(
+ libapp_base, TRAFFIC_CAR_SPAWNER_POPULATE_OFFSET, (void*)&Hook_TrafficCarSpawnerPopulate,
+ "TrafficCarSpawner::Populate zero hook");
+ return orig_TrafficCarSpawnerPopulate != nullptr;
+}
+
+// cont.75: the Populate do-while (above) can only reduce traffic to a
+// structural minimum (>=1 candidate per track waypoint), never zero - traced
+// one level further to find where a candidate actually becomes a visible,
+// positioned car on the road. sub_C26A0 is a generic "CarReset" utility (own
+// assert strings confirm "foundTrackInfo"/"Reset") - resolves a spline
+// distance + lateral offset to a world position (sub_2B2D18/sub_85660, same
+// TrackNavigator-family shape as PlaceCar/SpawnCopCar) and writes it via the
+// same sub_10B09C/sub_D5138 transform-write pair used by PlaceCar (subtask
+// 2.2) and SpawnCopCar (subtask 2.4) - i.e. this is the actual moment a
+// traffic car's position becomes real. It has two callers: sub_2A0470 (the
+// "ResetLine" event handler, via sub_31AAEC) and sub_C201C.
+//
+// First attempt hooked sub_2A0470 on the theory it handled both initial
+// placement and periodic recycling. Live-tested and DISPROVEN: across a full
+// race replay the hook's own diagnostic log (unconditional, not gated by the
+// test flag) never fired once, yet a traffic pickup truck was clearly
+// visible and moving the whole time - sub_2A0470 simply isn't on the path
+// that keeps an ordinary traffic car on the road during a normal-length
+// race. Left in place (harmless, possibly still relevant for very long
+// races) but is NOT the real fix.
+//
+// sub_C201C is: own second parameter carries a delta-time value (`*a2 *
+// -0.001` pattern), and it calls sub_C26A0 twice - once when an idle/wander
+// timer at `a1+88` counts down to zero (computing a fresh position via
+// sub_690A94), once to retry a previously-deferred reset stored at
+// `a1+112..128` (the exact same field layout sub_C26A0 itself writes on its
+// own "can't resolve position yet, queue it" fallback path) - i.e. this is
+// the traffic car's own per-tick controller, not a generic car utility.
+// Skipping it wholesale prevents that car from ever being (re)positioned on
+// the road at all.
+#define TRAFFIC_CAR_CONTROLLER_TICK_OFFSET 0xC201C
+
+typedef int (*TrafficCarControllerTickFn)(int a1, int* a2);
+static TrafficCarControllerTickFn orig_TrafficCarControllerTick = nullptr;
+
+// cont.77 CORRECTION: live-testing on a second track ("ПРОМЫШЛ. СОБЫТИЕ")
+// showed a traffic car sitting frozen right at the starting grid, causing a
+// spawn collision - unconfirmed on the first track (Reynolds Lane), where a
+// hook this thorough happened not to matter. Root cause: this wholesale skip
+// prevents sub_C201C's idle-timer branch from EVER calling sub_C26A0
+// (CarReset), which means the traffic car never receives ANY position -
+// not "no position" (invisible), but whatever its pooled/reused object's
+// stale transform already was, which can be anywhere, including right on
+// the grid. Skipping unconditionally forever is wrong; the fix is a
+// once-per-object CarReset hook below, which lets each traffic car receive
+// exactly one real, TrackNavigator-resolved position (same placement logic
+// used for opponents/cops - never intentionally at the grid) and then
+// freezes it there, rather than leaving it at an arbitrary stale transform.
+// This hook is now disabled by default in favor of Hook_CarReset.
+static volatile bool g_enableTrafficControllerSkipTest = false;
+static volatile int g_trafficControllerLogBudget = 50;
+
+extern "C" int Hook_TrafficCarControllerTick(int a1, int* a2) {
+ if (g_trafficControllerLogBudget > 0) {
+ g_trafficControllerLogBudget--;
+ Log("DIAG TrafficCarControllerTick: a1=%p enabled=%d", (void*)(uintptr_t)a1, (int)g_enableTrafficControllerSkipTest);
+ }
+ if (g_enableTrafficControllerSkipTest) {
+ return 0;
+ }
+ return orig_TrafficCarControllerTick(a1, a2);
+}
+
+static bool InstallTrafficCarControllerSkipHook() {
+ orig_TrafficCarControllerTick = (TrafficCarControllerTickFn)InstallArmTrampolineHook(
+ libapp_base, TRAFFIC_CAR_CONTROLLER_TICK_OFFSET, (void*)&Hook_TrafficCarControllerTick,
+ "Traffic car controller tick skip hook");
+ return orig_TrafficCarControllerTick != nullptr;
+}
+
+// cont.77: the real fix. Hook CarReset (sub_C26A0) itself - the shared
+// position-write primitive both sub_C201C and sub_2A0470 call into (see
+// §6mm) - and let each distinct traffic-car "this" pointer through exactly
+// once (a real, TrackNavigator-resolved position, same system used for
+// opponents/cops, so it lands somewhere sane on the road, never at the
+// grid by construction), then no-op every subsequent call for that same
+// object. Net effect: traffic cars appear once, parked, never move again -
+// no continuously-cycling ambient traffic, no risk of a frozen car sitting
+// at an arbitrary stale (possibly grid-overlapping) transform.
+#define CAR_RESET_OFFSET 0xC26A0
+
+typedef int (*CarResetFn)(int a1, uint32_t* a2, int a3, int a4);
+static CarResetFn orig_CarReset = nullptr;
+
+static volatile bool g_enableTrafficOnceOnlyResetTest = true;
+static const int kSeenCarResetCapacity = 128;
+static int g_seenCarResetControllers[kSeenCarResetCapacity];
+static volatile int g_seenCarResetCount = 0;
+static volatile int g_carResetLogBudget = 50;
+
+extern "C" int Hook_CarReset(int a1, uint32_t* a2, int a3, int a4) {
+ if (!g_enableTrafficOnceOnlyResetTest) {
+ return orig_CarReset(a1, a2, a3, a4);
+ }
+ for (int i = 0; i < g_seenCarResetCount; i++) {
+ if (g_seenCarResetControllers[i] == a1) {
+ return 0;
+ }
+ }
+ if (g_seenCarResetCount < kSeenCarResetCapacity) {
+ g_seenCarResetControllers[g_seenCarResetCount++] = a1;
+ }
+ if (g_carResetLogBudget > 0) {
+ g_carResetLogBudget--;
+ Log("DIAG CarReset: first-and-only real placement for a1=%p (seen=%d)", (void*)(uintptr_t)a1, g_seenCarResetCount);
+ }
+ return orig_CarReset(a1, a2, a3, a4);
+}
+
+static bool InstallCarResetOnceOnlyHook() {
+ orig_CarReset = (CarResetFn)InstallArmTrampolineHook(
+ libapp_base, CAR_RESET_OFFSET, (void*)&Hook_CarReset,
+ "CarReset once-only (traffic) hook");
+ return orig_CarReset != nullptr;
+}
+
+#define TRAFFIC_RESET_LINE_HANDLER_OFFSET 0x2A0470
+
+typedef int (*TrafficResetLineHandlerFn)(uint32_t* a1);
+static TrafficResetLineHandlerFn orig_TrafficResetLineHandler = nullptr;
+
+static volatile bool g_enableTrafficResetLineSkipTest = false;
+static volatile int g_trafficResetLineLogBudget = 50;
+
+extern "C" int Hook_TrafficResetLineHandler(uint32_t* a1) {
+ if (g_trafficResetLineLogBudget > 0) {
+ g_trafficResetLineLogBudget--;
+ Log("DIAG TrafficResetLineHandler: a1=%p enabled=%d", (void*)a1, (int)g_enableTrafficResetLineSkipTest);
+ }
+ if (g_enableTrafficResetLineSkipTest) {
+ return 0;
+ }
+ return orig_TrafficResetLineHandler(a1);
+}
+
+static bool InstallTrafficResetLineSkipHook() {
+ orig_TrafficResetLineHandler = (TrafficResetLineHandlerFn)InstallArmTrampolineHook(
+ libapp_base, TRAFFIC_RESET_LINE_HANDLER_OFFSET, (void*)&Hook_TrafficResetLineHandler,
+ "Traffic ResetLine handler skip hook");
+ return orig_TrafficResetLineHandler != nullptr;
+}
diff --git a/mpcore/src/main/cpp/lan_event_injection.h b/mpcore/src/main/cpp/lan_event_injection.h
index 28ff886..c686631 100644
--- a/mpcore/src/main/cpp/lan_event_injection.h
+++ b/mpcore/src/main/cpp/lan_event_injection.h
@@ -1277,6 +1277,60 @@ static bool InstallFireOutputDiagHook() {
return true;
}
+// cont.78 DIAG: TriggerOpenCarSelectOnDemand's own FireEventOutput call
+// (sub_17A99C) reliably SIGSEGVs (fault addr 0x8) firing our synthetic key
+// 0xC0FFEE00, before Hook_FireOutputDiag's "output=..." line ever prints -
+// i.e. it never reaches the real FireOutput/FlowNode dispatch at all. The
+// crash is inside FireEventOutput's own diagnostic-label-building code
+// (builds a "Database of prefabs has no entry for component on
+// actor " string when a key has no matching prefab-database entry -
+// own strings confirm this), independent of save state (reproduced on
+// both the 100%-save and the original small save) and independent of
+// threading (still crashes on GLThread after cont.78's queueEvent fix).
+// sub_240548 is the specific function that does the prefab lookup + builds
+// that message. Hooking its entry to log inputs before calling through -
+// purely diagnostic, always calls orig unconditionally, budget-capped.
+#define PREFAB_LOOKUP_DIAG_OFFSET 0x240548
+typedef char* (*PrefabLookupFn)(int a1, uint32_t* a2);
+static PrefabLookupFn orig_PrefabLookup = nullptr;
+static volatile int g_prefabLookupLogBudget = 20;
+
+extern "C" char* Hook_PrefabLookupDiag(int a1, uint32_t* a2) {
+ if (g_prefabLookupLogBudget > 0) {
+ g_prefabLookupLogBudget--;
+ uint32_t a2_0 = a2 ? a2[0] : 0;
+ uint32_t a2_1 = a2 ? a2[1] : 0;
+ uint32_t a2_2 = a2 ? a2[2] : 0;
+ Log("DIAG PrefabLookup: a1=%p a2=%p a2[0]=0x%x a2[1]=0x%x a2[2]=0x%x",
+ (void*)(uintptr_t)a1, (void*)a2, a2_0, a2_1, a2_2);
+ }
+ // cont.78 live finding: a2 is an eastl-style {begin,end,capacity} string
+ // range (the "EVENT ID " debug label FireEventOutput builds for its
+ // prefab-database lookup) - captured live with end==0 while begin was a
+ // valid heap pointer, a malformed/corrupt range that crashes downstream
+ // (fault addr 0x8) inside this lookup. Our synthetic event keys
+ // (0xC0FFEE00 family) are all negative as signed int32 (top bit set);
+ // the int-to-string conversion feeding this label plausibly mishandles
+ // that, producing this corrupt range. Defensive repair: if end is
+ // before begin, collapse to an empty (but well-formed) string instead
+ // of letting the corrupt range reach the lookup/message-building code -
+ // matches this binary's own many existing "detect bad state, substitute
+ // a safe default" guards rather than trying to patch the real root
+ // cause (the number-formatting call) blind.
+ if (a2 && a2[0] != 0 && a2[1] < a2[0]) {
+ Log("DIAG PrefabLookup: repairing malformed range a2[1] (0x%x) < a2[0] (0x%x) -> collapsing to empty string",
+ a2[1], a2[0]);
+ a2[1] = a2[0];
+ }
+ return orig_PrefabLookup(a1, a2);
+}
+
+static bool InstallPrefabLookupDiagHook() {
+ orig_PrefabLookup = (PrefabLookupFn)InstallArmTrampolineHook(
+ libapp_base, PREFAB_LOOKUP_DIAG_OFFSET, (void*)&Hook_PrefabLookupDiag, "Prefab lookup diag hook");
+ return orig_PrefabLookup != nullptr;
+}
+
// Three one-shot diagnostic hooks used to have their install functions
// here (cont.43/54/55: a "who fires this BACK cascade" tracer hooking
// sub_1A7920, a field-value dumper on sub_170EC8, and an attempt to
diff --git a/mpcore/src/main/cpp/main.cpp b/mpcore/src/main/cpp/main.cpp
index e6f0ead..e56500e 100644
--- a/mpcore/src/main/cpp/main.cpp
+++ b/mpcore/src/main/cpp/main.cpp
@@ -56,6 +56,21 @@
// out-parameter calls at all, so they ARE fully ported and are the real
// end-to-end proof this mechanism works.
// #include "lan_event_injection.h"
+//
+// Branch merge (2026-09-22): opponent_substitution.h and cop_traffic_disable.h
+// landed from the native32/lan-event-injection-poc branch, where they were
+// built and live-confirmed against a real dlopen'd libapp.so - proven
+// correct, but every one of their hooks resolves and calls raw
+// "libapp_base + OFFSET" function pointers directly (this file's own
+// InstallArmTrampolineHook pattern), the exact same shape as
+// lan_event_injection.h's un-ported call sites above. On THIS build there is
+// no real ARM32 code at that address to jump to - only Unicorn-backed guest
+// memory - so including these unmodified would not just misbehave, it would
+// jump the host CPU into data and crash immediately. Left as source
+// (untouched, not included) for the same follow-up porting session as
+// lan_event_injection.h, not wired in here.
+// #include "opponent_substitution.h"
+// #include "cop_traffic_disable.h"
void* libapp_base = NULL;
@@ -513,6 +528,15 @@ Java_com_ea_ironmonkey_GameActivityMain_nativeInstallCrashHandler(JNIEnv* env, j
if (b) env->ReleaseStringUTFChars(buildStamp, b);
}
+// cont.69/70: subtask 2.1 diagnostic pass - logs OpponentCollection's built
+// Opponent vector and StreetRaceStartingGrid's own placement-vector argument
+// side by side, to confirm live whether they're the same set before writing
+// the real substitution hook (see opponent_substitution.h's own comment and
+// ANALYSIS.md §6hh). Diagnostic-only for now - does not change any opponent
+// data yet, only logs. Gated independently so it can stay on for real-race
+// testing without pulling in unrelated hooks.
+static constexpr bool kEnableOpponentGridDiagnosticHooks = true;
+
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env = nullptr;
if (vm->GetEnv((void**)&env, JNI_VERSION_1_6) == JNI_OK) {
@@ -520,6 +544,21 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
} else {
Log("JNI_OnLoad: GetEnv failed, GameEvents bridge not initialised");
}
+ // Branch merge (2026-09-22): the native32/lan-event-injection-poc
+ // branch's own JNI_OnLoad installs ~20 hooks here (opponent
+ // substitution, cop/traffic removal, the LAN event-injection state
+ // machine, several crash workarounds) against a real dlopen'd
+ // libapp.so, available at JNI_OnLoad time. On this build libapp.so
+ // isn't loaded until Java calls loadEmulatedLibapp() well after
+ // JNI_OnLoad (see LoadEmulatedLibapp's own comment - no
+ // Context/AssetManager access this early) - `get_libapp_base()` would
+ // always be null here regardless, and every one of those Install*Hook
+ // calls resolves a raw "libapp_base + OFFSET" function pointer that
+ // needs GuestFn/CallGuestFunction plumbing to be callable at all (see
+ // this file's own comment above the commented-out includes). None of
+ // it is wired in here - deliberately, not an oversight - pending the
+ // same porting session as lan_event_injection.h/
+ // opponent_substitution.h/cop_traffic_disable.h themselves.
return JNI_VERSION_1_6;
}
diff --git a/mpcore/src/main/cpp/opponent_substitution.h b/mpcore/src/main/cpp/opponent_substitution.h
new file mode 100644
index 0000000..e77906c
--- /dev/null
+++ b/mpcore/src/main/cpp/opponent_substitution.h
@@ -0,0 +1,227 @@
+#pragma once
+
+// Subtask 2.1 — substitute real lobby players' car+color into AI opponent
+// slots. See ANALYSIS.md §6hh and ARCHITECTURE.md §3b for the full RE
+// writeup this is built on.
+//
+// cont.70 CONFIRMED LIVE: OpponentCollection::PopulateFromProperties
+// (sub_2B649C) builds a vector of Opponent* (8-byte elements) at
+// this+12/+16/+20. StreetRaceStartingGrid's own placement method
+// (sub_2B88BC) separately iterates a DIFFERENT vector (12-byte elements,
+// different heap addresses entirely, confirmed by comparing live pointer
+// dumps from both hooks) - so the two are NOT the same objects, there's an
+// intermediate spawn/resolve step between them. Despite that, overwriting
+// Opponent.CarDescriptionName/ColourIndex (+20/+24 string, +36 int) at
+// Populate time (map-load) WAS confirmed live to reach the actual spawned,
+// rendered car at the starting grid - screenshotted a Ford Focus RS500
+// forced into an opponent slot that was a completely different car/color
+// before the hook, on a real (non-synthetic) "Перед вами FAIRHAVEN" replay.
+// Whatever the intermediate step is, it reads CarDescriptionName/ColourIndex
+// off these same Opponent objects (or a value-copy taken after Populate
+// already ran), not off some earlier, already-fixed snapshot. Mechanism is
+// proven; the intermediate step itself was not traced (not needed for the
+// question this was testing).
+
+#include
+#include
+#include
+#include "util/util.h"
+#include "util/hook_install.h"
+
+extern void* libapp_base;
+
+#define OPPONENT_COLLECTION_POPULATE_OFFSET 0x2B649C
+#define STREET_RACE_GRID_PLACE_OFFSET 0x2B88BC
+
+typedef int (*OpponentCollectionPopulateFn)(uint32_t* thisPtr, int a2, uint32_t* a3);
+static OpponentCollectionPopulateFn orig_OpponentCollectionPopulate = nullptr;
+
+static volatile int g_opponentPopulateLogBudget = 2000;
+
+// Blanket test substitution (every opponent slot of EVERY collection, any
+// race) that produced the cont.70 live confirmation above. Kept here, but
+// gated OFF by default (g_enableBlanketOpponentSubstitutionTest) - it has no
+// way to target a specific race or a specific real lobby player yet (no
+// lobby system exists to source that data from), so leaving it on would
+// silently affect every race in normal play. Re-enable deliberately only for
+// another isolated live test; the real, production substitution hook
+// (targeted, sourced from actual lobby player data) is a separate follow-up
+// once the lobby UI/data model exists to drive it.
+//
+// cont.72: extended from a single fixed test car (cont.70) to a small roster
+// of distinct cars/colors cycling by slot index, to emulate what a real
+// multi-player lobby would look like (each opponent slot = "a different
+// player's own car"), per direct request.
+struct TestSubstituteCar {
+ const char* carId;
+ int colourIndex;
+};
+// cont.76: user-requested specific demo roster (car IDs sourced from
+// unpacked game data files, not binary strings, per user's own correction).
+static const TestSubstituteCar kTestSubstituteRoster[] = {
+ {"marussia_b2_2011_desc", 0},
+ {"lamborghini_gallardo_lp570_4_superleggera_2011_desc", 0},
+ {"srt_viper_2013_desc", 0},
+ {"lamborghini_aventador_lp700_4_2011_desc", 0},
+};
+static const int kTestSubstituteRosterLength =
+ (int)(sizeof(kTestSubstituteRoster) / sizeof(kTestSubstituteRoster[0]));
+static volatile bool g_enableBlanketOpponentSubstitutionTest = true;
+
+extern "C" int Hook_OpponentCollectionPopulate(uint32_t* thisPtr, int a2, uint32_t* a3) {
+ int result = orig_OpponentCollectionPopulate(thisPtr, a2, a3);
+ uint32_t* begin = (uint32_t*)thisPtr[3];
+ uint32_t* end = (uint32_t*)thisPtr[4];
+ int count = begin ? (int)(end - begin) / 2 : 0;
+
+ if (count > 0 && g_enableBlanketOpponentSubstitutionTest) {
+ for (int i = 0; i < count; i++) {
+ uint32_t* opp = (uint32_t*)begin[i * 2];
+ if (!opp) continue;
+ const TestSubstituteCar& car = kTestSubstituteRoster[i % kTestSubstituteRosterLength];
+ size_t len = strlen(car.carId);
+ *(const char**)((uint8_t*)opp + 20) = car.carId;
+ *(const char**)((uint8_t*)opp + 24) = car.carId + len;
+ *(int*)((uint8_t*)opp + 36) = car.colourIndex;
+ }
+ }
+
+ if (g_opponentPopulateLogBudget > 0) {
+ g_opponentPopulateLogBudget--;
+ Log("DIAG OpponentCollection::Populate: this=%p vec=[%p..%p) count=%d", thisPtr, begin, end, count);
+ for (int i = 0; i < count && i < 8; i++) {
+ uint32_t* opp = (uint32_t*)begin[i * 2];
+ if (!opp) {
+ Log(" [%d] Opponent*=NULL", i);
+ continue;
+ }
+ const char* nameBegin = *(const char**)((uint8_t*)opp + 20);
+ const char* nameEnd = *(const char**)((uint8_t*)opp + 24);
+ int nameLen = (nameEnd && nameBegin && nameEnd > nameBegin) ? (int)(nameEnd - nameBegin) : 0;
+ char buf[96];
+ if (nameLen > 0 && nameLen < (int)sizeof(buf)) {
+ memcpy(buf, nameBegin, nameLen);
+ buf[nameLen] = 0;
+ } else {
+ buf[0] = 0;
+ }
+ int colourIndex = *(int*)((uint8_t*)opp + 36);
+ Log(" [%d] Opponent*=%p CarDescriptionName=\"%s\" ColourIndex=%d (AFTER any substitution)", i, opp, buf, colourIndex);
+ }
+ }
+ return result;
+}
+
+static bool InstallOpponentCollectionPopulateHook() {
+ orig_OpponentCollectionPopulate = (OpponentCollectionPopulateFn)InstallArmTrampolineHook(
+ libapp_base, OPPONENT_COLLECTION_POPULATE_OFFSET, (void*)&Hook_OpponentCollectionPopulate,
+ "OpponentCollection::Populate diag hook");
+ return orig_OpponentCollectionPopulate != nullptr;
+}
+
+typedef int (*StreetRaceGridPlaceFn)(int a1, int* a2, int a3, uint32_t* a4);
+static StreetRaceGridPlaceFn orig_StreetRaceGridPlace = nullptr;
+
+static volatile int g_gridPlaceLogBudget = 20;
+
+// cont.72: random player grid position. sub_2B88BC's own algorithm (see
+// ANALYSIS.md §6hh Q2) is why the player is always last - it loops over
+// every opponent first (accumulating a randomized inter-car distance each
+// time via sub_291BA4/PlaceOpponent), THEN places the player once, at
+// whatever distance the loop finished on. There's no field to flip for
+// this - the "always last" behavior is baked into the call ORDER, so
+// randomizing it means reimplementing that order, not just tweaking data
+// beforehand/afterward like every other hook in this project. This calls
+// the same two real placement primitives orig uses (sub_291BA4 for an
+// opponent - keeps its own lateral zigzag math untouched; sub_2914DC/
+// PlaceCar directly for the player, lateral=0, matching orig's own player
+// call) in a randomly reordered sequence: count+1 total slots (N opponents
+// + 1 player), one is picked at random for the player, the rest go to
+// opponents in their original order. Distance accumulation matches orig's
+// own shape (place, then advance by a random offset in
+// [MinDistanceBetweenRacers, MaxDistanceBetweenRacers]) but uses plain
+// rand() instead of replicating sub_75680/sub_61C9F8's own RNG chain
+// (which is seeded from the just-placed car's own return value in a way
+// not fully understood - not worth the risk of misusing it for a test
+// hook). Falls back to the real, untouched orig_StreetRaceGridPlace
+// whenever this test is disabled - zero behavior change for normal play.
+typedef int (*PlaceOpponentFn)(int a1, int a2, int a3, float a4, float a5, int a6);
+typedef int (*PlaceCarFn)(int a1, uint32_t* a2, int a3, int a4, float a5);
+static PlaceOpponentFn PlaceOpponent = nullptr;
+static PlaceCarFn PlaceCar = nullptr;
+
+static volatile bool g_enableRandomPlayerGridPositionTest = true;
+
+extern "C" int Hook_StreetRaceGridPlace(int a1, int* a2, int a3, uint32_t* a4) {
+ if (g_gridPlaceLogBudget > 0) {
+ g_gridPlaceLogBudget--;
+ uint32_t begin = a4[0];
+ uint32_t end = a4[1];
+ int count = (end > begin) ? (int)(end - begin) / 12 : 0;
+ Log("DIAG StreetRaceGrid::Place: a1(grid)=%p a2(ctx)=%p a3(playerHandle)=0x%x vec=[0x%x..0x%x) count=%d",
+ (void*)(uintptr_t)a1, (void*)a2, a3, begin, end, count);
+ for (int i = 0; i < count && i < 8; i++) {
+ uint32_t* elem = (uint32_t*)(uintptr_t)(begin + i * 12);
+ Log(" [%d] word0=0x%x word1=0x%x word2=0x%x", i, elem[0], elem[1], elem[2]);
+ }
+ }
+
+ if (!g_enableRandomPlayerGridPositionTest || !PlaceOpponent || !PlaceCar) {
+ return orig_StreetRaceGridPlace(a1, a2, a3, a4);
+ }
+
+ uint32_t begin = a4[0];
+ uint32_t end = a4[1];
+ int count = (end > begin) ? (int)(end - begin) / 12 : 0;
+ if (count <= 0) {
+ return orig_StreetRaceGridPlace(a1, a2, a3, a4);
+ }
+
+ float minDist = *(float*)((uint8_t*)(uintptr_t)a1 + 12);
+ float maxDist = *(float*)((uint8_t*)(uintptr_t)a1 + 16);
+ float trackWidthFraction = *(float*)((uint8_t*)(uintptr_t)a1 + 20);
+ float playerSpeed = *(float*)((uint8_t*)(uintptr_t)a1 + 24);
+ float opponentSpeed = *(float*)((uint8_t*)(uintptr_t)a1 + 28);
+
+ int playerSlot = rand() % (count + 1); // 0..count inclusive - count+1 total physical slots
+ int ctxDeref = *a2;
+ float distance = 0.0f;
+ int opponentIndex = 0;
+
+ Log("RANDOM GRID TEST: %d opponents, player placed at slot %d/%d", count, playerSlot, count);
+
+ // Opponent lane index a6 cycles ((a6+1)%3), and a6%3==0 lands exactly on
+ // world-lateral 0 (center) - the SAME literal offset PlaceCar always uses
+ // for the player. Vanilla is safe because the player is placed once, at
+ // the very end, past the entire accumulated distance - never adjacent to
+ // any specific opponent. Here the player can land next to an opponent
+ // sharing that center lane, and a plain minDist..maxDist gap (sized for
+ // adjacent DIFFERENT lanes) isn't enough separation for two cars in the
+ // SAME lane - reproduced live as a spawn-inside-another-car launch bug.
+ // Fix: widen the distance gap on both sides of the player's slot so
+ // longitudinal separation alone guarantees no overlap, regardless of lane.
+ for (int slot = 0; slot <= count; slot++) {
+ if (slot == playerSlot) {
+ PlaceCar(ctxDeref, (uint32_t*)(uintptr_t)a3, (int)distance, 0, playerSpeed);
+ } else {
+ uint32_t* elem = (uint32_t*)(uintptr_t)(begin + opponentIndex * 12);
+ PlaceOpponent(ctxDeref, (int)(uintptr_t)elem, (int)distance, trackWidthFraction, opponentSpeed, opponentIndex);
+ opponentIndex++;
+ }
+ float randomFraction = (float)rand() / (float)RAND_MAX;
+ bool adjacentToPlayer = (slot == playerSlot) || (slot + 1 == playerSlot);
+ float gapMin = adjacentToPlayer ? (minDist + maxDist) : minDist;
+ float gapMax = adjacentToPlayer ? (minDist + maxDist) * 2.0f : maxDist;
+ distance += gapMin + randomFraction * (gapMax - gapMin);
+ }
+ return 1;
+}
+
+static bool InstallStreetRaceGridPlaceHook() {
+ orig_StreetRaceGridPlace = (StreetRaceGridPlaceFn)InstallArmTrampolineHook(
+ libapp_base, STREET_RACE_GRID_PLACE_OFFSET, (void*)&Hook_StreetRaceGridPlace,
+ "StreetRaceGrid::Place diag hook");
+ PlaceOpponent = (PlaceOpponentFn)((uintptr_t)libapp_base + 0x291BA4);
+ PlaceCar = (PlaceCarFn)((uintptr_t)libapp_base + 0x2914DC);
+ return orig_StreetRaceGridPlace != nullptr;
+}
diff --git a/mpcore/src/main/java/nfs/mod/mpcore/DebugFeatures.kt b/mpcore/src/main/java/nfs/mod/mpcore/DebugFeatures.kt
new file mode 100644
index 0000000..1aa9002
--- /dev/null
+++ b/mpcore/src/main/java/nfs/mod/mpcore/DebugFeatures.kt
@@ -0,0 +1,12 @@
+package nfs.mod.mpcore
+
+/**
+ * Explicit on/off switches for debug-only tooling (e.g. the on-map debug
+ * menu). Flip a flag here to enable/disable a feature - this is the one
+ * place that decides whether debug UI gets built at all, kept in mpcore so
+ * it's not tangled up with any one Activity/module.
+ */
+object DebugFeatures {
+ /** Shows the on-map debug menu button (money editor, etc). */
+ var debugMenuEnabled: Boolean = true
+}