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 4f0509f..df073b2 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,6 +13,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 2338544..1942390 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.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)") 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 acd21cd..29d7747 100644 --- a/mpcore/src/main/cpp/main.cpp +++ b/mpcore/src/main/cpp/main.cpp @@ -18,6 +18,8 @@ #include "util/armhooks.h" #include "game_events.h" #include "lan_event_injection.h" +#include "opponent_substitution.h" +#include "cop_traffic_disable.h" void* libapp_base = NULL; @@ -261,6 +263,15 @@ static constexpr bool kEnableMapScreenCtorTraceHook = false; // TEMP: isolating // on even if injection itself is toggled off for testing. static constexpr bool kEnableSoakTestDisableHook = true; +// 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) { @@ -279,6 +290,16 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { if (kEnableSoakTestDisableHook) { InstallSoakTestDisableHook(); } + if (kEnableOpponentGridDiagnosticHooks) { + InstallOpponentCollectionPopulateHook(); + InstallStreetRaceGridPlaceHook(); + } + InstallCopSpawnSchedulerSkipHook(); + InstallRaceEventTrafficCarCountHook(); + InstallTrafficCarSpawnerZeroHook(); + InstallTrafficResetLineSkipHook(); + InstallTrafficCarControllerSkipHook(); + InstallCarResetOnceOnlyHook(); if (kEnableMapScreenCtorTraceHook) { InstallMapScreenCtorTraceHook(); } @@ -317,6 +338,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { InstallLayoutScreenCtorHook(); InstallModSlotSelectedHook(); InstallFireOutputDiagHook(); + InstallPrefabLookupDiagHook(); // NOT installed (cont.43): live-tested and found to break touch // responsiveness on car_select once installed, for reasons not yet // understood (sub_16C660 itself runs fine every frame through the 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 +}