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
+187
View File
@@ -0,0 +1,187 @@
#pragma once
#include <jni.h>
#include "util/util.h"
// ---- Native -> Kotlin game-lifecycle event bridge ----
//
// Mirrors nfs.mod.mpcore.GameEvents on the Kotlin side: native hooks call
// FireMapLoaded()/FireRaceStarted()/FireRaceEnded() whenever they observe the
// corresponding moment, and this forwards to GameEvents.dispatch*() via JNI,
// which fans it out to whatever Kotlin GameEventListeners are registered.
//
// Only onMapLoaded and onUpgradesAccepted have real triggers wired up right
// now (from Hook_MapTrackHandleEvent and Hook_LayoutScreenCtor respectively,
// both in lan_event_injection.h) - FireRaceStarted/FireRaceEnded are
// scaffolded for when those hook points are found, per the user's request
// for a general onMapLoaded/onRaceStarted/onRaceEnded-shaped event class on
// both sides. See PROGRESS.md for status.
static JavaVM* g_gameEventsJvm = nullptr;
static jclass g_gameEventsClass = nullptr;
static jmethodID g_onMapLoadedMethod = nullptr;
static jmethodID g_onRaceStartedMethod = nullptr;
static jmethodID g_onRaceEndedMethod = nullptr;
static jmethodID g_onUpgradesAcceptedMethod = nullptr;
static jmethodID g_onCarSelectedMethod = nullptr;
// Must be called from JNI_OnLoad (a properly app-classloader-scoped thread -
// FindClass from an arbitrary AttachCurrentThread'd native thread later would
// resolve against the wrong classloader and fail to find app classes).
inline void InitGameEvents(JavaVM* vm, JNIEnv* env) {
g_gameEventsJvm = vm;
jclass localClass = env->FindClass("nfs/mod/mpcore/GameEvents");
if (!localClass) {
Log("GameEvents: FindClass(nfs/mod/mpcore/GameEvents) failed");
env->ExceptionClear();
return;
}
g_gameEventsClass = (jclass)env->NewGlobalRef(localClass);
env->DeleteLocalRef(localClass);
g_onMapLoadedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchMapLoaded", "()V");
g_onRaceStartedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchRaceStarted", "()V");
g_onRaceEndedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchRaceEnded", "()V");
g_onUpgradesAcceptedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchUpgradesAccepted", "([I[I)V");
g_onCarSelectedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchCarSelected",
"(Ljava/lang/String;Ljava/lang/String;IIII)V");
if (!g_onMapLoadedMethod || !g_onRaceStartedMethod || !g_onRaceEndedMethod || !g_onUpgradesAcceptedMethod ||
!g_onCarSelectedMethod) {
Log("GameEvents: one or more dispatch* methods not found on GameEvents.kt");
env->ExceptionClear();
} else {
Log("GameEvents: JNI bridge initialised");
}
}
inline JNIEnv* GetJNIEnvForCurrentThread(bool* didAttach) {
*didAttach = false;
if (!g_gameEventsJvm) return nullptr;
JNIEnv* env = nullptr;
jint res = g_gameEventsJvm->GetEnv((void**)&env, JNI_VERSION_1_6);
if (res == JNI_EDETACHED) {
if (g_gameEventsJvm->AttachCurrentThread(&env, nullptr) != JNI_OK) {
Log("GameEvents: AttachCurrentThread failed");
return nullptr;
}
*didAttach = true;
} else if (res != JNI_OK) {
Log("GameEvents: GetEnv failed (res=%d)", res);
return nullptr;
}
return env;
}
inline void FireGameEvent(jmethodID method, const char* name) {
if (!g_gameEventsClass || !method) {
Log("GameEvents: %s fired but JNI bridge isn't ready, dropping", name);
return;
}
bool didAttach = false;
JNIEnv* env = GetJNIEnvForCurrentThread(&didAttach);
if (!env) return;
env->CallStaticVoidMethod(g_gameEventsClass, method);
if (env->ExceptionCheck()) {
Log("GameEvents: %s dispatch threw a Java exception", name);
env->ExceptionDescribe();
env->ExceptionClear();
} else {
Log("GameEvents: %s dispatched to Kotlin successfully", name);
}
if (didAttach) {
g_gameEventsJvm->DetachCurrentThread();
}
}
inline void FireMapLoaded() { FireGameEvent(g_onMapLoadedMethod, "onMapLoaded"); }
inline void FireRaceStarted() { FireGameEvent(g_onRaceStartedMethod, "onRaceStarted"); }
inline void FireRaceEnded() { FireGameEvent(g_onRaceEndedMethod, "onRaceEnded"); }
// Carries data (unlike the plain lifecycle events above), so it needs its
// own JNI array marshaling instead of reusing FireGameEvent. Called once,
// right as the controlled BACK-chain exit lands back on map_overworld (see
// Hook_LayoutScreenCtor in lan_event_injection.h) - slotIds/carMods mirror
// g_modSlotSelections[] at that moment: parallel arrays, one entry per slot
// the player touched this loadout session (carMod==0 means "cleared/NONE").
inline void FireUpgradesAccepted(const int* slotIds, const int* carMods, int count) {
if (!g_gameEventsClass || !g_onUpgradesAcceptedMethod) {
Log("GameEvents: onUpgradesAccepted fired but JNI bridge isn't ready, dropping (%d slot(s))", count);
return;
}
bool didAttach = false;
JNIEnv* env = GetJNIEnvForCurrentThread(&didAttach);
if (!env) return;
jintArray jSlotIds = env->NewIntArray(count);
jintArray jCarMods = env->NewIntArray(count);
if (!jSlotIds || !jCarMods) {
Log("GameEvents: onUpgradesAccepted failed to allocate JNI int arrays");
env->ExceptionClear();
} else {
env->SetIntArrayRegion(jSlotIds, 0, count, slotIds);
env->SetIntArrayRegion(jCarMods, 0, count, carMods);
env->CallStaticVoidMethod(g_gameEventsClass, g_onUpgradesAcceptedMethod, jSlotIds, jCarMods);
if (env->ExceptionCheck()) {
Log("GameEvents: onUpgradesAccepted dispatch threw a Java exception");
env->ExceptionDescribe();
env->ExceptionClear();
} else {
Log("GameEvents: onUpgradesAccepted dispatched to Kotlin successfully (%d slot(s))", count);
}
}
if (jSlotIds) env->DeleteLocalRef(jSlotIds);
if (jCarMods) env->DeleteLocalRef(jCarMods);
if (didAttach) {
g_gameEventsJvm->DetachCurrentThread();
}
}
// cont.61/63b: fired once, right as car_select's own confirm checkmark
// fires "CONTINUE" (see Hook_FireOutputDiag in lan_event_injection.h).
// carId is the resource-id string read directly off the car-select state
// singleton (GetCurrentCarId), e.g. "ford_focus_rs500_2010_desc". colorName
// + colorR/G/B/A are resolved live from the game's own engine data (see
// GetCurrentCarColor in lan_event_injection.h -
// CarDescription::GetPaintJobDescription via `*(singleton+56)`) rather than
// a static extracted table, so this keeps working for any car
// added/modded into the game later, per the user's own explicit request.
inline void FireCarSelected(const char* carId, const char* colorName,
int colorR, int colorG, int colorB, int colorA) {
if (!g_gameEventsClass || !g_onCarSelectedMethod) {
Log("GameEvents: onCarSelected fired but JNI bridge isn't ready, dropping");
return;
}
bool didAttach = false;
JNIEnv* env = GetJNIEnvForCurrentThread(&didAttach);
if (!env) return;
jstring jCarId = env->NewStringUTF(carId ? carId : "");
jstring jColorName = env->NewStringUTF(colorName ? colorName : "");
if (!jCarId || !jColorName) {
Log("GameEvents: onCarSelected failed to allocate JNI string(s)");
env->ExceptionClear();
} else {
env->CallStaticVoidMethod(g_gameEventsClass, g_onCarSelectedMethod, jCarId, jColorName,
colorR, colorG, colorB, colorA);
if (env->ExceptionCheck()) {
Log("GameEvents: onCarSelected dispatch threw a Java exception");
env->ExceptionDescribe();
env->ExceptionClear();
} else {
Log("GameEvents: onCarSelected dispatched to Kotlin successfully "
"(carId=%s, color=%s RGBA=%d,%d,%d,%d)",
carId ? carId : "(null)", colorName ? colorName : "(null)",
colorR, colorG, colorB, colorA);
}
}
if (jCarId) env->DeleteLocalRef(jCarId);
if (jColorName) env->DeleteLocalRef(jColorName);
if (didAttach) {
g_gameEventsJvm->DetachCurrentThread();
}
}
File diff suppressed because it is too large Load Diff
+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();
}
@@ -0,0 +1,67 @@
package nfs.mod.mpcore
import android.os.Handler
import android.os.Looper
import android.util.Log
private const val TAG = "mpcore_kt"
// Proof-of-concept only, deliberately NOT UX-friendly: a fixed delay after
// the map screen loads, then a hardcoded replay of the exact tap sequence
// already verified live (map pin -> event card -> confirm race -> confirm
// car) to land on the native car-select/upgrade-loadout Flow screen, without
// needing FlowManager's internal "goto screen by name" API (still unknown -
// see PROGRESS.md cont.20/21).
//
// Coordinates are raw device pixels captured from one specific play session
// on one specific device/resolution/save state - they WILL be wrong on a
// different map camera position, screen size, or unlocked-event set. This
// only exists to validate that synthetic-touch injection can drive the
// game's own Flow navigation at all; a real implementation needs to resolve
// these positions at runtime (e.g. from the target MapTrack widget's actual
// screen-space bounds) instead of hardcoding them.
private object CarSelectLoadoutTestSequence {
const val ENABLED = false // temporarily off - checking if the injected pin is even visible on the map first
private const val DELAY_AFTER_MAP_LOAD_MS = 3000L
private const val DELAY_BETWEEN_TAPS_MS = 2000L
// (x, y) in device pixels, 2220x1080 landscape - Galaxy A9 test session.
private val TAP_SEQUENCE = listOf(
993f to 455f, // map pin (МАККЛЕЙН)
250f to 555f, // event card (ПОБУДКА)
1943f to 938f, // confirm race
1943f to 938f, // confirm car -> lands on car_select_loadout
)
fun run() {
val dispatcher = GameInput.dispatcher
if (dispatcher == null) {
Log.w(TAG, "CarSelectLoadoutTestSequence: no GameInput.dispatcher registered yet, aborting")
return
}
Log.i(TAG, "CarSelectLoadoutTestSequence: armed, first tap in ${DELAY_AFTER_MAP_LOAD_MS}ms")
val handler = Handler(Looper.getMainLooper())
TAP_SEQUENCE.forEachIndexed { index, (x, y) ->
handler.postDelayed({
Log.i(TAG, "CarSelectLoadoutTestSequence: tap #$index at ($x, $y)")
dispatcher.dispatchTap(x, y)
}, DELAY_AFTER_MAP_LOAD_MS + index * DELAY_BETWEEN_TAPS_MS)
}
}
}
/** Call once at startup (from [MultiplayerCore.loadCore]) to arm the test. */
fun installCarSelectLoadoutTestTrigger() {
if (!CarSelectLoadoutTestSequence.ENABLED) return
Log.i(TAG, "installCarSelectLoadoutTestTrigger: registering listener")
GameEvents.register(object : GameEventListener {
private var fired = false
override fun onMapLoaded() {
Log.i(TAG, "CarSelectLoadoutTestTrigger.onMapLoaded fired (already fired before = $fired)")
if (fired) return
fired = true
CarSelectLoadoutTestSequence.run()
}
})
}
@@ -0,0 +1,104 @@
package nfs.mod.mpcore
import android.util.Log
private const val TAG = "mpcore_kt"
/**
* Register on [GameEvents] to react to game lifecycle moments fired from the
* native side (see game_events.h). Every method has a default no-op body -
* override only what you need.
*/
interface GameEventListener {
fun onMapLoaded() {}
fun onRaceStarted() {}
fun onRaceEnded() {}
/**
* Fired once, right as the player's controlled exit from the car-upgrade
* loadout screen lands back on the map (see Hook_LayoutScreenCtor in
* lan_event_injection.h - the BACK x3 chain out of
* garage_select_rollout). [slotIds]/[carMods] are parallel arrays, one
* entry per upgrade slot the player touched this session; a carMod of 0
* means that slot was cleared back to "NONE". No lobby/network consumer
* exists yet - this is the hook point for whatever eventually needs to
* know which upgrades the player accepted.
*/
fun onUpgradesAccepted(slotIds: IntArray, carMods: IntArray) {}
/**
* Fired once, right as car_select's own confirm checkmark fires
* "CONTINUE" (see Hook_FireOutputDiag in lan_event_injection.h). [carId]
* is the car's resource-id string read directly off the car-select
* state singleton (e.g. "ford_focus_rs500_2010_desc") - see PROGRESS.md
* cont.61 for how this was found and verified. [colorName]/[colorR]/
* [colorG]/[colorB]/[colorA] are the currently-equipped paint job,
* resolved live from the game's own engine data (not a static extracted
* table, so it stays correct for any car added/modded later) - see
* PROGRESS.md cont.63b.
*/
fun onCarSelected(carId: String, colorName: String, colorR: Int, colorG: Int, colorB: Int, colorA: Int) {}
}
/**
* Global dispatch point for game-lifecycle events. Native code (mpcore's
* game_events.h) calls the `dispatch*` methods below via JNI whenever it
* observes the corresponding moment through one of its hooks; this object
* fans that out to whatever Kotlin-side listeners are registered.
*
* Not every event has a real native trigger wired up yet - see PROGRESS.md
* for which ones actually fire today (onMapLoaded, via the existing
* MapTrack::HandleEvent hook) versus which are scaffolded for later
* (onRaceStarted/onRaceEnded).
*/
object GameEvents {
private val listeners = mutableListOf<GameEventListener>()
@Synchronized
fun register(listener: GameEventListener) {
if (!listeners.contains(listener)) listeners.add(listener)
Log.i(TAG, "GameEvents.register: now ${listeners.size} listener(s)")
}
@Synchronized
fun unregister(listener: GameEventListener) {
listeners.remove(listener)
}
@Synchronized
private fun snapshot(): List<GameEventListener> = listeners.toList()
// Called from native via CallStaticVoidMethod - keep names/signatures in
// sync with game_events.h's cached jmethodIDs.
@JvmStatic
fun dispatchMapLoaded() {
val targets = snapshot()
Log.i(TAG, "GameEvents.dispatchMapLoaded: ${targets.size} listener(s)")
targets.forEach { it.onMapLoaded() }
}
@JvmStatic
fun dispatchRaceStarted() {
snapshot().forEach { it.onRaceStarted() }
}
@JvmStatic
fun dispatchRaceEnded() {
snapshot().forEach { it.onRaceEnded() }
}
@JvmStatic
fun dispatchUpgradesAccepted(slotIds: IntArray, carMods: IntArray) {
val targets = snapshot()
Log.i(TAG, "GameEvents.dispatchUpgradesAccepted: ${targets.size} listener(s), ${slotIds.size} slot(s)")
targets.forEach { it.onUpgradesAccepted(slotIds, carMods) }
}
@JvmStatic
fun dispatchCarSelected(carId: String, colorName: String, colorR: Int, colorG: Int, colorB: Int, colorA: Int) {
val targets = snapshot()
Log.i(TAG, "GameEvents.dispatchCarSelected: ${targets.size} listener(s), carId=$carId, " +
"color=$colorName RGBA=($colorR,$colorG,$colorB,$colorA)")
targets.forEach { it.onCarSelected(carId, colorName, colorR, colorG, colorB, colorA) }
}
}
@@ -0,0 +1,15 @@
package nfs.mod.mpcore
/**
* Implemented by the app module's Activity (GameActivityMain) so mpcore can
* synthesize touch input into the game's own view without depending on the
* `app` module by type - mpcore is a library `app` depends on, not the
* other way around, so a direct reference back would be circular.
*/
interface SyntheticInputDispatcher {
fun dispatchTap(x: Float, y: Float)
}
object GameInput {
var dispatcher: SyntheticInputDispatcher? = null
}
@@ -4,6 +4,27 @@ object MultiplayerCore {
external fun bumpBackTraceToLogcat()
fun loadCore() = System.loadLibrary("mpcore")
/**
* Fires the same real "open car_select" call the game's own map-pin
* tap uses (see TriggerOpenCarSelectOnDemand in
* lan_event_injection.h), but on demand instead of only at boot -
* eventually meant to be wired to a real lobby-overlay "select car"
* button. See cont.44 in PROGRESS.md.
*/
external fun triggerCarSelectTest()
/**
* EXPERIMENTAL (cont.48): true one-hop jump straight to car_select,
* bypassing EventDetails entirely, by temporarily redirecting the
* shared FlowNode to event_detail's own already-captured real Outputs
* tree before firing "EVENT" from the map. See
* TriggerTrueDirectCarSelectJump in lan_event_injection.h.
*/
external fun triggerTrueDirectCarSelectJump()
fun loadCore() {
System.loadLibrary("mpcore")
installCarSelectLoadoutTestTrigger()
}
}