Add LAN car_select flow: real event handling, GameEvents JNI bridge, live car/upgrade/color capture, Compose UI example

Fixes the synthetic car_select jump for cold sessions, makes the loadout
exit chain safe for real (non-synthetic) events, and adds a native->Kotlin
GameEvents bridge (onMapLoaded/onRaceStarted/onRaceEnded/onUpgradesAccepted/
onCarSelected) so both the UI layer and a future native RatNet client can
learn what the player picked - car id, accepted upgrades, and paint color
(name + RGBA) are all resolved live from the game's own engine state
rather than a static extracted table, so they stay correct for any car
added later. Includes a Jetpack Compose overlay as a worked example of a
UI-side GameEventListener consumer.

Full investigation history, root causes, and the several dead ends ruled
out along the way are documented in PROGRESS.md (cont. 30-63b).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:30:21 +03:00
co-authored by Claude Sonnet 5
parent 1c6324b589
commit cde392870e
10 changed files with 3280 additions and 5 deletions
+111
View File
@@ -16,6 +16,7 @@
#include <iomanip>
#include "util/armhook.h"
#include "util/armhooks.h"
#include "game_events.h"
#include "lan_event_injection.h"
void* libapp_base = NULL;
@@ -173,11 +174,70 @@ static bool InstallBuildTrackScenePathHook() {
return true;
}
// ---- MapScreen constructor trace hook (temporary, RE discovery only) ----
// Purpose: capture the live `im::app::flow::nfs::MapScreen` instance pointer
// so we can read its "scroll" layout-entity (found via sub_1332B8's
// FindOrCreateLayoutEntity call with the literal name "scroll" - see
// PROGRESS.md) - a Transform-shaped object whose position (offset +36/+40)
// and scale (offset +44/+48) are hypothesized to be the map's current
// pan/zoom state, needed to convert a MapTrack's world-space bounds rect
// (found earlier, offsets +0x44.."+0x50") into real screen pixels.
#define MAPSCREEN_CTOR_OFFSET 0x1781BC
typedef void* (*MapScreenCtorFn)(void* a1);
static MapScreenCtorFn orig_MapScreenCtor = nullptr;
void* g_mapScreenInstance = nullptr;
void* Hook_MapScreenCtor(void* a1) {
void* result = orig_MapScreenCtor(a1);
g_mapScreenInstance = a1;
Log("MapScreen constructed: %p", a1);
return result;
}
static bool InstallMapScreenCtorTraceHook() {
uintptr_t target = (uintptr_t)libapp_base + MAPSCREEN_CTOR_OFFSET;
uint32_t* target32 = (uint32_t*)target;
// Confirmed ARM-mode, position-independent prologue this session
// (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C), same trampoline pattern as the
// other hooks in this file.
void* tramp = mmap(nullptr, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (tramp == MAP_FAILED) {
Log("MapScreen ctor hook: mmap trampoline failed");
return false;
}
uint32_t* tramp32 = (uint32_t*)tramp;
tramp32[0] = target32[0];
tramp32[1] = target32[1];
tramp32[2] = 0xE51FF004; // LDR PC, [PC, #-4]
tramp32[3] = (uint32_t)(target + 8);
orig_MapScreenCtor = (MapScreenCtorFn)tramp;
uintptr_t page = target & ~((uintptr_t)getpagesize() - 1);
if (mprotect((void*)page, (size_t)getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC) != 0) {
Log("MapScreen ctor hook: mprotect target failed: %s", strerror(errno));
return false;
}
target32[0] = 0xE51FF004; // LDR PC, [PC, #-4]
target32[1] = (uint32_t)(uintptr_t)&Hook_MapScreenCtor;
__builtin___clear_cache((char*)target, (char*)(target + 8));
__builtin___clear_cache((char*)tramp, (char*)tramp + 16);
Log("Installed MapScreen ctor trace hook at %p, trampoline=%p", (void*)target, tramp);
return true;
}
// Flip to false to run the game completely unmodified (e.g. to capture a
// baseline/"before" comparison) - true installs the track-substitution hook.
// Just edit this and rebuild, no need to touch anything else.
static constexpr bool kEnableTrackSubstitutionHook = true;
static constexpr bool kEnableLanEventInjectionHook = true;
static constexpr bool kEnableMapScreenCtorTraceHook = false; // TEMP: isolating a reproducible SIGSEGV, see PROGRESS.md
// See ANALYSIS.md §6ff/§6gg: prevents a QA-only "Soak Test" auto-race feature
// from eventually crashing the process on entries our injection adds to the
// prefab cache. Independent of kEnableLanEventInjectionHook so it can be kept
@@ -185,6 +245,12 @@ static constexpr bool kEnableLanEventInjectionHook = true;
static constexpr bool kEnableSoakTestDisableHook = true;
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env = nullptr;
if (vm->GetEnv((void**)&env, JNI_VERSION_1_6) == JNI_OK) {
InitGameEvents(vm, env);
} else {
Log("JNI_OnLoad: GetEnv failed, GameEvents bridge not initialised");
}
if (get_libapp_base()) {
if (kEnableTrackSubstitutionHook) {
@@ -196,6 +262,32 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
if (kEnableSoakTestDisableHook) {
InstallSoakTestDisableHook();
}
if (kEnableMapScreenCtorTraceHook) {
InstallMapScreenCtorTraceHook();
}
InstallCopSoundsTickSkipHook();
InstallGetComponentNameSkipHook();
InstallStrlenNullGuardHook();
InstallInternStringDiagHook();
InstallFatalLogCallerTraceHook();
InstallResolveDisplayTextHook();
InstallResolveDisplayTextWrapperDiagHook();
InstallLayoutScreenCtorHook();
InstallModSlotSelectedHook();
InstallFireOutputDiagHook();
// 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
// hook per its own diagnostics - "returned 0" every ~16ms, no hang
// - yet taps stop registering; reproduced 5/5 tries with the hook
// installed vs 1/1 without). sub_16C660 is called at a much higher,
// more global frequency (~60/sec, from app boot onward) than any
// other function hooked in this project - too risky to keep
// chasing blind. See lan_event_injection.h for the full writeup;
// the FireOutput-level interception was widened instead (does not
// need this hook).
// InstallConfirmCarSelectionHook();
InstallFlowNodeTickHook();
}
return JNI_VERSION_1_6;
@@ -205,4 +297,23 @@ extern "C"
JNIEXPORT void JNICALL
Java_nfs_mod_mpcore_MultiplayerCore_bumpBackTraceToLogcat(JNIEnv *env, jobject thiz) {
//backtraceToLogcat();
}
// cont.44: lets Kotlin (eventually a real lobby-overlay button, for now a
// debug broadcast receiver - see GameActivityMain.kt) open car_select on
// demand instead of only automatically at boot. See
// TriggerOpenCarSelectOnDemand in lan_event_injection.h for the details.
extern "C"
JNIEXPORT void JNICALL
Java_nfs_mod_mpcore_MultiplayerCore_triggerCarSelectTest(JNIEnv *env, jobject thiz) {
TriggerOpenCarSelectOnDemand();
}
// cont.48: experimental TRUE direct jump to car_select, bypassing
// EventDetails entirely - see TriggerTrueDirectCarSelectJump in
// lan_event_injection.h for the details and the real risk involved.
extern "C"
JNIEXPORT void JNICALL
Java_nfs_mod_mpcore_MultiplayerCore_triggerTrueDirectCarSelectJump(JNIEnv *env, jobject thiz) {
TriggerTrueDirectCarSelectJump();
}