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
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" /> <bytecodeTargetLevel target="17" />
</component> </component>
</project> </project>
+33
View File
@@ -22,6 +22,39 @@
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DialogSelection /> <DialogSelection />
</SelectionState> </SelectionState>
<SelectionState runConfigName="main">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-28T12:35:54.649730307Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=2B101JEGR07875" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="unitTest">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-28T12:35:54.649730307Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=2B101JEGR07875" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="androidTest">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-28T12:35:54.649730307Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=2B101JEGR07875" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates> </selectionStates>
</component> </component>
</project> </project>
+4 -4
View File
@@ -9,10 +9,10 @@
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$USER_HOME$/AndroidStudioProjects/NFSMostWanted128" />
<option value="$PROJECT_DIR$/app" /> <option value="$USER_HOME$/AndroidStudioProjects/NFSMostWanted128/app" />
<option value="$PROJECT_DIR$/mpcore" /> <option value="$USER_HOME$/AndroidStudioProjects/NFSMostWanted128/mpcore" />
<option value="$PROJECT_DIR$/savemanager" /> <option value="$USER_HOME$/AndroidStudioProjects/NFSMostWanted128/savemanager" />
</set> </set>
</option> </option>
</GradleProjectSettings> </GradleProjectSettings>
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="corretto-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">
Generated
+1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$USER_HOME$/AndroidStudioProjects/NFSMostWanted128" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
</component> </component>
</project> </project>
@@ -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.ironmonkey.domain.AssetLocationType
import com.ea.nimble.ApplicationLifecycle import com.ea.nimble.ApplicationLifecycle
import com.ea.nimble.Global import com.ea.nimble.Global
import nfs.mod.mpcore.DebugFeatures
import nfs.mod.mpcore.GameInput import nfs.mod.mpcore.GameInput
import nfs.mod.mpcore.MultiplayerCore.loadCore import nfs.mod.mpcore.MultiplayerCore.loadCore
import nfs.mod.mpcore.MultiplayerCore.loadEmulatedLibapp 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 // "select car" button, letting the on-demand car_select-opening call be
// exercised at an arbitrary moment (not just automatically at boot) for // exercised at an arbitrary moment (not just automatically at boot) for
// live testing. See PROGRESS.md cont.44. // 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() { private val carSelectTestReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) { 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 // cont.48 DEBUG: fires the EXPERIMENTAL true-direct-jump variant via
// `adb shell am broadcast -a nfs.mod.mpcore.TEST_TRUE_DIRECT_CARSELECT // `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() { private val trueDirectCarSelectTestReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) { 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 = FrameLayout(this)
mFrameLayout.addView(gameGLSurfaceView) mFrameLayout.addView(gameGLSurfaceView)
mFrameLayout.addView(buildCarSelectionOverlay()) 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) setContentView(mFrameLayout)
// ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md): // ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md):
// fmodex/fmodevent/Nimble/app are all armeabi-v7a-only builds (no // 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) { override fun setContentView(view: View) {
d(TAG, "setContentView($view)") d(TAG, "setContentView($view)")
+284
View File
@@ -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 <cstdint>
#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;
}
+54
View File
@@ -1277,6 +1277,60 @@ static bool InstallFireOutputDiagHook() {
return true; 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 <Type> prefabs has no entry for component on
// actor <id>" 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 <n>" 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 // Three one-shot diagnostic hooks used to have their install functions
// here (cont.43/54/55: a "who fires this BACK cascade" tracer hooking // 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 // sub_1A7920, a field-value dumper on sub_170EC8, and an attempt to
+39
View File
@@ -56,6 +56,21 @@
// out-parameter calls at all, so they ARE fully ported and are the real // out-parameter calls at all, so they ARE fully ported and are the real
// end-to-end proof this mechanism works. // end-to-end proof this mechanism works.
// #include "lan_event_injection.h" // #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; void* libapp_base = NULL;
@@ -513,6 +528,15 @@ Java_com_ea_ironmonkey_GameActivityMain_nativeInstallCrashHandler(JNIEnv* env, j
if (b) env->ReleaseStringUTFChars(buildStamp, b); 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) { JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env = nullptr; JNIEnv* env = nullptr;
if (vm->GetEnv((void**)&env, JNI_VERSION_1_6) == JNI_OK) { 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 { } else {
Log("JNI_OnLoad: GetEnv failed, GameEvents bridge not initialised"); 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; return JNI_VERSION_1_6;
} }
+227
View File
@@ -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 <cstdint>
#include <cstdlib>
#include <cstring>
#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;
}
@@ -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
}