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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-22 23:19:24 +03:00

228 lines
11 KiB
C++

#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;
}