diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f59bde2..e746d33 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,11 +8,14 @@ android { compileSdk = 34 defaultConfig { - applicationId = "com.ea.games.nfs13_na" + applicationId = "com.ea.games.nfs13_mod" minSdk = 21 targetSdk = 34 - versionCode = 1 - versionName = "1.0" + // Must match the OBB expansion file's version code + // (/sdcard/Android/obb/com.ea.games.nfs13_mod/main.1003128.com.ea.games.nfs13_mod.obb) + // for the game's OBB lookup (main...obb) to find it. + versionCode = 1003128 + versionName = "1.3.128" buildConfigField( "String", "DEV_MENU_VERSION", "\"0.1\"") buildConfigField( "String", "DEV_MENU_ID", "\"DevMenu\"") diff --git a/devmenu/domain/build.gradle.kts b/devmenu/domain/build.gradle.kts index c71b0ef..8a7e95e 100644 --- a/devmenu/domain/build.gradle.kts +++ b/devmenu/domain/build.gradle.kts @@ -8,6 +8,12 @@ java { targetCompatibility = JavaVersion.VERSION_17 } +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = "17" + } +} + dependencies { implementation("javax.inject:javax.inject:1") testImplementation("junit:junit:4.13.2") diff --git a/mpcore/src/main/cpp/main.cpp b/mpcore/src/main/cpp/main.cpp index d581ada..27bfcd1 100644 --- a/mpcore/src/main/cpp/main.cpp +++ b/mpcore/src/main/cpp/main.cpp @@ -27,6 +27,136 @@ void pending_thread(){ } +// === EXPERIMENT: call TrackTestLayer::TrackTestLayer(path) directly === +// See ANALYSIS.md §6d/6e for the reverse-engineering behind this. TrackTestLayer +// is a real, functional EA QA tool: its constructor loads an arbitrary track +// scene via the engine's generic scene loader, finds "start"/"finish" nodes, +// and spawns 8 reference cars bound to TrackNavigator - entirely bypassing +// RaceLoaderTask/Flow. We never found a legitimate, externally-triggerable path +// to it (the debug mode-string source is untraceable in shipped assets - see +// §6e), so this calls the constructor directly instead. +// +// libapp.so's internal "string" objects are a 3-word {begin,end,capacity} +// vector-like struct (no small-string optimization), built via the +// sub_CF5F8 helper seen throughout the binary. +// +// CORRECTED after a live crash (see PROGRESS.md 2026-07-31): sub_CF5F8's real +// signature is `sub_CF5F8(dest, rangeBegin, rangeEnd)` - a [begin,end) range +// constructor (length = rangeEnd - rangeBegin, then memmove), NOT an implicit- +// strlen "assign from C-string" helper. Every caller seen during static +// analysis manually scanned for the string's end pointer first before calling +// it; first attempt here only passed 2 args, leaving the 3rd register (end) +// as garbage, which produced a bogus memmove length and SIGSEGV'd inside +// sub_CF5F8 itself (confirmed via the crash's PC landing at sub_CF5F8+0x68). +typedef void *(*StrAssignRange_t)(void *strObj, const char *rangeBegin, const char *rangeEnd); +typedef void *(*TrackTestLayerCtor_t)(void *self, void *pathStrObj); + +void try_load_track_test_layer(){ + + // mpcore's JNI_OnLoad fires from GameActivity's Kotlin `init {}` block, + // i.e. before onCreate()/nativeOnCreate() - long before the engine's own + // resource manager and main loop are up. Give it time to boot first; + // this is a guess, not a traced signal - if this crashes or does nothing, + // that's the first thing to revisit. + Log("TrackTestLayer experiment: waiting 15s for engine boot..."); + std::this_thread::sleep_for(std::chrono::seconds(15)); + + auto StrAssignRange = (StrAssignRange_t) APP_ADDR(0xcf5f8); + auto TrackTestLayerCtor = (TrackTestLayerCtor_t) APP_ADDR(0x24a1f4); + + uint32_t pathStrObj[3] = {0, 0, 0}; + // region4_chicago_track4 is referenced by TrackName in event_01_race.prefabs.sb + // (a real, shipped race event) and confirmed (via unpacking the .scene.sb with + // NFSMW12MobileTools) to contain top-level "actor" entities literally named + // "start" and "finish" - region1_foothills_track3 (first attempt) crashed on + // this exact lookup despite also containing "start"/"finish" actors somewhere, + // so nesting depth (not mere presence) may be what matters. See ANALYSIS.md §6f. + const char *trackPath = "published/prefabs/tracks/region4_chicago_track4.scene.sb"; + StrAssignRange(pathStrObj, trackPath, trackPath + strlen(trackPath)); + + void *obj = malloc(0x110); + memset(obj, 0, 0x110); + + Log("TrackTestLayer experiment: calling ctor @ 0x%08x obj=%p path=%s", + (unsigned) APP_ADDR(0x24a1f4), obj, trackPath); + + TrackTestLayerCtor(obj, pathStrObj); + + Log("TrackTestLayer experiment: ctor returned without crashing, obj=%p " + "(intentionally leaked for this test - not registered with any layer " + "manager, so it may not render even if construction succeeded)", obj); +} + +// === ATTEMPT 2: hook RaceLoaderTask::BuildTrackScenePath instead === +// See ANALYSIS.md §6f for why: TrackTestLayer's start/finish lookup crashes +// even on a confirmed race-linked track with the right data present, which +// points to a dependency on async load completion or ambient engine state a +// cold direct call can't replicate. Hooking the engine's own always-correctly- +// initialized code path (this function, called every time a real race loads) +// sidesteps that entirely - we let the player pick any real race from the +// menu and just substitute the track name at the last moment. +// +// IMPORTANT: unlike the Thumb-oriented code armhook.cpp's InstallHook/makeJMP +// assume, RaceLoaderTask_BuildTrackScenePath (0x2db384) is compiled in ARM +// mode (confirmed via disassembly: E92D41F0 = ARM "PUSH {r4-r8,lr}", not a +// Thumb encoding). Using the existing Thumb-oriented hook helpers here would +// misinterpret/corrupt the function. This installs a separate, ARM-mode- +// correct inline hook instead: overwrite the first 8 bytes (2 whole ARM +// instructions - safe, ARM instructions are always 4 bytes wide, so there's +// no mid-instruction split risk the way there is in mixed-width Thumb-2) with +// `LDR PC, [PC, #-4]` + hook-function-address. A trampoline re-executes the 2 +// displaced instructions (both position-independent - PUSH/SUB, no PC-relative +// addressing - confirmed safe to relocate) then long-jumps back to target+8 +// to resume the original function. The hook address's bit 0 is set +// automatically by the compiler (mpcore is Thumb-2 code) for correct +// ARM->Thumb interworking on the `LDR PC` jump into our hook; the trampoline's +// jump back to target+8 has bit 0 clear, staying in ARM mode as required. + +typedef int (*BuildTrackScenePath_t)(uint32_t *raceLoaderTaskThis); + +static BuildTrackScenePath_t g_origBuildTrackScenePath = nullptr; + +static uint32_t *install_arm_inline_hook(uintptr_t targetAddr, void *hookFnPtr) { + auto *tramp = (uint32_t *) mmap(nullptr, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + auto *target = (uint32_t *) targetAddr; + + tramp[0] = target[0]; // relocated original instr 0 (PUSH, position-independent) + tramp[1] = target[1]; // relocated original instr 1 (SUB SP, position-independent) + tramp[2] = 0xE51FF004; // LDR PC, [PC, #-4] + tramp[3] = (uint32_t) (targetAddr + 8); // jump back into original, ARM mode (bit0=0) + + unProtect(targetAddr, 8); + target[0] = 0xE51FF004; // LDR PC, [PC, #-4] + target[1] = (uint32_t) hookFnPtr; // our Thumb hook (bit0=1, set by the compiler) + + __builtin___clear_cache((char *) target, (char *) (target + 2)); + __builtin___clear_cache((char *) tramp, (char *) (tramp + 4)); + + return tramp; +} + +int hook_BuildTrackScenePath(uint32_t *raceLoaderTaskThis){ + Log("hook_BuildTrackScenePath: fired, this=%p", raceLoaderTaskThis); + + uint32_t raceDefPtr = raceLoaderTaskThis[8]; + if (raceDefPtr) { + auto StrAssignRange = (StrAssignRange_t) APP_ADDR(0xcf5f8); + // Deliberately different from any of this build's real tracks, so a + // successful override is visually unmistakable regardless of which + // race the player actually picked in the menu. + const char *overrideTrack = "region3_colorado_track2"; + void *trackNameStrObj = (void *) (raceDefPtr + 72); + StrAssignRange(trackNameStrObj, overrideTrack, overrideTrack + strlen(overrideTrack)); + Log("hook_BuildTrackScenePath: raceDefPtr=0x%08x, overrode track name to '%s'", + raceDefPtr, overrideTrack); + } else { + Log("hook_BuildTrackScenePath: raceDefPtr is null, skipping override"); + } + + return g_origBuildTrackScenePath(raceLoaderTaskThis); +} + jint JNI_OnLoad(JavaVM *vm, void *reserved){ @@ -39,7 +169,24 @@ jint JNI_OnLoad(JavaVM *vm, void *reserved){ //*(int *)APP_ADDR(0x00A522C4) = 0xE320F000; //WriteMemory(APP_ADDR(0x00E4BA94), (uintptr_t)"my_tag", 6); - raise(SIGSTOP); + + // TrackTestLayer experiment (2026-07-31): superseded by the + // RaceLoaderTask_BuildTrackScenePath hook below - see ANALYSIS.md §6f. + // std::thread(try_load_track_test_layer).detach(); + + // Installing the ARM inline hook is just a code patch - safe at any time, + // unlike calling into engine internals cold. No delay needed; the hook + // only fires later, whenever the player naturally starts a real race. + g_origBuildTrackScenePath = (BuildTrackScenePath_t) + install_arm_inline_hook(APP_ADDR(0x2db384), (void *) hook_BuildTrackScenePath); + Log("Installed RaceLoaderTask_BuildTrackScenePath hook, orig trampoline=%p", + (void *) g_origBuildTrackScenePath); + + // raise(SIGSTOP) disabled for this experiment (2026-07-31): SIGSTOP + // suspends the entire process, including the thread spawned above, which + // would prevent the delayed call from ever firing. Re-enable if you need + // to attach a debugger before continuing normal boot - see PROGRESS.md. + //raise(SIGSTOP); return JNI_VERSION_1_6; }