These files had never been tracked anywhere - they lived in a plain directory with no git at all, which is also where the whole reverse-engineering record sat. Code already committed refers to them by name (opponent_substitution.h cites "ANALYSIS.md section 6hh", DebugMenuOverlay.kt cites "DEBUG_MENU.md section 3"), so until now a fresh clone carried references to documents it did not contain. ANALYSIS.md the RE record, and the reason the rest works ARCHITECTURE.md how the mod's pieces fit together ARM64_TRANSLATION_LAYER.md the translation layer's running log PROGRESS.md chronological progress across both chats BETA_TELEMETRY_PLAN.md how crash/telemetry reporting is meant to work LOBBY_UI_DESIGN.md + .html lobby design and its clickable prototype DEBUG_MENU.md debug panel design STATIC_RECOMPILATION_FALLBACK.md the plan if translation had not panned out evidence/ font atlas capture from the glyph-corruption bug save_backups/ saves at known milestones, for reproducing state Co-Authored-By: Claude <noreply@anthropic.com>
461 KiB
PROGRESS.md — Changelog of binary/code modifications
Every entry: what changed, why, which snapshot/backup it corresponds to. No binary patches have been applied yet — this file will gain entries once we move past planning into subtask 1 (arbitrary track loading).
Mandatory rule (see ARCHITECTURE.md §6 for full reasoning): native_lib/libapp.so (and siblings) is the pristine reference copy and is not under version control — it must never be overwritten in place. Any patched binary is written as a new file under native_lib/patched/, logged here, then copied into launcher/app/src/main/jniLibs/armeabi-v7a/ (which is git-tracked inside the launcher repo) for the actual APK build. Runtime hooks installed by mpcore (the common case — no .so file on disk is touched, only memory at process start) must be logged here too, just as rigorously as on-disk patches, since a bad hook is just as capable of breaking singleplayer.
Format for future entries:
## YYYY-MM-DD — <short title>
- **What**: <exact change — address/offset, file, function>
- **Why**: <goal it serves, link to ANALYSIS.md/ARCHITECTURE.md section>
- **Snapshot**: <path/tag of pre-change backup>
- **Verification**: <how it was tested>
2026-07-30 — Session start: context reconstruction, no binary changes
- What: No binary or source modifications performed. This session was documentation/analysis-only:
- Reconstructed structured context from
chat-export-1785094024785.jsonintoANALYSIS.md. - Opened
native_lib/libapp.so.i64via IDA MCP (idalib), surveyed segments/imports/entrypoints, confirmed RTTI presence of key Iron Monkey engine classes (RaceLoaderTask,OpponentCollection,TrackNavigator, etc.) at addresses different from the prior chat's (unrelated binary copy). - Reviewed existing
launcher/mpcoredraft (main.cpp,armhook.cpp,NativeLib.kt) and cross-verified one of its hardcoded addresses (0xC8C9D8→"APPLICATION_OK") against our actual.i64, confirming the draft was built against this exact binary. - Wrote
ARCHITECTURE.mdwith a preliminary lobby-UI recommendation (option (a), overlay Android View) based on the finding that in-game text/UI is rendered natively via GLES/EAMText, not via a JNI Canvas bridge.
- Reconstructed structured context from
- Why: Per task instructions, no reverse engineering of specific subsystems or binary patching should start before the subtask plan is reviewed and agreed with the user.
- Snapshot: N/A (no changes made).
- Verification: N/A.
2026-07-31 — IDA annotation only: named the BitmapGraphics JNI bridge functions
- What: Renamed 7 functions in
native_lib/libapp.so.i64(analysis database only — not a runtime binary patch,libapp.soitself is untouched):sub_5640EC→BitmapGraphics_ctor_jni(0x5640ec)sub_5625A0→BitmapGraphics_drawString_jni(0x5625a0)sub_56274C→BitmapGraphics_drawString_thunk(0x56274c)sub_562A58→BitmapGraphics_createPaintFromFamilyName_jni(0x562a58)sub_562B94→BitmapGraphics_createPaintFromFile_jni(0x562b94)sub_563D30→BitmapGraphics_blitBitmapToAtlasTexture(0x563d30)sub_51145C→ResolveFontPaint_ttfOtfOrFamily(0x51145c) Also added two explanatory comments (at0x563d30and0x5625a0) describing the native↔Java text/atlas pipeline. Saved viaidb_save.
- Why: User corrected an earlier (wrong) claim in
ANALYSIS.md§3.3 that text rendering had no JNI Canvas bridge. Traced and confirmed the real mechanism (com.ea.ironmonkey.BitmapGraphics— seelauncher/app/src/main/java/com/ea/ironmonkey/BitmapGraphics.kt) against the actual.i64, and named the functions so future sessions don't have to re-derive this. Directly informs the lobby-UI marker-drawing approach inARCHITECTURE.md§4. - Snapshot: Not taken — this is a reversible, non-destructive IDA metadata change (renames/comments only) to an analysis database, not a patch to the shipped binary or source tree. No backup policy needed for this class of change; backups are for actual binary patches (subtask 1+ onward).
- Verification: Re-decompiled each renamed address after the rename via
decompile/read-back is implicit in therenametool's success response (7/7ok);idb_saveconfirmed write tonative_lib/libapp.so.i64.
2026-07-31 — IDA annotation only: located and named RaceLoaderTask's real vtable (subtask 1 start)
- What: Located
RaceLoaderTask's actual vtable at0xd86210(19 slots) by walking its RTTI chain (name string →type_infostruct →find_bytesfor who embeds thattype_infoaddress). Renamed 8 functions innative_lib/libapp.so.i64:sub_2D8E04→RaceLoaderTask_dtor_complete(0x2d8e04)sub_2D8F18→RaceLoaderTask_dtor_deleting(0x2d8f18)sub_2DBBA4→RaceLoaderTask_ExecuteLoadSequence(0x2dbba4) — main load orchestratorsub_2DB160→RaceLoaderTask_ResolveDriverPositionComponent(0x2db160)sub_2D9D10→RaceLoaderTask_SetupPlayerCar(0x2d9d10)sub_2DAE0C→RaceLoaderTask_SetupOpponentCar(0x2dae0c)sub_2DA2A0→RaceLoaderTask_ResetStartingLine(0x2da2a0)sub_2DA880→RaceLoaderTask_HandleSpikeStrip(0x2da880) Added a full vtable-layout comment at0xd86210and a detailed comment at0x2dbba4. Saved viaidb_save. Full table with evidence for every slot (including unconfirmed ones, left un-renamed) is inANALYSIS.md§6a.
- Why: First concrete step of subtask 1 (arbitrary track loading) — needed real addresses in our binary before any hook can be written; the previous chat's addresses (see §1 provenance note) don't apply here at all.
- Snapshot: Not taken — same reasoning as the previous entry (IDA metadata only, no binary/source touched).
- Verification:
renametool reported 8/8ok; role assignments backed by decompiled evidence (destructor pattern, literal progress-fraction floats, string refs"playerCar"/"opponentCar"/"ResetLine"/"Spike Strip", anddynamic_castRTTI arguments) rather than guesswork — see ANALYSIS.md §6a for the evidence behind each one. Slots without solid evidence were deliberately left unrenamed rather than guessed.
2026-07-31 — IDA annotation only: decoded all 8 ExecuteLoadSequence stage functions, found subtask-1 hook point
- What: Decompiled all 8 stage functions called from
RaceLoaderTask_ExecuteLoadSequence. Renamed 7 innative_lib/libapp.so.i64(2 left un-renamed, evidence too thin):sub_2DB384→RaceLoaderTask_BuildTrackScenePath(0x2db384) — the subtask-1 hook point, builds the track prefab path from a single string fieldsub_2DBF58→GenericLoadScene_trackEnvWrapper(0x2dbf58)sub_2DA710→RaceLoaderTask_SetupAIDifficultyProfiles(0x2da710)sub_2DAA7C→RaceLoaderTask_TagPlayerEntity(0x2daa7c)sub_2D994C→RaceLoaderTask_RegisterTrafficFlow(0x2d994c)sub_2DB534→RaceLoaderTask_LoadRaceFSM(0x2db534) — loads the race rules/state-machine prefab, resolves theim::app::race::Racecomponent, confirmsim::app::car::Healthexists in this binarysub_2D969C→RaceLoaderTask_DispatchInitialFSMEvents(0x2d969c) Added detailed comments on all 4 most important ones. Saved viaidb_save. Full breakdown with per-stage evidence inANALYSIS.md§6b.
- Why: Direct continuation of subtask 1 — needed to find exactly which sub-step of the load sequence resolves the track path, rather than hooking the whole orchestrator.
- Snapshot: Not taken — IDA metadata only.
- Verification: Track-path construction is unambiguous (literal path-prefix strings
"published/prefabs/tracks/"/"published/prefabs/environments/"plus a.scene.sb/.prefabs.sbsuffix, directly matching thegame_cache/published/prefabs/tracks/and.../environments/directories already on disk). Race-FSM loading similarly confirmed via the literal"/published/prefabs/racefsms/{0}.prefabs.sb"string matchinggame_cache/published/prefabs/racefsms/.
2026-07-31 — IDA annotation only: identified TrackTestLayer, EA's own QA track-testing tool
- What: Investigated the "SceneLoader" question and the
0x24a1f4hot lead from the previous session. Confirmed a real, literally-named classTrackTestLayerexists and is fully functional in this binary. Renamed 5 functions innative_lib/libapp.so.i64:sub_24A1F4→TrackTestLayer_ctor(0x24a1f4) — constructor; 2nd arg is the scene/track path to load; loads it via the generic scene loader (sub_33C284), finds start/finish nodes, spawns 8 hardcoded reference cars bound toTrackNavigator, logs telemetry to CSVsub_24AEC4→TrackTestLayer_factory_wrapper(0x24aec4)sub_243160→TrackTestLayer_QABatchRunner_tick(0x243160) — auto-cycles through up to 15 tracks from a runtime tablesub_2217EC→DebugTestHarness_DispatchByName(0x2217ec) — dispatches a mode-name string ("Track/...","Performance/...","CarPreview/...", etc., default"MainMenu") to construct the matching test layersub_CF904→ResourceDirListeners_Init_maybeCallsDebugHarness(0xcf904) — the one caller of the dispatcher found so far, itself called once at startup Added detailed comments on the 3 most important ones. Saved viaidb_save. Full writeup inANALYSIS.md§6d.
- Why: User recalled a "SceneLoader" and asked for a theory; while confirming no such literal class exists (§6c), traced into
TrackTestLayer(previously flagged as a "hot lead") per explicit follow-up request. This is now the leading candidate approach for subtask 1 (and partially 2/4) — a pre-built, EA-tested path-to-scene loader with working start/finish detection and car-to-TrackNavigator binding, bypassingRaceLoaderTaskentirely. - Snapshot: Not taken — IDA metadata only.
- Verification: Class identity confirmed via the literal tag string
"TrackTestLayer"written into the object right before its vtable pointer is set (standard constructor pattern, same technique used to confirmBitmapGraphics/RaceLoaderTaskearlier). Car IDs and category-prefix strings are literal, unambiguous string references in the decompiled code. Not yet traced: where themodeNamestring passed toDebugTestHarness_DispatchByNameactually originates at runtime — flagged as the next concrete step inANALYSIS.md§6d.
2026-07-31 — IDA annotation only: traced the debug mode-string source to a dead end; bonus discovery of an editable debug-menu SB file
- What: Traced
ResourceDirListeners_Init_maybeCallsDebugHarness(0xcf904) fully — it reads the mode string from*(a1+292)of a separately-resolved runtime object (not identified) and passes it toDebugTestHarness_DispatchByName. Found its one caller, the app bootstrap functionsub_DE180, renamed toAppBootstrap_flowInitOrStartupRaceCheck(0xde180), which also independently checks a config key"flow"against literal"STARTUP_RACE". Unpacked and searched all 8 files ingame_cache/published/tweaks/(debug_options.sb,tweaks.sb,race_tweaks.sb,track_performance_tweaks.sb,tweaks_ipad.sb,traffic_tweaks.sb,car_preview_tweaks.sb,lod_tweaks.sb) viaNFSMW12MobileTools, plus the reverse-engineered launcher Java sources — neither the+292field's value nor the"flow"/"STARTUP_RACE"key/value pair appear anywhere in shipped assets. Added comments explaining the trace and conclusion. Saved viaidb_save. Full writeup inANALYSIS.md§6e.- Bonus:
debug_options.sbturned out to be a full, directly-editable in-game debug-menu tree (unpack/edit-JSON/repack viaNFSMW12MobileTools, zero binary patching) with entries directly useful to the mod (AI/Race/Max Num AI Opponents,Everything is Available,Infinite Player Nitro, etc.) — noted for later use, not yet tested on-device.
- Bonus:
- Why: Direct follow-up to the previous session's
TrackTestLayerfinding — user asked specifically where the debug-mode string comes from, to determine if it's an available trigger mechanism. - Snapshot: Not taken — IDA metadata only; the
sb_unpackscratch files (unpacked JSON) live under the session scratchpad, not the project tree, and were not modified/repacked. - Verification: Absence of the search terms was checked with plain-text
grepacross all 8 unpacked JSON files and the relevant Java source tree — a conclusive negative result, not a guess. Conclusion (EA-internal build-only switch) is an inference from that absence, clearly labeled as such inANALYSIS.md§6e, alongside the recommended workaround (callTrackTestLayer_ctordirectly instead of trying to trigger the dispatcher).
2026-07-31 — First live test of TrackTestLayer_ctor from mpcore (two crashes, both understood; code left in a known-crashing state)
- What: Modified
launcher/mpcore/src/main/cpp/main.cpp: addedtry_load_track_test_layer(), spawned fromJNI_OnLoadvia a detachedstd::thread(15s delay), callingTrackTestLayer_ctor(0x24a1f4) directly with a manually-constructed path-string object forpublished/prefabs/tracks/region1_foothills_track3.scene.sb. Commented out the pre-existingraise(SIGSTOP)inJNI_OnLoad(it suspends the whole process, including the new thread — noted inline why, easily reverted). Also fixed an unrelated, pre-existing build break inlauncher/devmenu/domain/build.gradle.kts(Kotlin/Java JVM-target mismatch, JDK 21 vs explicitsourceCompatibility 17) by pinningcompileKotlin'sjvmTargetto 17 — required to get any variant of the app to build at all in this environment; unrelated to the mod logic itself. Built via:app:assembleNo_devmenuDebug(JDK 17 from~/.jdks/corretto-17.0.14, explicit-Dorg.gradle.java.home, since only JDK 21/8 are onPATHand AGP 8.1.2 requires 17). Installed and ran on the already-set-up WayDroid instance (com.ea.games.nfs13_na, a separate app from the already-installedcom.ea.games.nfs13_mod— applicationId not changed, per the still-open question from the previous session).- Crash 1:
sub_CF5F8called with an assumed 2-arg "C-string assign" signature; its real signature is a 3-arg[begin,end)range constructor. Fixed by passingtrackPath + strlen(trackPath)as the third argument. SeeANALYSIS.md§6f for full detail, confirmed via decompilingsub_CF5F8after the crash pinpointed it (tombstone frame#00 pc sub_CF5F8+0x68). - Crash 2 (after the fix): construction proceeds correctly through scene load and car-catalog setup, then crashes in the "start"/"finish" named-node lookup (
sub_695A10→sub_FDA64, no null-check for a missing node). Root cause: the test track doesn't have a top-level node named exactly"start"(has it as a nested path segment and as capitalized"Name": "Start"instead). SeeANALYSIS.md§6f.
- Crash 1:
- Why: Direct, explicit user request to try this ("Попробуй вызвать TrackTestLayer через хук в mpcore") — validating whether the reverse-engineered
TrackTestLayerconstructor (§6d) can actually be driven frommpcoreas designed for subtask 1. - Snapshot: Not applicable to
native_lib/libapp.so(untouched, no binary patch — this is a runtime call frommpcore, not an on-disk modification).mpcore's own source changes are tracked in thelaunchergit repo as usual. - Verification: Verified via live on-device testing, not static analysis alone —
adb logcatconfirmed each stage's log line firing, andtombstone/DEBUGcrash logs plus IDAlookup_funcson the crash PCs confirmed the exact crashing function both times. Current state: the code inmain.cppas committed will crash on launch (still targets a track without confirmed start/finish markers) — this is intentional/expected for now, not a regression to fix blindly; next step is picking a track actually used by a real race event (cross-referencegame_cache/published/data/races/*.sb) before re-testing, perANALYSIS.md§6f.
2026-07-31 — Third live test: race-linked track, identical crash — ruled out "wrong track", theory revised
- What: Cross-referenced
game_cache/published/data/races/event_01_race.prefabs.sbfor a realTrackName(region4_chicago_track4). Unpacked that track's.scene.sbviaNFSMW12MobileToolsand confirmed"start"/"finish"actors exist and are listed directly in the scene's top-level"actors"DataIdsMap(not nested). Updatedlauncher/mpcore/src/main/cpp/main.cpp'strackPathto this track, rebuilt, reinstalled, re-ran on WayDroid.- Result: identical crash (
sub_FDA64+0x18, same as the previous attempt on an arbitrary track) despite the data being confirmed present and correctly structured. This rules out "picked a track without start/finish markers" as the cause. - Revised theory (not confirmed): either (a) scene loading via
sub_33C284is asynchronous andTrackTestLayer's normal usage polls across frames before the lookup, which a single direct constructor call can't replicate, or (b)TrackTestLayerdepends on ambient global engine state (e.g. a "current world" singleton) set up by whatever legitimate code path constructs it, which we never found (§6e) and can't replicate. SeeANALYSIS.md§6f for full detail and the resulting recommendation.
- Result: identical crash (
- Why: Direct follow-up to the user's request to check whether a race-linked track resolves the earlier crash.
- Snapshot: N/A — no binary patch,
mpcoresource changes tracked in thelaunchergit repo. - Verification: Live on-device (WayDroid), same method as the previous two attempts (
adb logcat+ tombstone + IDAlookup_funcson the crash PC) — crash address matched exactly (0xfda7c/0xfda74) between this and the prior attempt, a clean, reproducible negative result. - Recommendation going forward (see
ANALYSIS.md§6f): stop iterating blindly onTrackTestLayer— either invest in tracing scene-load-completion state properly, or pivot subtask 1 to hookingRaceLoaderTask_BuildTrackScenePath(§6b) instead, the engine's own always-correctly-initialized code path. Left as an open decision for the user rather than unilaterally choosing.
2026-07-31 — Pivoted to hooking RaceLoaderTask_BuildTrackScenePath (user chose option (b))
- What: Modified
launcher/mpcore/src/main/cpp/main.cpp: disabled theTrackTestLayerthread-spawn experiment (commented out, not deleted). Added a new ARM-mode inline hook (install_arm_inline_hook,hook_BuildTrackScenePath) targetingRaceLoaderTask_BuildTrackScenePath(0x2db384), installed unconditionally inJNI_OnLoad. The hook overwrites theRaceDefinition's track-name string field (this[8] + 72, a 3-word{begin,end,capacity}object, same layout assub_CF5F8's strings) with a hardcoded override (region3_colorado_track2) before calling through to the original function via a trampoline. This does not reusearmhook.cpp's existingInstallHook— disassembly showed the target function is compiled in ARM mode (E92D41F0/E24DD040, classic 32-bit ARM encodings), whilearmhook.cpp's hook helpers are hardcoded for Thumb targets and would have corrupted this function. Wrote a separate, ARM-mode-correct 8-byteLDR PC,[PC,#-4]detour + trampoline instead (full technical detail inANALYSIS.md§6g). Built via:app:assembleNo_devmenuDebug(same JDK 17 pin as before), installed on WayDroid (com.ea.games.nfs13_na).- Result so far: hook installs cleanly, confirmed via
mpcore_log. App continued running and actively working (growing RSS) for 2+ minutes afterward with no crash — good evidence the ARM patch itself is well-formed. However, the hook was never actually exercised: the game never reached its main menu (screen stayed black indefinitely) because the WayDroid container has no default network route (ip routeshows only the local/24subnet, confirmed by 100% packet loss pinging8.8.8.8), and the boot log showsNIM_ERROR: No network connectionduring EA's online-services init — very likely blocking progress to the menu. Fixing this needs root inside the WayDroid container, which requires the user'ssudopassword (not available in this session, and not something to prompt for).
- Result so far: hook installs cleanly, confirmed via
- Why: Direct continuation of subtask 1 per the user's explicit choice of approach (b) over (a) (fix
TrackTestLayer's async/state dependency) after three straightTrackTestLayercrashes. - Snapshot: N/A — no
native_lib/libapp.sopatch (all changes are runtime, viampcore);mpcoresource changes tracked in thelaunchergit repo as usual. - Verification: Verified the ARM-vs-Thumb mode distinction directly via IDA disassembly (not assumed) before writing any hook code — this is the single most important correctness check for this kind of inline hook and was confirmed before, not after, implementation. Hook-install success and post-install stability verified live via
adb logcat+ repeatedps/screenshot polling over 2+ minutes. Not yet verified: whether the hook actually fires and correctly substitutes the track (blocked on the WayDroid networking issue above) — this is the concrete next step once network access is restored, not a known-working conclusion yet.
2026-07-31 — Switched to com.ea.games.nfs13_mod; corrected an unverified claim about OBB versionCode lookup
- What: The "no network" black-screen theory above was wrong — user identified the real cause: WayDroid's game cache exists only under
com.ea.games.nfs13_mod(a separate, already-installed app,versionName 1.3.128, with/sdcard/Android/obb/com.ea.games.nfs13_mod/main.1003128.com.ea.games.nfs13_mod.obbpresent), not under our build'scom.ea.games.nfs13_na. Changedlauncher/app/build.gradle.kts:applicationId→com.ea.games.nfs13_mod(keptnamespaceascom.ea.games.nfs13_nasince source still importscom.ea.games.nfs13_na.BuildConfig). First install attempt failed withINSTALL_FAILED_VERSION_DOWNGRADE(ourversionCode=1vs. the real game's1003128, a confirmed OS-level fact about Android's installer). Fixed by settingversionCode = 1003128,versionName = "1.3.128"to match —adb install -rthen succeeded without-d.- Correction: the comment originally added alongside the versionCode change claimed the game's own code looks up its OBB file via
main.<versionCode>.<applicationId>.obb. User asked for evidence. Checked properly:grep -i obbacross the entirelaunchersource tree (Java/Kotlin/XML) → zero matches;find_regexforaddAssetPath|mountObb|StorageManager|\.obbinlibapp.so→ zero matches (already run earlier this session, just not connected to this claim until asked). No evidence this game's code does OBB-based lookup at all — the comment was corrected inbuild.gradle.ktsto state only the confirmed fact (avoids the installer downgrade block) and flag the OBB theory as unverified. Full detail inANALYSIS.md§6h. - Rebuilt/reinstalled/relaunched under the corrected package+versionCode: hook still installs cleanly (
mpcore_logconfirmed), but the screen is still black after oneonDrawFramecall (same as before the package switch) — i.e. the versionCode fix, as expected given it's now understood to be unrelated to asset loading, did not change the outcome.
- Correction: the comment originally added alongside the versionCode change claimed the game's own code looks up its OBB file via
- Why: Correcting a wrong diagnosis (network) with the user's correct one (package/cache mismatch), then being properly rigorous when the user challenged an unverified claim in my own code comment rather than accepting it at face value.
- Snapshot: N/A — no
native_lib/libapp.sopatch;build.gradle.kts/main.cppchanges tracked in thelaunchergit repo. - Verification:
INSTALL_FAILED_VERSION_DOWNGRADE→ success after versionCode match is directly observedadb installbehavior, not inferred. The OBB-lookup claim's correction is a genuinegrep/find_regexnegative result across the full source tree and the native binary, not a guess. Still open: howgame_cache/published/...is actually located at runtime (candidates:AAssetManagerreading the APK's own bundledassets/, or loose external-storage files viafopenfrom an untraced root path) — not resolved this session. Resolved empirically later the same day: see the 2026-08-01 entry below — the user's correctedlauncher/native_libcombination mounts the OBB successfully (Mounting SKU: ... to /publishedin logcat) and the actual EA splash screen renders on WayDroid, confirming cache loading now works end-to-end.
2026-08-01 — Discovered the IDA database was stale (silently out of sync with the replaced binary); rebuilt fresh, re-verified
- What: The user swapped in corrected versions of
native_lib/and thelauncher/project (now at/home/megboyzz/AndroidStudioProjects/NFSMostWanted128) partway through the previous session, butnative_lib/libapp.so.i64was never rebuilt. It kept opening without error and even reportedsurvey_binaryhash metadata matching the new on-disk file, which looked like proof it was current. Direct verification proved otherwise: read raw bytes straight fromnative_lib/libapp.soat offset0x2db384via plain Python file I/O (bypassing IDA) and compared against the still-open.i64's disassembly at that address — they were completely different instructions (ADD R0,SP,#0x74/STR/MOV/BLin the real file vs. thePUSH {R4-R8,LR}/SUB SP,#0x40RaceLoaderTask_BuildTrackScenePathprologue the stale.i64showed). This conclusively proved the open database was analyzing cached/stale content despite reporting a matching hash.- Moved the stale database aside:
native_lib/libapp.so.i64→native_lib/libapp.so.i64.stale_2026-07-31(kept, not deleted, in case it's ever useful for diffing against the pre-fix build). - Opened
native_lib/libapp.sofresh viaidb_open(pointing at the.sodirectly, not an existing.i64), forcing full auto-analysis into a brand-new database. - Re-verified the fix the same way the problem was found: disassembly at
0x2db384in the fresh session now matches the raw file bytes exactly. - Ran
survey_binaryon the fresh database:image_size 0xb167d0(~11.6MB), 34,726 functions (2,425 named), 21,031 strings — all different from the old build's 50,772/2,789/20,895. JNI entrypoints now readJava_com_ea_ironmonkey_GameActivityMain_*, matching the renamed activity class; new engine-assertion strings and alibc++__ndk1namespace suggest a genuinely different compiled build, not just a rename. - Re-ran the RTTI class search (
RaceLoaderTask|TrackTestLayer|NFSScene|TrackNavigator|OpponentCollection|BitmapGraphics) against the fresh binary: all present, confirming the underlying engine/architecture is unchanged even though every specific address fromANALYSIS.md§6a–§6h is now invalid. One refinement:TrackTestLayer's full namespace isim::app::layers::debug::TrackTestLayer, not just a bare class name as previously known.
- Moved the stale database aside:
- Why: User asked to "check the binary again" after a prior (incorrect) all-clear where the reported hash match was mistakenly treated as sufficient proof of consistency. Full detail and the general lesson (don't trust
survey_binary's hash fields as proof of currency — cross-check actual bytes) are inANALYSIS.md§6i. - Snapshot: The stale
.i64was preserved (renamed, not deleted) rather than discarded, consistent with the project's backup discipline even for analysis-only artifacts. - Verification: The staleness was proven by direct raw-byte comparison (Python
open().seek().read()) against IDA's own disassembly output at the same address — not inferred from indirect signals (hash fields, rename persistence) that turned out to be misleading. The fix was verified the identical way, closing the loop. - Next: every address-specific finding in
ANALYSIS.md§6a–§6h (RaceLoaderTask vtable,BuildTrackScenePath,TrackTestLayer_ctor,BitmapGraphics_*, etc.) needs to be rediscovered from scratch in the fresh database using the same methodology (RTTI walk, decompile, confirm via strings/constants) — not yet done, this session only re-established the baseline and confirmed the architecture still applies. ThempcoreARM hook from §6g/§6h now targets a meaningless offset in this binary; the user has already movedmain.cppon from it to a freshdl_iterate_phdr-based base lookup with hooks not yet reinstalled, consistent with restarting that work properly once new addresses are found.
2026-08-01 — Rediscovered RaceLoaderTask vtable + BuildTrackScenePath in the fresh binary (IDA annotation only)
- What: Redid the §6a RTTI-walk against the freshly-rebuilt
native_lib/libapp.so.i64(no runtime binary patch — analysis database only):- Located
RaceLoaderTaskvtable at0xaa78e8(18 slots, was0xd86210/19 slots in the old build), via RTTI nameN2im3app4race14RaceLoaderTaskEat0xa2f570→type_infoat0xaa7938. - Decompiled all 18 slots. Renamed 6 that had unambiguous evidence (a distinctive string or an exact structural match to the old binary's confirmed roles):
RaceLoaderTask_dtor_complete(0x2a7e58),RaceLoaderTask_dtor_deleting(0x2a8130),RaceLoaderTask_ExecuteLoadSequence(0x2a8144),RaceLoaderTask_SetupPlayerCar(0x2aafac, string"playerCar"),RaceLoaderTask_ResetStartingLine(0x2abe08, string"m_StartLine"),RaceLoaderTask_HandleSpikeStrip(0x2ad4a8,dynamic_cast<BlacklistTech→SpikeStrip>). Left 8 other slots unnamed rather than guess without evidence (e.g. the old binary's"opponentCar"-string slot has no equivalent string in the new slot at the same relative position — recorded as unconfirmed, not asserted). - Decompiled all 8 stage sub-calls inside
RaceLoaderTask_ExecuteLoadSequenceand found stage 1 (0x2a8424) references the exact strings"published/prefabs/tracks/"+".scene.sb"— theBuildTrackScenePathequivalent (was0x2db384). Renamed toRaceLoaderTask_BuildTrackScenePath. - Confirmed ARM-mode compilation at the new address too (
0xE92D4FF0=PUSH {R4-R11,LR}, condE) — same as the old binary, so the ARM-mode inline-hook design from §6g is directly reusable, just retargeted. - Added
append_commentsat the vtable and key slots documenting evidence, andidb_saved. - Full detail, table of all 18 slots, and the specific strings/constants used as evidence:
ANALYSIS.md§6j.
- Located
- Why: Direct continuation of rebuilding the RE baseline after the stale-
.i64incident (previous entry) —RaceLoaderTask/BuildTrackScenePathis the concrete hook point this project needs for subtask 1 (arbitrary track loading), so it was prioritized first among the invalidated §6a–§6h findings. - Snapshot: N/A —
.i64annotation only,libapp.soitself untouched. - Verification: Each rename is backed by either a literal string reference decompiled from the function body, or an exact structural match (same call pattern/argument shape) to the corresponding already-verified old-binary slot from §6a — not positional guessing alone, consistent with the lesson from §6h's "find evidence" correction.
- Next: re-implement the ARM-mode inline hook in
launcher/mpcore/src/main/cpp/main.cpptargeting0x2a8424(the design is proven from §6g, just needs the new address and a re-check of the track-name field's offset within theRaceDefinition-like struct, which may differ from the old binary'sthis[8]+72). Then re-test live on WayDroid.
2026-08-03 — Implemented and live-tested the RaceLoaderTask_BuildTrackScenePath ARM-mode hook on Pixel 6a; mechanism confirmed working, exposed a real data-consistency limitation
- What: Runtime hook installed by
mpcoreat process start (nolibapp.sofile on disk touched, memory-only, per this file's header rule):- Added
Hook_BuildTrackScenePath+InstallBuildTrackScenePathHooktolauncher/mpcore/src/main/cpp/main.cpp, targetingRaceLoaderTask_BuildTrackScenePathatlibapp_base + 0x2a8424(offset confirmed fresh this session inANALYSIS.md§6j). 8-byte ARM-mode patch (LDR PC,[PC,#-4]+ hook address) at function entry,mmap'd RWX trampoline relocates the 2 displacedPUSH/ADDinstructions and jumps back totarget+8— same design proven in the (now-stale) old-binary test from §6g, just retargeted. - Hook overrides the track-name field (
raceDefPtr+72/+76, whereraceDefPtr = a1[8]) with a literal"region3_colorado_track2", and (in a follow-up build) also the environment-name field (raceDefPtr+100/+104) with"colorado". Deliberately leaks the original field buffers rather than freeing/reallocating them (avoids guessing an unconfirmed capacity-field offset —BuildTrackScenePathonly reads these fields, never frees them). - Built via
./gradlew :app:assembleDebug(JDK 17), installed on the Pixel 6a (GrapheneOS, real ARM hardware — seereference-pixel6a-grapheneos-testingmemory) viaadb install -r, launched via the package's default-launcher intent (dismissing GrapheneOS's deprecated-32-bit-ABI dialog viadumpsys window/screenshot/tap, not by waiting), navigated the real menu UI (map → event → car select → confirm) to trigger an actual race load.
- Added
- Why: Direct continuation of subtask 1 (arbitrary track loading) — this is the concrete hook point identified in
ANALYSIS.md§6j, now implemented and tested end-to-end on real hardware for the first time this project (WayDroid never got far enough to reach a race load). - Snapshot: N/A — runtime hook only, no on-disk
libapp.sopatch.main.cppchanges tracked in thelaunchergit repo. - Verification / outcome: Hook installs cleanly every time (
mpcore_log:Installed RaceLoaderTask_BuildTrackScenePath hook at <libapp_base+0x2a8424>, trampoline=<addr>— address arithmetic checked against the loggedlibapp_baseand matches exactly). Fires correctly on a real race load (BuildTrackScenePath hook fired: track -> region3_colorado_track2, env -> colorado), and the engine genuinely starts loading Colorado-region assets instead of the real event's track (Add asset: .../texture_collidables_colorado.sba) — the field-pointer override reaches the engine's real path-construction logic, proving the hook mechanism itself is correct. However, ~150ms later the process crashes:Fatal signal 11 (SIGSEGV) at fault addr 0x38,Cause: null pointer dereference, in a background thread (Thread-9). Decompiling the exact crash site (tombstonepc - libapp_base = 0x53a604) identified it as a generic transform/bounds getter (a1[14], i.e. byte offset0x38, dereferenced on a NULLa1) called from 21 different sites across physics/rendering component code — not track/environment-specific. Adding the environment-field override (second test) did not change the crash (identical fault address and code offset both times), ruling out a simple track/environment name mismatch as the cause. Working theory (evidence-backed, not yet independently traced further): otherRaceDefinitionfields (checkpoints, opponents, starting grid — populated separately, perRaceLoaderTask_LoadRaceFSM's stage in §6j) still reference the original event's objects, which don't exist in the substituted scene; a background thread walking one of these dangling references hits the crash. Confirmed this is a regression from the hook specifically (not a pre-existing device/build issue): the same event ("Петерсон стрит") was played to a clean, crash-free finish on this same device earlier the same session with the unmodified build (see the "Так, это все прекрасно" verification below/earlier this session).- Also verified basic device-testing hygiene requested by the user: launched, force-killed (
kill -9), and cleanly exited (in-game exit button → confirmation dialog →System.exit status: 0) the app multiple times this session with no leftover tombstones from the kill/clean-exit paths (only from the hook-induced SIGSEGV above) — confirms the GrapheneOS deprecated-ABI dialog dismissal and process-lifecycle understanding are solid.
- Also verified basic device-testing hygiene requested by the user: launched, force-killed (
- Next: extend the hook to also intercept/rewrite whatever supplies checkpoint and opponent-placement data (
RaceLoaderTask_ResetStartingLine,RaceLoaderTask_LoadRaceFSM) so those resolve against the substituted track's own data instead of the original event's — full detail and the decompiled crash-site evidence inANALYSIS.md§6k.
2026-08-04 — Tried debugger attach (blocked by device sleep/Doze, not RE), added Log() diagnostics instead — confirmed the race-FSM is track-agnostic
- What: Two parts, same goal (understand the §6k crash without more blind guessing):
- Debugger attempt: pushed a version-matched
lldb-server(18.0.1, exact revision-hash match to the NDK 27 host client) to the Pixel 6a, attemptedplatform- andgdbserver-mode attach to the live game process viaadb forward+lldb. Handshake never completed despite confirmed-listening server, matched versions, and working raw TCP connectivity. Root cause: the device's screen locked mid-session (fingerprint lock), andadb shell/suround-trips became wildly inconsistent while locked (34ms to 300s+, no clear pattern) — most likely Android Doze/screen-off throttling. Confirmed via directtime adb shell echotiming tests and cross-checking against a second connected device (which had simply disconnected, ruling out a general adb-server problem). After the user unlocked the device, responsiveness returned to normal, but the debugger session needed re-establishing — deprioritized in favor of the lighter-weight approach below per user's direction. Not a dead end, just not finished this round; seeANALYSIS.md§6l for exact resumption notes (keep the device unlocked/awake next time). Log()diagnostics: added temporary logging toHook_BuildTrackScenePath(launcher/mpcore/src/main/cpp/main.cpp) dumping the original (pre-override) track name, theraceDef+40/44field, and — critically —*(raceDef+12)+48/52, the nested field that supplies the race-FSM prefab name (sub_2A9338/RaceLoaderTask_LoadRaceFSMbuilds"/published/prefabs/racefsms/{0}.prefabs.sb"from it). Rebuilt, reinstalled, relaunched (redoing the GrapheneOS deprecated-ABI dialog dismissal), played the same "Петерсон стрит" event to trigger the hook.
- Debugger attempt: pushed a version-matched
- Why: Direct continuation of diagnosing the §6k SIGSEGV — needed to know whether the checkpoint/rules data is tied to the original track (supporting the §6k theory) or something else entirely, without committing to the slower debugger path if a couple of log lines could answer it.
- Snapshot: N/A — runtime hook + temporary diagnostic logging only, no on-disk
libapp.sopatch. - Verification / outcome: Live log on the real event (values captured before our override overwrites them):
Confirms
diag: original track name = 'region1_foothills_track4' (len=24) diag: raceDef+40/44 field = '' (len=0) diag: racefsm name = 'point_to_point_fsm_newintro' (len=27)point_to_point_fsm_newintrois a generic, track-agnostic race-type template (not per-track), refining the §6k theory: the crash isn't from loading the wrong FSM, it's from that generic FSM's checkpoint/actor lookups failing against the substitutedregion3_colorado_track2scene. A quickstringsdiff of both tracks'.scene.sbfiles for checkpoint-related tokens found identical type/prefab names in both (Checkpoint,start,finish) but couldn't distinguish instance counts/IDs (needs a real SB unpack, not juststrings). - Next: unpack both
.scene.sbfiles viaNFSMW12MobileToolsand diff their checkpoint actor lists/counts (fast, no binary RE) to confirm the mismatch directly; or resume the debugger attempt (mechanics are proven correct, just needs the device to stay unlocked) for a live look at the exact null lookup. Full detail inANALYSIS.md§6l.
2026-08-04 — Root cause of the §6k SIGSEGV confirmed: checkpoint-container name AND count both mismatch (no debugger needed)
- What: Unpacked both
.scene.sbfiles viaNFSMW12MobileTools.jar unpack(jar +lib/+HCStructFileArray.jsoncopied into a scratch working directory) and walked their actorDataIdsMapstructures directly:region1_foothills_track4.scene.sb(the real track behind "Петерсон стрит", per §6l's live-loggedoriginal track name): full unpack succeeded. Checkpoint container is namedcheckpoints_timetrial_event_2(tied to a specific numbered event instance) holding 6 checkpoints (timetrialcheckpoint…_6).region3_colorado_track2.scene.sb(our override target): the unpacker's full DATA-object parser crashes on this specific file (NegativeArraySizeException, a pre-existing bug in the community tool, unrelated to our work) — worked around with-disableDATAObjectsUnpack, which still parses the CDAT string table. Checkpoint container is namedtimetrial_checkpoints(generic, no event-number suffix — structurally different name) holding 8 checkpoints (timetrialcheckpoint…_8).- Cross-checked against a sibling track,
region3_colorado_track1.scene.sb(unpacks fully with no workaround needed): same generic container-naming convention, but yet another checkpoint count (7) — confirms colorado tracks generally use a different, generic naming scheme than event-specific foothills containers, and that counts vary freely per track.
- Why: Direct continuation of diagnosing the §6k crash after the
Log()-based diagnostics (previous entry) narrowed it down to the checkpoint/actor lookup inside the genericpoint_to_point_fsm_newintrorace-FSM. This was the fast, no-binary-RE way to get a definitive answer, avoiding the debugger environment issues from earlier the same day. - Snapshot: N/A — read-only analysis of
game_cache/published/prefabs/tracks/*.scene.sbvia a third-party unpacker; no source or binary changes. - Verification / outcome: Root cause confirmed directly, not just theorized. The generic FSM looks for a checkpoint container by the original event's name/count (
checkpoints_timetrial_event_2, 6 checkpoints) inside whatever scene is loaded. Our hook substitutesregion3_colorado_track2, which has no actor namedcheckpoints_timetrial_event_2at all (its container istimetrial_checkpoints) and has 8 checkpoints, not 6. The lookup returns NULL, and a background thread walking a nonexistent checkpoint/container dereferences it unchecked — matches the tombstone (r0=NULL,fault addr 0x38) exactly. - Next: extend the hook to also override the checkpoint-container-name field (not yet located precisely — likely a sibling field on the same nested struct as the racefsm-name field found in §6l, at
*(raceDef+12)+48/52) with the substituted track's real container name. This is the same low-risk string-pointer-repoint pattern already proven for track/environment names. Full detail, including the two candidate fix directions, inANALYSIS.md§6m.
2026-08-05 — region3/colorado confirmed cut content; switched to region4_chicago_track4; found the real bug and got a live successful race
- What: Continued the §6m whack-a-mole one step further (patched
sub_53A5FC,sub_52A9B8,sub_52A620with defensive ARM-mode null-guards as each new crash site appeared), then stopped to check the underlying assumption rather than patch indefinitely:- Confirmed
game_cache/published/models/environments/has nocolorado/folder (onlychicago/desert/foothills/garage/newyork), andprefabs/environments/has only one un-numberedcolorado.prefabs.sbwhere every other region ships numbered<region>1..6.prefabs.sb. Unpackedregion3_colorado_track2.scene.sband confirmed it references a loose.m3gmodel path undermodels/environments/colorado/that was never shipped. region3/colorado is unfinished/cut content — no downstream patch can fix a track whose geometry cannot load. - Reverted all 4 defensive null-guard patches (no longer needed) and switched
Hook_BuildTrackScenePath's override target inlauncher/mpcore/src/main/cpp/main.cpptoregion4_chicago_track4(a confirmed-shipped, confirmed-playable track per §6). First attempt usedkOverrideEnvName = "chicago"— wrong, hitCould not open database at .../chicago.prefabs.sb(environment prefabs are numbered per-track like every other complete region); fixed to"chicago4". - With real geometry now loading, hit a new crash:
GetComponent<Checkpoint>()(sub_870E8) called on a NULL entity from inside the already-namedRaceLoaderTask_ResetStartingLine— a genuine missing-null-check engine bug (every other access in that function null-checks; this one doesn't). Added temporary diagnostics dumpingraceDef+164/180/196/212(m_StartLine/m_FinishLine/m_EndOfTrack/checkpoint-container name fields) and foundm_FinishLine/checkpoints hold per-event custom actor names (event_02_finish,checkpoints_timetrial_event_2) for the time-trial "Петерсон стрит" event, while a regular point-to-point event ("Побудка", Macklein) has only generic names (start/finish/end_of_track/empty). - Bought the "Каждый день"-class car required for "Побудка" (12 000$ in-game currency, user-confirmed) and played it with
region4_chicago_track4/chicago4substituted in.
- Confirmed
- Why: Direct continuation of subtask 1 validation — needed to know whether the §6k/§6m crash chain was a fixable data bug or something structural, before sinking more effort into defensive patches.
- Snapshot:
main.cppnow has onlyHook_BuildTrackScenePathinstalled (the 4 defensive null-guards were added and then removed in the same session — net diff from §6m's committed state is just the hook's target track/env strings and the diagnostic dump, no new permanent hooks). - Verification / outcome: Live, sustained, successful race — "Побудка" loaded and ran on
region4_chicago_track4's substituted geometry: correct HUD (position 6/6, timer counting up), AI opponents visible and racing, no crash over multiple seconds of observation. Confirmed viaadb shell pidofstaying alive plus a live gameplay screenshot. This is the first time this project has gotten a substituted-track race to actually run, not just load-then-crash. - Next: decide subtask-1 scope for time-trial/checkpoint events specifically (either also override
raceDef+180/+212to generic names when substituting, or restrict arbitrary-track substitution to regular-race event types and leave time-trial events pinned to their original track) — not yet implemented, a design decision rather than a bug. Full trace, exact field values, and both candidate fixes inANALYSIS.md§6n.
2026-08-05 (cont.) — Cleaned up diagnostics, visually confirmed track substitution with a baseline A/B comparison
- What: Removed the temporary
raceDef+40/44/+12/+164..212diagnosticLog()dump added earlier this session (its job was done — findings are recorded inANALYSIS.md§6n).main.cppnow has only the cleanHook_BuildTrackScenePathoverride (track/env name repoint) and no defensive null-guards, no diagnostic logging. Then, per user request, temporarily commented out theInstallBuildTrackScenePathHook()call, rebuilt/reinstalled, and replayed the same "Побудка" event (regular race, Macklein) to capture a baseline screenshot of the real, unmodified track - then re-enabled the hook, rebuilt/reinstalled again. - Why: User wanted direct visual proof (not just logcat text) that the hook genuinely substitutes the track geometry, not just the on-screen labels.
- Snapshot:
main.cppend state = clean hook only, actively installed (same as the successful §6n test, minus the diagnostic logging). - Verification / outcome: Unambiguous visual confirmation. With the hook active, "Побудка" loads a daytime highway/overpass scene with road signs reading "South 92", "McClane", "Ripley's Point" (chicago4-specific signage). With the hook disabled (baseline), the same event loads a completely different nighttime downtown street scene - a "HOTEL" building, road signs "Franklin Plaza / Rochelle Hall" and "Emerson Greenway", different guardrail/road geometry (the original
region5_newyork_track2). Different time-of-day, different geometry, different signage, different lighting - conclusively the same event now runs on genuinely different track geometry depending on the hook, not a coincidence or cached state. - Next: none outstanding for subtask 1's regular-race case - the hook is validated end-to-end, visually and via logs. Time-trial/opponent-race scope decision remains open per
[[track-substitution-scope]]memory (out of scope per user direction, not pursued further).
2026-08-05 (cont. 2) — Added a toggle flag for on/off comparisons; documented the hook as a reference in ARCHITECTURE.md
- What: Per user request, replaced the manual "comment out
InstallBuildTrackScenePathHook(), rebuild, reinstall" workflow with a singleconstexpr bool kEnableTrackSubstitutionHookinmain.cpp, checked inJNI_OnLoadbefore installing the hook — flip the bool, rebuild, reinstall, no other edits needed. Done on a new branch (launcherrepo, branchtrack-hook-toggle-flag, offmaster), committed asd8a1f34— kept scoped to onlympcore/src/main/cpp/main.cpp(thelauncherworking tree had several pre-existing uncommitted/staged files from earlier unrelated work -.idea/*,AndroidManifest.xml,settings.gradle.kts, a newsavepath.xml- left untouched, not part of this change). Also addedARCHITECTURE.md§3a, a consolidated reference doc for this hook (struct offsets table, trampoline mechanics, the environment-naming gotcha, the toggle flag, and the regular-races-only limitation) - distinct fromANALYSIS.md's chronological investigation log, meant to be the go-to doc for extending or reusing this hook pattern. - Why: User wanted to iterate on before/after comparisons themselves (flip flag, rebuild, look) without spending a full agent turn per comparison; also wanted the by-now-substantial tribal knowledge about this hook consolidated somewhere reference-shaped rather than only spread across
ANALYSIS.md's per-session log entries. - Snapshot:
launcherbranchtrack-hook-toggle-flag, commitd8a1f34. Not merged tomasteryet - user hasn't asked for that. - Verification: Rebuilt after the flag was added (
kEnableTrackSubstitutionHook = true) — compiles clean, user confirmed live on-device it "works great" (Fairphone 5). - Next: none outstanding. If/when the user wants this merged to
masteror wants a similar toggle for future hooks (opponent list, coordinate sync), follow the same pattern.
2026-08-05 (cont. 3) — Investigated street-event data model and the file-loading theory (read-only, no binary/code changes)
- What: Unpacked all 62
game_cache/published/data/races/event_*.prefabs.sbfiles andflow/menus/map_overworld.sbviaNFSMW12MobileToolsto check whether a street's event list (e.g. "МАККЛЕЙН" → "ПОБУДКА") is simple, editable SB data. Then traced the engine's actual file-open call chain in IDA, starting from the generic SB property-accessor functions down throughsub_8604A0→sub_8598A4(path, mode) -> FILE*→j_fopen→ libcfopen. - Why: User asked (a) whether a virtual "LAN: " entry could be added to an existing street's event list, and (b) whether SB file contents could be overridden entirely from
mpcore/JNI_OnLoadin memory, without ever touchinggame_cacheor patching the binary on disk. - Snapshot: N/A — read-only IDA analysis and third-party-tool unpacking only, no source or binary changes.
- Verification / outcome:
- (a) Not a quick data-only win.
RaceEvent'sLocationfield only holds 4 coarse region values (Chicago/Foothills/NewYork/Desert), not the street-level label shown on the map.map_overworld.sb's Flow script contains no event-ID lists or street data. The street/POI clustering system wasn't found in any SB data checked - likely computed natively at runtime, needs further RE. - (b) Theory confirmed correct.
sub_8598A4(0x8598a4) is a clean(path, mode) -> FILE*function with only 4 callers in the whole binary, itself calling aj_fopenthunk with only 4 callers - a tight, single chokepoint for engine file opens. Hooking it and returning anfmemopen()-backedFILE*for specific virtual paths (falling through to the realfopenotherwise) would let any SB file's content be overridden purely inmpcorememory, with zero footprint onnative_lib/game_cache/the OBB.
- (a) Not a quick data-only win.
- Next: not implemented yet - this is a substantial, general-purpose capability (any asset override, not just track substitution) rather than a small tweak, flagged for a deliberate decision before building it. If pursued: install a trampoline hook on
sub_8598A4(first-instruction relocatability not yet checked), inspect its 3 other callers (sub_8BBD94/sub_8D6D9C/sub_8FC64C), and prove it out on one already-understood file (e.g. anevent_*.prefabs.sb) before generalizing. Full detail inANALYSIS.md§6p.
2026-08-05 (cont. 4) — Implemented and tested the sub_8598A4 file-open hook: mechanism works, wrong chokepoint for game data
- What: New branch
file-open-hook-poc(based ontrack-hook-toggle-flag-masteris a stale, unrelated baseline, confirmed this session). AddedHook_sub_8598A4/InstallSub8598A4Hooktomain.cpp(trampoline hook, same PUSH/SUB pattern asBuildTrackScenePath), logging every(path, mode)and substituting anfmemopen()buffer for any path containing"event_02_timetrial". Prepared the substitute payload viaNFSMW12MobileTools: unpackedevent_02_timetrial.prefabs.sb, editedCashReward.Gold/Silver/Bronze10500/8500/7000→99999/88888/77777, repacked, embedded as a byte array (mpcore/src/main/cpp/test_event_02_data.h). Gated behind a newkEnableFileOpenHooktoggle flag (same pattern as the existingkEnableTrackSubstitutionHook). Built, installed, launched on the Fairphone 5, navigated map → "Петерсон стрит" → "На время" event card (the screen that displays those exact CashReward values). - Why: Direct implementation of the plan from
ANALYSIS.md§6p, at the user's explicit request to implement and test the file-open hook onevent_02_timetrialspecifically. - Snapshot:
launcherbranchfile-open-hook-poc, built but not yet committed (working tree only) - implementation is a scaffold to be corrected before committing, per the finding below. - Verification / outcome: Hook mechanism confirmed working - logcat shows it genuinely intercepting a real engine
fopen()call (/home/ogami/output-arm/openssl.cnfat startup). Wrong target function -sub_8598A4was never called again after that one OpenSSL open; the "На время" event card still showed the original10 500$/8 500$/7 000$, unchanged. Checked the other 2 named callers of the underlyingj_fopenthunk: neither is a general asset opener either (sub_859528= stream-command dispatcher,sub_8AC1F4= a file-hashing/checksum utility). Revised theory: game assets are likely served from a single startup-time mmap'd bundle via an in-memory name→pointer lookup, not per-filefopencalls at all - not yet located. - Next: trace the
sub_3FB6E4("Mounting SKU") mount-table path or the 11 otheropen()(notfopen) call sites in the binary to find the real per-asset data-resolution function, then repoint the existing hook scaffold (trampoline + path-match +fmemopensubstitution logic all reusable as-is) at it. Full detail inANALYSIS.md§6q.
2026-08-05 (cont. 5) — Found the real VFS path resolver; redirected toward runtime object injection per user clarification (read-only, no code changes)
- What: Traced
sub_208C88(RaceEvent loader) →sub_6753FC→sub_4F0138→ a virtual call on a VFS singleton (sub_40E8E8(), vtableoff_AB2084, confirmed via"VFS::AddVariant("string) → resolved vtable slot+8 by reading raw bytes →sub_410808, confirmed asVFS::OpenInputStreamvia its own strings and by reproducing the exact"Could not open database at "error seen live in §6n. This is the real, universal per-asset file resolver (notfopen-based - §6q's hook target was a red herring). User then clarified the actual goal is runtime injection into already-parsed game objects (e.g. add a synthetic map event after the real list loads), not file-content substitution before load - redirected the investigation accordingly. Searched RTTI and foundim::app::flow::nfs::MapScreen(map screen controller) andim::app::ui::MapTrack(likely the per-event marker object, appears as aboost::shared_ptrargument in one ofMapScreen's methods). Also checkedcareer.prefabs.sbas a possible data-only source for street groupings - ruled out, it only holds progression tiers and car unlocks. - Why: Direct continuation of "find the real function," redirected mid-investigation by explicit user clarification of the actual end goal (runtime injection, not load-time file swap).
- Snapshot: N/A - read-only IDA analysis and SB unpacking only.
- Verification:
sub_410808's identity asVFS::OpenInputStreamis strongly evidenced (own literal strings + exact error-string match to a previously-observed live crash), not a guess.MapScreen/MapTrackare RTTI-confirmed class names, not yet confirmed as the actual injection point. - Next: find where
MapScreenbuilds/holds itsMapTrackcollection andMapTrack's field layout, then apply the same "hook after real population, inject a synthetic entry" technique already proven forRaceDefinitionfields. Full detail inANALYSIS.md§6r.
2026-08-06 — Investigated runtime (in-memory) injection into a street's event list; found MapScreen/MapTrack/MapTrackEventList, not yet the exact add-card call
- What: Continued the file-open-hook investigation (§6p), then pivoted per explicit user feedback away from file/VFS-content substitution toward a purely in-memory, post-load UI injection approach (add a synthetic "LAN: " event card to an existing street's event list at runtime, no disk/OBB/game_cache changes at all). Read-only IDA RE only, no code/binary changes this entry.
- Confirmed via RTTI:
im::app::flow::nfs::MapScreen(world-map screen),im::app::ui::MapTrack(one pin per street),im::app::ui::MapTrackEventList(the event-card list widget). - Found
MapScreen's giant setup functionsub_1781BC(~8.7KB, largest function found this project) - confirmed it looks up a single, fixed-name"event_list"widget (via a genericFindOrCreateLayoutEntity<T>(scene, name)helper,sub_17A4CC) rather than one list per street, meaning the widget is repopulated per-pin-click, not pre-populated N times. - Found 2
Node -> MapTrackdynamic_castloops and a"TrackName"property-lookup inside the same function, but the exact call that matchesRaceEvent.TrackNameagainst a clicked pin'sTrackIdand adds a card to"event_list"was not isolated - the function is too large to decompile through the MCP tool in one call (truncates before reaching it), and windowed disassembly reading doesn't scale well at this size.
- Confirmed via RTTI:
- Why: Direct continuation of the user's street-event-injection question, redirected from the file-content-substitution approach (§6p) after the user clarified they specifically want runtime/in-memory injection, not disk-based substitution.
- Snapshot: N/A - read-only IDA analysis, no source or binary changes.
- Verification: N/A - investigation only, nothing implemented or tested yet.
- Next: recommended pivoting to live on-device debugging (lldb-server setup already proven working on the Fairphone 5 earlier this session, per the 2026-08-04 entries) to directly observe the add-card call via a breakpoint/watchpoint while manually selecting a street pin in the running game, rather than continuing to guess through raw disassembly of the remaining ~7KB of
sub_1781BC. Full detail inANALYSIS.md§6q.
2026-08-06 (cont.) — Live debugger attempt on Fairphone 5: reproducibly blocked, same failure as the 2026-08-04 attempt
- What: Per user's explicit choice, attempted live debugging to find the exact "add event card" call in
MapScreen::sub_1781BC(§6q). Pushed NDK 27's ARM32lldb-server(18.0.1) to/data/local/tmp/, matched with the same-NDK hostlldbclient (LD_LIBRARY_PATHpointing at the NDK's bundledpython3/lib). Used directgdbserver --attach <pid> *:5039mode (notplatform, avoiding the known zombie-child issue from 2026-08-04) viaadb forward tcp:5039 tcp:5039, connected from the host withgdb-remote 127.0.0.1:5039. - Why: Direct continuation of §6q - static disassembly of the 8.7KB
sub_1781BCstalled before finding the exact per-street event-card-add call; the user chose live debugging over continued blind static reading. - Snapshot: N/A - debugging session only, no source or binary changes. Game process needed relaunching afterward (killed when the debug connection tore down).
- Verification / outcome: Reproducibly blocked by the same issue as the 2026-08-04 attempt, this time isolated to a specific root cause: the on-device
lldb-serverhits an internal assertion (GetMaxU64 invalid byte_size!,DataExtractor.cpp:527) while enumerating thread/register state on every connection - non-fatal (the process doesn't crash and 37 threads list correctly with real names), but it leavesPCunresolvable for every thread (frame #0: 0xffffffffffffffff), matching the exact symptom documented on 2026-08-04. With a breakpoint set beforecontinue, the client hangs indefinitely (breakpoint verification needs a working PC/register context that's already corrupted). Confirms this is a persistent bug in this specificlldb-serverbuild (NDK 27, ARM32 target) against this device combination, not a one-off fluke - not something fixable by retrying the same tool. - Next: live debugging is not viable with currently available tooling on this device/toolchain combination. Reverting to continued static IDA disassembly of
sub_1781BC's remaining unexplored ~7KB (the other option from the user's decision menu) to find the event-card-add call. A different NDK/lldb-server version, or a different device, would be needed to make live debugging viable - not pursued further this session.
2026-08-06 (cont. 2) — Tried Frida as an alternative to lldb; also blocked (injection crashes the target)
- What: After the
lldb/lldb-serverroute hit a reproducible internal assertion bug (previous entry), tried Frida as a fundamentally different instrumentation approach (JS-bridge based, not raw GDB-remote protocol - different failure surface). Installedfrida/frida-tools17.17.0 into a fresh Python venv on the host, downloaded and pushed the matchingfrida-server(android-arm64, since the Fairphone 5's native ABI isarm64-v8aeven though the target game process itself isarmeabi-v7a) to/data/local/tmp/, ran it as root. Confirmed basic connectivity (frida-ps -Ulisted all device processes correctly). Attempted to attach to the running game process (both via-fspawn, which hit an unrelated AndroidActivityManagerstart-timeout kill, and via-p <pid>attach to an already-running instance, launched normally viamonkey). - Why: Direct continuation of the live-debugging attempt (previous entry) - Frida was tried specifically because its injection/instrumentation mechanism is architecturally different from
lldb-server's raw GDB-remote-protocol register enumeration, so it doesn't necessarily hit the same class of bug. - Snapshot: N/A - debugging attempt only, no source or binary changes.
- Verification / outcome: Also blocked, for a different reason.
frida -U -p <pid> -l script.jsreliably reportsFailed to attach: agent connection closed unexpectedly, andlogcatconfirms why: the target process is killed by the kernel withsignal 11 (Segmentation fault)(Zygote: Process <pid> exited due to signal 11) at the exact moment of the attach attempt, every time. This is Frida's own agent injection crashing the process, not a transient issue - reproduced twice with fresh process launches. Root cause not diagnosed further (candidates: 32-bit ARM target + 64-bitfrida-servercross-arch injection incompatibility specific to this NDK/toolchain combination, or some interaction with this build's own native init code) - not worth deeper investigation given the pattern (two independent, architecturally-different dynamic instrumentation tools both fail against this exact target) suggests an environment-level obstacle rather than a tool-specific bug worth working around by trying a third tool. - Next: dynamic/live analysis is not viable in this environment for now (two different tools tried, two different failure modes, both blocking). Continuing with static IDA analysis of
sub_1781BCas the only remaining viable path for finding the exact event-card-add call (§6q) - or, if the user wants to keep investigating dynamic instrumentation, it would need a different host/device/toolchain combination than what's available in this session.
2026-08-06 (cont. 3) — Found the real cause of the debugger failures (ARM32 lldb-server bug, not anti-debug) and a working cross-arch fix
- What: Per user's instinct ("может это защита?"), ran a control experiment instead of guessing: attached the same NDK's ARM32
lldb-serverto a harmless system process (systemui, pid unrelated to the game) - hit the identicalGetMaxU64 invalid byte_size!assertion and unresolvable-PC symptom seen against the game. This proved the bug is generic to the ARM32lldb-serverbuild itself, not anti-debug in the game. Then tested the NDK's AArch64lldb-serveragainst that same system process - worked perfectly (correct PC, disassembly, no assertion). Finally tested whether the AArch64lldb-servercan also correctly trace the 32-bit game process (cross-arch ptrace) - it can, cleanly, with real PC values and correct ARM32 disassembly. Used this fixed setup to breakpointsub_1781BC's"event_list"widget-lookup return point (§6s) and observed a street-pin tap live - the breakpoint never fired across two separate taps, even though the event-card UI rendered correctly both times. - Why: Direct continuation of the §6q/§6s MapScreen investigation - needed working live debugging to find the exact per-click "populate event list" call after static disassembly of the 8.7KB
sub_1781BCstalled, and the user's suggestion to investigate why the debugger was failing (rather than giving up on debugging entirely) led directly to the fix. - Snapshot: N/A - debugging only, no source or binary changes.
- Verification: Reproduced the AArch64-vs-ARM32
lldb-serverdistinction twice (control process, then the actual game process), and reproduced the breakpoint non-hit twice (initial tap, then back-out-and-retap) with screenshots confirming the game rendered the event list correctly both times (not stalled/crashed - genuinely never executed that code path). - Next: reusable finding for all future debugging on this device: always use the AArch64
lldb-serverbinary (not ARM32), pointed at the target process regardless of the target's own bitness - full recipe inANALYSIS.md§6t. Next concrete step: breakpointsub_17A4CCitself (the generic named-widget-lookup helper) and inspectr2/lron each hit while tapping a street pin, to find the actual click-handler function via whichever widget lookup (if any) it performs - not yet done due to session time, high-confidence this will work now that the tooling itself is fixed. Full detail inANALYSIS.md§6t.
2026-08-06 (cont. 4) — Two more negative debugging results + a likely-important UI structure observation
- What: Continued live debugging (§6t's fixed setup). Breakpointed
sub_17A4CC(generic widget-lookup-by-name) at its own entry, repeated the tap-a-pin sequence - never fired. Statically tracedsub_7D2E8's only 2 callers in the whole binary (sub_208C88and a generic prefab-loadersub_7CE58) - both are load-time-only, not click-time. Also noticed, from screenshots taken across this whole session, that every event-list screen shown always displays exactly 3 card slots (1 real + 2 locked placeholders), regardless of which street. - Why: Direct continuation of the search for the per-click "populate event list" call (§6s/§6t) - each negative result narrows down what the click handler does not do (no widget re-lookup, no fresh RaceEvent load), converging on a much simpler theory than originally assumed.
- Snapshot: N/A - debugging/analysis only, no source or binary changes.
- Verification: Both breakpoints confirmed correctly set (via
lldboutput) and confirmed not hit despite the UI visibly updating correctly each time (screenshots) - a real negative result, not a broken breakpoint. - Next: test whether the "always 3 slots" pattern holds for a street with more than 1 unlocked real event (would confirm a fixed-size pre-authored slot pool rather than dynamic child insertion) - if confirmed, the injection strategy simplifies to "populate one of the existing locked slots with synthetic data" rather than "find an AddChild call". Full detail in
ANALYSIS.md§6u.
2026-08-06 (cont. 5) — Correction: breakpoints don't actually work in the cross-arch lldb setup; earlier negative results retracted
- What: The user directly questioned the two prior entries' evidence ("клик НЕ вызывает загрузку... доказательств мало", "уверен что в правильном месте поставил точку останова?"). Ran the right test instead of re-asserting: breakpointed
j_malloc_0(called continuously, guaranteed to fire within milliseconds under normal operation) through the exact same cross-arch AArch64-lldb-server-on-32-bit-target setup used for the prior "negative results" - it never fired in 15 seconds of active gameplay. Tried a hardware breakpoint as a fallback - it failed outright with an explicitlldberror (hardware breakpoint resources might be exhausted or unavailable). - Why: Direct response to the user's correct skepticism - the prior entries (this same day) claimed several functions were "never called" based on breakpoints not firing, without ever validating that breakpoints could fire at all in this specific tooling configuration. That validation was missing and needed to happen before trusting any negative result from it.
- Snapshot: N/A - debugging/documentation correction only, no source or binary changes.
- Verification: The malloc sanity check and the hardware-breakpoint fallback were both run live against the actual running game process, not simulated - both failed to insert a working breakpoint.
- Outcome: This cross-arch debugging setup can attach and passively inspect (registers, memory, disassembly at whatever point the process is already stopped) but cannot actually insert working breakpoints, software or hardware. All "function X is never called on click" claims from the two prior entries this day are retracted as unverified, not confirmed-false - the underlying static-analysis-based theories (
RaceEventloaded once at startup; the map's event list is a fixed 3-slot pool) are unaffected since they never depended on the broken breakpoint evidence, just no longer have live-debugging support behind them. - Next: either find a debugging path where breakpoints genuinely work (untried:
PTRACE_SINGLESTEP-based stepping instead of breakpoints; the user's own previously-working IDA GUI +android_server32remote debugger, which requires manual GUI interaction not available through the current MCP tooling) or continue with static IDA analysis only, treating live debugging as inspection-only for now. Full detail inANALYSIS.md§6t.
2026-08-06 (cont. 6) — Tried real GDB + genuine ARM32 gdbserver; also can't sustain a breakpoint (different failure mode, same practical outcome)
- What: Following the user's question ("а gdb работает?"), installed
gdb-multiarchwithout root (apt-get download+dpkg-deb -x, nosudoneeded). It failed to talk to the existing AArch64lldb-serverat the wire-protocol level (Invalid hex digit 59) - a genuine LLDB/GDB remote-protocol dialect mismatch, separate from the earlier cross-arch issue. Downloaded the full legacyandroid-ndk-r16b(852MB - partial/range-based extraction againstdl.google.comfailed first, forcing the full download) to get a real, native ARM32gdbserver(Google droppedgdbserverfrom the NDK after r17). Attached it natively (no cross-arch tricks needed) to the game process and connected withgdb-multiarch. - Why: Direct continuation of the user's question and the broader goal (§6t) of finding a debugging setup where breakpoints actually work, having ruled out the AArch64-cross-arch
lldb-servercombination. - Snapshot: N/A - debugging/tooling investigation only, no source or binary changes.
- Verification:
interruptcorrectly stopped the live process and printed a genuine, correct backtrace (syscall() <- libart.so) - strictly better evidence of real control than eitherlldbpath achieved. A breakpoint was accepted onj_malloc_0, butcontinuecrashedgdbserveritself (game process unaffected, survived and kept running normally) -gdbreportedRemote connection closed, no breakpoint hit was ever observed. - Outcome: Real GDB + real native ARM32 gdbserver can attach and interrupt correctly, but still cannot sustain a
continuepast an installed breakpoint - a different failure (gdbserverprocess death, not a silent no-op) but the same practical result as thelldbattempts. Likely cause: ~7-year version skew between the 2017gdbserver(last NDK release to include it) and the 2024gdb-multiarch15.1 client - protocol-handshake warnings observed in earlier connection attempts support this. - Next: not yet tried - a period-matched legacy GDB client (own toolchain, not just swapping the server), or abandoning breakpoints entirely in favor of
PTRACE_SINGLESTEP-based stepping. Until then, debugging in this project is inspection-only (attach/interrupt/read state), and anything requiring "does function X run when Y happens" needs static IDA analysis instead. Full detail inANALYSIS.md§6t.
2026-08-06 (cont. 7) — Root cause of all debugging failures identified: Fairphone 5 has no native 32-bit hardware
- What: The user identified, via their own research, that the Fairphone 5's SoC has no native AArch32 hardware support - 32-bit code runs through a software translation/compat layer (like Intel's Houdini on x86 Chromebooks), which modern Qualcomm chips increasingly lack real hardware for. Documented this as the retroactive explanation for every debugging symptom hit this session (ARM32
lldb-server's register-parsing assertion, hardware breakpoints being explicitly unavailable, software breakpoints installing but never firing,gdbservercrashing oncontinue) - no further tooling investigation needed, this isn't a bug to work around on this device. - Why: Explains and closes out the entire multi-session debugging investigation (§6t) with a single, coherent root cause rather than a pile of tool-specific workarounds.
- Snapshot: N/A - documentation/conclusion only.
- Verification: User-provided research; consistent with every observed symptom (passive ptrace operations via kernel compat translation work fine; anything requiring the patched/native code stream to actually execute does not).
- Next: retry live breakpoint-based debugging on a device with genuine AArch32 hardware - the already-available Pixel 6a (GrapheneOS) is the nearest candidate; the user also plans to bring a Pixel 5a or 2018 Galaxy A9. Until then, per the user's explicit direction, continuing with static IDA analysis only. Full detail in
ANALYSIS.md§6t.
2026-08-06 (cont. 8) — Static analysis resumed: decoded MapScreen's click hit-test and per-slot lock refresh (no debugger needed)
- What: Per the user's "continue with statics" direction, went back into
sub_1781BC(MapScreen setup/update function) usingfunc_profile+disasmwindows (the plaincalleestool returned an empty list for this function for unclear reasons —func_profilewithinclude_lists: trueworked and returned all 46 real callees). Found two distinct property-reading blocks: Region A (0x178afc-0x178e40) is a straight-line "populate selected-event summary panel" read of TrackName/Completion/EventName/class_restriction/event_type from one object at[MapScreen+0x120]. Region B (0x179a2c-0x179cfc) is the real touch-click hit-test: iteratesmap_scroll's children,dynamic_cast<MapTrack>s each, finds the nearest one to the touch point, and for the winner copies two fields straight off the MapTrack object (+0xB8/+0xBC) intoMapScreen+0x1C0/0x1C4— no VFS/prefab-load call anywhere in this path. Region C (0x179d30-0x179dfc) walks a linked list of pre-existing card-slot entities and pushes each one's "locked" property to a vtable setter, rather than adding/removing children. - Why: This is the concrete evidence needed to unblock the user's "inject a synthetic LAN lobby event into an already-loaded street's list, at runtime, no file/OBB changes" goal — it identifies the exact mechanism (a
MapTrackpin carries a pre-set data pointer at+0xB8; card slots are a fixed pre-existing pool toggled by alockedflag) rather than a dynamic add-child call. - Snapshot: N/A — read-only static analysis,
libapp.sountouched. - Verification: Static disassembly, cross-checked against
xrefs_toon theMapTrackRTTI typeinfo (0xac6434) confirming only 3 functions in the whole binary everdynamic_casttoMapTrack(sub_1781BCitself,sub_17C120— unlock/tap-routing, strings "unlocking"/"NEXT_EVENT"/"RACE"/"BLACKMARKET" — andsub_17FC6C— fully decompiled, confirmed to be a "scroll map to named track" helper, not event attachment). - Outcome/Next: The function that writes
MapTrack+0xB8/+0xBC(i.e., attaches a RaceEvent to a pin at load time) is still not found — that's the real injection target. Next: check the third, unexamineddynamic_cast<MapTrack>site insub_1781BC(~0x17a02c), findMapTrack's own constructor, and/or fully decompilesub_17C120. Full detail inANALYSIS.md§6u.
2026-08-06 (cont. 9) — Ruled out both other MapTrack-touching functions; RTTI-xref search for the "attach event to pin" write site is a dead end
- What: Fully decompiled the other two functions in the binary that
dynamic_casttoMapTrack(found via RTTI xrefs in cont. 8).sub_17FC6C= "scroll map camera to a named track" helper (auto-scroll-to-next-event).sub_17C120=MapScreen's per-frame update/tick handler: touch-down dispatch (UIButton vs MapTrack), an "unlock reveal" animation state machine (reads/sets an"unlocking"property when a pending-unlock list drains), camera-scroll clamping, and a periodic QA-only "Soak Test" auto-race feature. Neither writesMapTrack+0xB8/+0xBC. - Why: Needed to confirm/rule out whether either of these (the only other MapTrack-touching code in the whole binary) was the "attach RaceEvent to pin at load time" function — the actual target for the user's runtime-injection goal.
- Snapshot: N/A — read-only static analysis.
- Verification: Full Hex-Rays decompilation of both functions (both small enough to not hit the truncation limit that affects
sub_1781BC). - Outcome/Next: RTTI-xref searching for
MapTrack-related code is exhausted as an approach — the real population site must get a statically-typedMapTrack*without needingdynamic_cast. Next: locateMapTrack's own constructor via its vtable symbol near_ZTIN2im3app2ui8MapTrackE(0xac6434) in.data.rel.ro, same technique already proven forRaceLoaderTask(§6a), then follow xrefs to that. Full detail inANALYSIS.md§6u.
2026-08-06 (cont. 10) — FOUND the exact "attach RaceEvent to pin" function: MapTrack::AddEvent
- What: Pivoted away from the RTTI-xref dead end (cont. 9) to the proven §6a technique — found
MapTrack's real vtable (_ZTVN2im3app2ui8MapTrackE,0xaabfdc) viaentity_queryon names near its RTTI typeinfo, then its real constructor (sub_368860, confirms+0xB8starts zeroed) and factory (sub_395B38, called only from a 27KB class-registration bootstrap — a dead end on its own). The real answer came from the"Couldn't find TrackId property on MapTrack widget "error string, unique to one function:sub_369040=MapTrack::RefreshEvents(). It reads its ownTrackId, looks up matchingRaceEvents in a registry, and callssub_369AB0=MapTrack::AddEvent(RaceEventHandle*)for each match — whichpush_backs the event into a growable vector living directly on the pin (MapTrack+0x240..0x248), updates completion-percentage counters (3 medals/event), and sets lock/availability flags.RefreshEventsitself is triggered fromMapTrack::HandleEvent(sub_368DFC) on the engine'sFlowSetLayoutScreenEventmessage — i.e. each pin self-registers its own events when its screen finishes laying out, a bottom-up per-pin pattern, not a top-down "MapScreen assigns events to pins" one. - Why: This is the exact, concrete answer to the question this whole multi-session thread has been chasing — the real target for injecting a synthetic "LAN: " entry into an existing street's card list at runtime, with no file/OBB changes.
- Snapshot: N/A — read-only static analysis.
- Verification: Full Hex-Rays decompilation of the whole chain (
sub_368860,sub_3688D4,sub_395B38,sub_369040,sub_369AB0,sub_368DFC,sub_368FDC), cross-checked viaxrefs_toat each step (AddEventhas exactly one caller, confirming it's dedicated and safe to call directly). - Outcome/Next: Implementation-ready target identified: hook or directly call
MapTrack::AddEvent(0x369AB0) on an already-loaded pin with a synthetic event handle to make the engine's own bookkeeping (vector growth, completion %, lock flags) handle a fake "LAN lobby" entry exactly like a real one. Remaining unknown: the exact shape of theRaceEventHandleargument (looks like an ECS-style handle/ID, not a raw pointer — resolved through the same "component" indirection seen elsewhere in the binary, not a directRaceEvent*). Full detail inANALYSIS.md§6v.
2026-08-06 (cont. 11) — Decoded AddEvent's handle format: a 32-bit FNV-1a-keyed resource cache; AddEvent doesn't touch the TrackId registry
- What: Decompiled
sub_173350(called fromAddEvent) andsub_242778(called fromRefreshEvents, upstream). Found two independent, chained hash maps:TrackId string --[FNV-1a keyed]--> vector<uint32 hash>(sub_242778), then eachhash --[int keyed]--> refcounted RaceEvent-prefab-instance ptr(sub_173350, same generic prefab-instance-cache system assub_7CE58from §6s). ConfirmedAddEventonly calls the second (hash→instance) map directly — it never touches the TrackId registry itself. - Why: Needed the exact argument format
MapTrack::AddEventexpects before it can be called with synthetic data — this determines what minimal fabrication is actually required for the injection. - Snapshot: N/A — read-only static analysis.
- Verification: Full Hex-Rays decompilation of both functions; FNV-1a identification via its unmistakable literal constants (offset basis -2128831035, prime 16777619).
- Outcome/Next: Simplifies the injection plan — no need to touch the TrackId registry at all. Only requires: (1) one fabricated node in the prefab-instance cache (
sub_173350's hash map) under an unused ID, wrapping a fakeRaceEvent-shaped object using the known SB field layout from §6p; (2) a direct call toMapTrack::AddEvent(existingPin, &chosenId). Remaining unknowns: exact in-memory shape of a loadedRaceEventprefab instance, and safe construction details for splicing a new hash-map node at runtime. Full detail inANALYSIS.md§6w.
2026-08-06 (cont. 12) — Confirmed cache-node value = real RaceEvent component pointer; found a much cheaper injection strategy (clone-and-relabel vs fabricate-from-scratch)
- What: Decompiled
sub_7CE58(the insert side of the same prefab-instance cacheAddEvent's handle resolves against, per §6w) and confirmed it storessub_7D2E8's (the long-known "extract RaceEvent component from a loaded prefab" function) two outputs plus the owning refcounted Actor pointer into the cache node's+12/+16/+20fields. This means a resolvedAddEventhandle always points at a real, fully-formedRaceEventcomponent object — never anything synthetic. - Why: Determines whether injecting a synthetic event requires hand-fabricating a whole
RaceEvent-shaped C++ object (vtable, RTTI, full field layout — risky, layout only partially known) or whether a cheaper route exists. - Snapshot: N/A — read-only static analysis.
- Verification: Full Hex-Rays decompilation of
sub_7CE58, cross-referenced againstsub_173350's read-side node layout (§6w) andsub_7D2E8's established role from earlier sessions. - Outcome/Next: Two viable strategies now documented (§6x): (1) clone-and-relabel — reuse a real, already-loaded
RaceEvent*as the fake cache entry's payload and overwrite only its display properties via already-known property setters (recommended, avoids needing to fabricate a vtable/RTTI from scratch); (2) fabricate-from-scratch — build a realRaceEvent-shaped object by hand (needs further reversing of its live in-memory class layout, not just the on-disk SB field layout from §6p). No further static analysis strictly required to choose between these — this is now an implementation-planning decision. Full detail inANALYSIS.md§6x.
2026-08-06 (cont. 13) — Byte-precise RaceEvent field map found; corrected an earlier wrong guess about the 3 floats
- What: Per the user's choice of "Вариант Б" (fabricate a synthetic RaceEvent from scratch), found RaceEvent's real vtable (
_ZTVN2im3app4race9RaceEventE,0xaa78a8) and constructor (sub_2A4B58:malloc(0xE4)— object is exactly 228 bytes; base ctor sets owner-ptr/type-tag header, then vtable overwritten withoff_AA78B0). Fully decompiledsub_2A4D70(the field deserializer) and built a byte-precise offset→property-name table:TrackName@+72,RaceType@+24,CarRestriction@+56,EventName@+88 (interned ptr, not a full string),Location@+92, computedZone@+96,EnvironmentPrefab@+100,TrafficCarCount@+116,OpenWorldTrack@+120,AutologID@+124 (default -1),BlacklistEvent@+128,ClassRestriction@+132,PursuitType@+136,StartLineNoSpawnZone/FinishLineNoSpawnZone/SpawnDistance@+152/156/160 (floats, default 1000.0 each),StartLine/FinishLine/EndOfTrack/CheckpointCollection@+164/180/196/212 (12-byte string triples). - Why: Fabricating a convincing synthetic RaceEvent object requires knowing its exact live memory layout, not just its on-disk SB field names.
- Snapshot: N/A — read-only static analysis.
- Verification: Full Hex-Rays decompilation;
StartLine/FinishLine/EndOfTrack/CheckpointCollectionoffsets (164/180/196/212) cross-checked exactly against theraceDef+164/180/196/212offsets already established in much earlier sessions — independent agreement. - Correction: An earlier guess this session (§6x) that the 3 floats at +152/156/160 might be
CashRewardGold/Silver/Bronze is now disproven — they're spawn-zone tuning floats, all defaulting to 1000.0.CashRewardand the UI-facing card properties (class_restriction,event_type,Completion) are confirmed absent from this 228-byte struct entirely — they must live on a separate component or be computed at read time. - Outcome/Next: Next concrete step — find the reflective property-descriptor table that
sub_15F2DC/sub_406644consult when the UI readsclass_restriction/event_type/Completion/CashRewardby name, since those aren't raw fields of this struct. Full detail inANALYSIS.md§6y.
2026-08-06 (cont. 14) — Corrected sub_15F2DC's role: it's a named-widget lookup, not a property read — narrows the fabrication gap to just CashReward
- What: Fully decompiled
sub_15F2DC, previously only inferred by call-pattern analogy as "a generic property getter." It's actually the sameFindOrCreateLayoutEntity<T>pattern assub_17A4CC(same "Unable to locate layout entity: " error string), specialized forim::scene2d_new::Textwidgets. This means §6u Region A ofsub_1781BClooks up named Text-label widgets by fixed name ("TrackName", "class_restriction", "event_type", etc.) within a scene rooted at[MapScreen+0x120](a Node, not a RaceEvent*) — it doesn't read data values at all. - Why: Re-evaluating what §6y's RaceEvent field map still needs to cover for a convincing fabricated card, now that "class_restriction"/"event_type" are understood to be widget names (very likely fed from the already-known
ClassRestriction@+132 andRaceType@+24 fields) rather than separate unknown properties. - Snapshot: N/A — read-only static analysis.
- Verification: Full Hex-Rays decompilation; error string and list-traversal pattern match
sub_17A4CCexactly. - Outcome/Next: The fabrication gap narrows to just
CashReward(Gold/Silver/Bronze) — the only field genuinely unaccounted for in the 228-byte RaceEvent struct (Completion is computed by AddEvent, not stored on RaceEvent, so not actually missing). TrackName/EventName/RaceType/ClassRestriction from §6y's table already cover what the visible card needs. Full detail inANALYSIS.md§6z.
2026-08-06 (cont. 15) — Found CashReward's full layout, closing the fabrication gap for Вариант Б
- What: Found
CashReward(im::app::metagame::CashReward) is a separate 28-byte class (not a RaceEvent field), part of aReward/RewardsCollectionfamily. Constructor (sub_23E2C4) sets vtableoff_AA5B54, then+16/+20/+24= 10000/30000/50000 (inferred Bronze/Silver/Gold by ascending default magnitude) and+12= an unrelated unknown field defaulting to 0. SameComponentbase (sub_670454) as RaceEvent, via an intermediateRewardbase ctor (sub_25D4F0). - Why: This was the last missing piece for fabricating a complete, convincing synthetic race event (Вариант Б) — RaceEvent's own struct (§6y) has no reward fields.
- Snapshot: N/A — read-only static analysis.
- Verification: Full Hex-Rays decompilation of both
CashReward's andReward's constructors. - Outcome/Next: Both objects needed for a fabricated event are now fully specified (228B RaceEvent + 28B CashReward, both simple malloc-and-fill constructors). Remaining open question (not blocking): how a RaceEvent and its CashReward are associated on the same prefab Actor — not investigated yet, only matters if real card-rendering code fetches the reward via that link rather than a separate cache lookup we control ourselves. Full detail in
ANALYSIS.md§6aa.
2026-08-06 (cont. 16) — Resolved RaceEvent<->CashReward link: sibling components on the same Actor, each deserialized independently per-race
- What: Found
sub_164540=Actor::GetComponent<CashReward>()(confirmed via "GetComponent" profiling string + generic component-list-iteration/dynamic_cast pattern) — its argument is anActor*, the same object typesub_7D2E8extractsRaceEventfrom. This confirmsRaceEventandCashRewardare independent sibling components on the sameActor, linked only via that Actor's component list — not via any direct field on either object. Also foundCashReward's actual field deserializer (sub_23E47C, same code region as its constructor from §6aa), which explicitly reads"Bronze"→+16,"Silver"→+20,"Gold"→+24 from the same shared per-prefab property tableRaceEvent's own deserializer (sub_2A4D70) reads from — confirming (not just inferring, as in §6aa) the exact tier-to-offset mapping. - Why: User specifically asked to resolve this link and flagged that different races have different rewards — needed to confirm the mechanism supports per-race variation (it does: each race's own
.prefabs.sbproduces its own freshly-deserializedCashRewardinstance on its ownActor). - Snapshot: N/A — read-only static analysis.
- Verification: Full Hex-Rays decompilation of both
sub_164540andsub_23E47C; property names read directly as literal strings in the decompile, not inferred. - Outcome/Next: Fabrication plan is now fully closed for both components — a synthetic race's fake Actor needs a component list containing both a fake RaceEvent* (§6y) and a fake CashReward* (§6aa, with whichever Bronze/Silver/Gold values are desired) so that
sub_7D2E8andsub_164540resolve correctly against it exactly as for a real race. Full detail inANALYSIS.md§6bb.
2026-08-06 (cont. 17) — Implementation plan finalized for the synthetic "LAN: " event injection
- What: Wrote and got user approval for a concrete implementation plan (saved at
/home/megboyzz/.claude/plans/dreamy-giggling-hearth.md) covering: hookingMapTrack::HandleEvent(sub_368DFC, confirmed ARM-mode/position-independent prologue, same trampoline technique as the two existing hooks), an observation-only first step to log real on-deviceTrackIdvalues before hardcoding a target street, and the full fabrication sequence (RaceEvent ctorsub_2A4B58+ string-field appendsub_7B524+ interned EventName viasub_406644, CashReward ctorsub_23E2C4, a minimal fake Actor exposing onlyactor[5]/[6]'s component array, cache-key insertion via the real engine primitivesub_7D638rather than hand-rolled hash-map logic, thenMapTrack::AddEventsub_369AB0). - Why: Closes out the multi-session static-analysis thread (§6s-§6bb) into an actionable next step, per the user's explicit request to move to injection implementation planning.
- Snapshot: N/A — planning only, no code written yet (user explicitly chose "plan only, no code" this pass via AskUserQuestion).
- Verification: N/A — plan not yet executed.
- Outcome/Next: Implementation (writing the actual hook code in
launcher/mpcore/src/main/cpp/, on a new branch offmaster, e.g.lan-event-injection-poc) is the next session's task, on the user's go-ahead. Key flagged risk carried into that work: the fake Actor object only satisfies the two component accessors this plan calls — fullActorlayout was never reverse-engineered, so other code touching the same fake object is a residual crash risk, mitigated by incremental on-device testing.
2026-08-07 — BREAKTHROUGH: live breakpoint-based debugging confirmed working on Samsung Galaxy A9 (2018)
- What: User provided a rooted Samsung Galaxy A9 (2018), SM-A920F, Snapdragon 660/Kryo 260, Android 10 — a device whose SoC pre-dates Qualcomm's removal of native AArch32 hardware support, as a direct test of the root-cause theory from the Fairphone 5 debugging saga. Attached the same native ARM32
gdbserver(NDK r16b) used before, but this time paired with the matching-generation GDB client (gdb-orig7.11, also from NDK r16b, run with a localLD_LIBRARY_PATHsymlink working around its legacylibncurses.so.5dependency) instead of a moderngdb-multiarch. Set a software breakpoint onmalloc— the exact same sanity check that never fired on the Fairphone 5 — and it fired correctly on the firstcontinue, with a real PC and backtrace frame, thengdbserverdetached cleanly with the game process undisturbed. - Why: Confirms the Fairphone 5's lack of native 32-bit hardware (not a tooling bug) was indeed the root cause of every prior debugging failure this project hit, and unblocks genuine live debugging going forward.
- Snapshot: N/A — pure debugging session, no file/memory patches,
libapp.sountouched. - Verification: Direct, reproducible breakpoint hit with correct PC/backtrace; process survived cleanly after detach.
- Notable side-findings: (1)
gdbserver --attachpauses the process immediately via ptrace — attaching mid-loading-screen freezes the app there, looked like a hang until understood. (2) This device (Android 10) mmaps native libs directly from inside the APK zip rather than extracting a standalonelibapp.so, so/proc/PID/mapsnever shows alibapp.so-named entry — runtime load base has to be computed via the APK's zip data-offset forlibapp.somatched against a/proc/PID/mapsfile-offset field, then verified byte-for-byte against the reference file's ELF header. Changes every process relaunch (ASLR), must be recomputed each time. - Outcome/Next: The Galaxy A9 is now the reference device for live debugging on this project. This directly unblocks the previously-static-analysis-only questions from earlier this session (e.g. could now verify rather than infer the
MapTrack/RaceEvent/AddEventcall chain from §6v-§6cc live, and will be the natural next step when validating the injection plan from/home/megboyzz/.claude/plans/dreamy-giggling-hearth.md). Full detail inANALYSIS.md§6cc; device details also recorded in[[reference-native-arm32-debugging-requirement]].
2026-08-07 (cont. 2) — Live-verified the full MapTrack::HandleEvent -> RefreshEvents -> AddEvent chain; confirmed base-address resolution has no divergence on APK-embedded-libs devices
- What: Two live tests on the Galaxy A9. (1) Compared
get_libapp_base()'s (existingdl_iterate_phdr-based code inmain.cpp) actual runtime result against an independently, manually computed load address — readlibapp_base's live value straight out of the already-runninglibmpcore.so(bundled in the installed APK, not stripped) via its own computed load address. Both methods gave the identical0xb8798000. (2) Set a conditional breakpoint onMapTrack::HandleEvent(only stopping when the dispatched event type is really1025/FlowSetLayoutScreenEvent), plus plain breakpoints onRefreshEventsandAddEvent, attached early to a freshly-relaunched game process, and let it run through its natural startup. All three fired in the exact predicted order, back to back, for multiple street pins in sequence, withAddEventfiring 6/4/3 times per pin for the first three pins observed. - Why: Directly answers the user's two questions — whether base-address resolution needs a fallback algorithm for this device class (no, one algorithm already works), and whether the reverse-engineered
MapTrack/AddEventchain from §6v-§6y actually holds up under real execution (yes, confirmed exactly). - Snapshot: N/A — pure debugging/observation, no patches.
- Verification: Live breakpoint hits with correct
thispointers matching across the whole call chain (HandleEvent'sthis==RefreshEvents'sthis==AddEvent'sthis, repeated per pin); also learned Garage->Map navigation does not refire the layout event (MapScreen persists rather than being recreated) — a fresh app launch was needed to catch the real trigger. - Outcome/Next: The injection plan's core assumption (
AddEventis the right, safe, real call target) is now empirically confirmed, not just inferred. New concrete data point: real streets carry 3-6 events per pin in this save, not just 1 — worth keeping in mind when picking a target pin for the synthetic "LAN" entry (a pin with existing events is a safer test target than an all-locked one). Full detail inANALYSIS.md§6dd.
2026-08-07 (cont. 3) — Implemented and live-tested the LAN event injection hook: works end-to-end, no crashes, after fixing 2 real bugs
- What: Implemented the approved plan on new branch
lan-event-injection-poc(launcher repo), branched fromtrack-hook-toggle-flagrather than baremaster(master turned out to lack the working hook-trampoline infrastructure the plan assumed - it's a much earlier, scratch-code-laden state never merged forward from the feature branches). New filelauncher/mpcore/src/main/cpp/lan_event_injection.hhooksMapTrack::HandleEvent, and onFlowSetLayoutScreenEventfabricates aRaceEvent+CashReward+fakeActor, inserts a cache entry, and calls the realMapTrack::AddEvent. Built and deployed to the Galaxy A9 multiple times, iterating on real crashes: (1)Actor+4must be non-null ("actor has been deleted" check), (2)Actor+8(a refcount) must start at 1, not 0, sinceAddEventdoes a borrow-then-release cycle that calls a virtual "release" method through the object's own vtable if it nets to exactly 0 - crashing on this fake object's null vtable at offset +12. Also fixed a separate, unrelated torn-read crash in the diagnostic logging code (reading aMapTrack's event vector from a background GLThread raced a concurrent update once). - Why: Validates the entire multi-session reverse-engineering chain (§6v-§6bb) against the live game, not just statically - this was the actual deliverable the user asked for.
- Snapshot: New file
launcher/mpcore/src/main/cpp/lan_event_injection.h;main.cppgained one include + one toggle flag + oneJNI_OnLoadcall. Not yet committed (only committed on explicit request per project convention). - Verification: Repeated fresh-app-launch tests on the Galaxy A9 via
adb logcat, confirming zero crashes across all ~12MapTrackpins this hook currently reaches, each logging a successful "LAN injection: added synthetic event..." line. - Outcome/Next: The injection mechanism itself is proven working. Not yet achieved: visual confirmation on a currently-visible street - the 3 streets shown on this save's map (one is a "Most Wanted #10" boss battle) aren't among the ~12 pins this hook's
evtype==1025condition catches, meaning that special/story content likely gets populated through a different mechanism than the generic path this hook intercepts. Next investigative thread if visual confirmation is wanted: find why those 3 pins don't dispatch through the same broadcast HandleEvent gets. Full detail inANALYSIS.md§6ee.
2026-08-07 (cont. 4) — Narrowed the delayed injection crash to the "Blacklist" rival-ranking subsystem; exact faulting instruction still open
- What: Chased the delayed crash from cont.3 (fault addr = our injected key
0xc0ffee00, ~5 min after injection) via static analysis and live debugging. Found it traces throughsub_233684(checks event names against a hardcoded Blacklist-rival-rank table - "event_60_blacklist_1".."event_04_blacklist_10" - matching the "Most Wanted #10" boss card seen earlier), which walks a boost::function-keyed map via a generic invoker (sub_234F38), calling a previously-IDA-unrecognized callback function at0x233b4c(only reachable via a stored function pointer, never called by name), which calls into0x40602C. Along the way, fixed a recurring gdb/gdbserver "Cannot execute this command while the target is running" sync bug - reliably avoided by attaching immediately after process spawn rather than to an already-running instance. - Why: User explicitly prioritized understanding/fixing this crash before continuing the separate "why don't 3 special pins dispatch through HandleEvent" investigation.
- Snapshot: N/A — investigation only, no code changes this pass.
- Verification: Live-caught the call chain firing 28+ times with stable, safe register values, confirming it's a frequent/routine scan unrelated to injection under normal conditions - the crash is a rare condition within this hot path, not yet isolated to one exact instruction.
- Outcome/Next: Root cause narrowed to subsystem level (Blacklist rival scan misusing a raw injected key as a pointer) but not to the exact instruction - would need either a much longer live-debug soak or a conditional breakpoint on suspicious register values to fully pin down. Proposed an untested, lower-effort mitigation direction instead: generate synthetic cache keys that are structurally closer to real hashes (via
sub_67223C) rather than an easily-distinguishable sentinel range, and re-run the soak test empirically. Full detail inANALYSIS.md§6ff.
2026-08-07 (cont. 5) — EXACT root cause of the delayed crash found: QA-only "Soak Test" auto-race feature, not the Blacklist system
- What: Corrected cont.4's investigation - had traced the wrong backtrace frame (a caller/return-address site, not the actual fault). Re-computed the real faulting frame's static offset (
0x406cd0) and found it's insidesub_406CAC, a generic "build a string from a C-string pointer" helper, faulting on its very firststrlen-style scan when given a non-string pointer. Traced the caller: it'ssub_17C120's already-identified (§6v/§6bb) QA-only "Soak Test" auto-race feature - every ~4s of gameplay, it index-walks a separate, parallel array (obtained viasub_242904, apparently a list of resource-path strings, not the same hash-tablesub_7D638/sub_173350operate on) and builds a debug log line from a "random" (index-based) entry. Our injection only touches the primary hash-table, never this parallel list, desyncing their sizes - eventually the Soak Test's index-based lookup reads past/misaligned memory landing on our raw injected key instead of a real string pointer. - Why: Gives a concrete, well-supported, and importantly cheap mitigation path, without needing to reverse-engineer the parallel list itself.
- Snapshot: N/A — investigation only.
- Verification: Static re-analysis of the exact faulting instruction and its containing function, cross-referenced against the original crash's exact fault address (
0xc0ffee00, our injected key) and againstsub_17C120's already-decompiled body from earlier sessions. - Outcome/Next: Recommended mitigation: this whole code path is QA/debug-only (auto-launches random cached races for soak testing, not player-facing) - the clean fix is to prevent it from ever running (hook
sub_17C120or force itsflt_AD417C > 4.0gate to never trip) rather than trying to keep a second, unidentified parallel list in sync with every injected entry. Not yet implemented in code. Full detail inANALYSIS.md§6gg (supersedes the less-precise §6ff).
2026-08-07 (cont. 6) — Implemented and verified the Soak Test disable hook: 10-minute crash-free soak test, game confirmed healthy
- What: Added
Hook_MapScreenTick/InstallSoakTestDisableHooktolan_event_injection.h- hookssub_17C120(MAPSCREEN_TICK_OFFSET, same trampoline pattern as the other two hooks) and forces the Soak Test's trigger accumulator (flt_AD417C, static offset0xAD417C) to0.0fimmediately before every call to the original function, so its>4.0threshold never trips and the feature never fires. Wired in via a new independent toggle flagkEnableSoakTestDisableHookinmain.cpp, deliberately separate fromkEnableLanEventInjectionHookso it can stay on even with injection itself toggled off. Rebuilt, reinstalled, relaunched fresh, then ran a 10-minute logcat monitor (past the ~5 min point the original crash always hit by) - zero crash events,pidofstill showed the same PID throughout. Woke the screen and foregrounded the app afterward to confirm it wasn't just "alive" but genuinely responsive: screenshot showed a normal, interactive map screen with real pins/balance, not a hang. - Why: Directly implements the fix for the exact root cause found in cont.5, per explicit user request ("Да, реализуй фикс").
- Snapshot:
launcher/mpcore/src/main/cpp/lan_event_injection.hgained the new hook;main.cppgained one toggle flag + oneJNI_OnLoadcall. Still on branchlan-event-injection-poc, not yet committed. - Verification: 10-minute crash-free
Monitorrun (mpcore_log+*:Ffatal logcat),pidofcontinuity check,logcat -d -v time *:F | grep -c "beginning of crash"= 0, and a post-soak screenshot of a live, interactive map screen (pins, currency, nav bar all responsive) after waking/foregrounding the app. - Outcome/Next: The delayed-crash bug from cont.3-5 is resolved and empirically confirmed fixed, not just theorized. Remaining open thread from cont.3 (paused, not abandoned, per explicit user prioritization): why the 3 special/story pins on this save (incl. the "Most Wanted #10" boss card) never dispatch through the generic
HandleEvent(evtype=1025)path this hook intercepts - to resume on user's direction.
2026-08-07 (cont. 7) — Solved the 3-pin mystery: they aren't MapTrack instances at all
- What: Resumed the paused 3-pin investigation. First tried live gdb (gdb-orig 7.11 + gdbserver_arm on the Galaxy A9, same recipe as cont.1-2) to set a breakpoint on
MapTrack's constructor (sub_368860) - this repeatedly failed today across ~25 attempts and two frameworks: gdb hit a persistent, 100%-reproducible remote-protocol sync error ("Cannot execute this command while the target is running") tied to the vendor graphics-driver library-loading burst every single attach went through, occasionally cascading into real app crashes (crash_dump32) when cleanup was too aggressive; a Frida fallback (after fixing a frida-server/frida-python version mismatch, 16.7.0 vs 17.17.0) hit a different, unrelated bug - Frida's own Java-bridge crashing with "Error: invalid address" on any attach to this device's ART build. A full device reboot (user-initiated) didn't resolve the gdb issue either. Abandoned live debugging and instead added a temporary compiled-in logging hook on the same constructor address, following this file's own established, proven hook pattern (Hook_MapTrackCtor/InstallMapTrackCtorTraceHookinlan_event_injection.h, gated by a newkEnableMapTrackCtorTraceHookflag) - rebuilt, installed, and read the result straight from logcat, which worked immediately and cleanly. Result: exactly 12MapTrackconstructor calls fire per app launch, all from the identical call site (lr=0xba03cb5cthis run, static offset0x395b5c) - matching exactly the ~12 pins already known to reach theHandleEventhook, with zero extra. Traced that call site via IDA: it'ssub_395B38, a tinymake_shared<MapTrack>()-style factory (malloc 0x118 + constructor call + shared_ptr control-block wrap), itself only referenced as a data function-pointer (never called directly) fromsub_38DA44- a ~27KB function that reads as a generic widget-type factory registry (name-string -> factory-function-pointer table), not a per-instance construction loop. - Why: Directly answers the question paused back in cont.3 - resumed per this session's explicit user request ("Погоди... живая отладка... ставь breakpoint на конструктор").
- Snapshot:
launcher/mpcore/src/main/cpp/lan_event_injection.hgained the temporaryHook_MapTrackCtor/InstallMapTrackCtorTraceHook;main.cppgained one toggle flag + oneJNI_OnLoadcall. Still on branchlan-event-injection-poc, not yet committed - this hook is investigation-only and should be removed/disabled before that branch is considered final. - Verification: Live logcat output from the running game (not static inference) -
MapTrack ctor #1..#12, all with the same caller address, immediately following the three other hooks' "Installed..." lines at app startup. - Outcome/Next: Since
sub_395B38is registered generically by type name in a factory registry rather than being a fixed per-instance loop, the actual count and identity of constructedMapTrackwidgets is driven entirely by the map screen's layout/prefab data - however many nodes that data types as"MapTrack"(12, matching every pin theHandleEventhook already reaches, none missed). At the time this entry was written, this was read as meaning the 3 special/story pins (incl. the "Most Wanted #10" boss card) must be a different widget class (guessed:im::app::ui::MostWantedLeaderboard) - this guess was wrong, corrected in cont.8 below.
2026-08-07 (cont. 8) — Correction: the "3 special pins" premise itself was false, not the widget-class theory
- What: User corrected the
MostWantedLeaderboardguess from cont.7 - that class is the side-panel listing Blacklist rivals, not anything rendered as a map pin, so it was never a plausible fit. User asked to just open the game and visually check a supposedly-special pin (МАККЛЕЙН/McLane) directly instead of continuing to theorize. Did so: launched the game fresh, panned the map down/around from the default 3-pin view to reveal 5 pins total (Рэйнольдз Лэйн 50%, Кэмерон Драйв 0%, Крюгер Авеню 0%, Макклейн 33%, Петерсон Стрит 100%), and tapped both Макклейн and Петерсон Стрит. Both opened the completely ordinary per-street event-card screen (street races/checkpoint challenges, one with a trophy for a completed event) - no "Most Wanted #10" boss card or anything unusual anywhere. - Why: Directly requested by the user, who was skeptical of the RTTI-based widget-class theory and wanted empirical confirmation before any more code was chased.
- Snapshot: N/A - pure UI inspection via adb screenshots, no code changes.
- Verification: Direct visual confirmation via
adb shell screencapon the real device, tapping into two different pins' actual event-card screens. - Outcome/Next: Combined with cont.7's live count (exactly 12
MapTrackinstances constructed, matching all reachable-via-HandleEventpins with none missing), there is now no evidence the "3 special pins never fireHandleEvent" observation (carried over from an earlier session) ever matched what this save actually shows. The whole investigation is closed as resting on a false or stale premise, not as an unsolved mystery - see[[feedback-verify-ingame-before-re-theory]]for the general lesson about re-verifying carried-over RE premises live before spending more effort explaining them. No code fix was needed since nothing was actually broken; both theMapTrack-constructor logging hook (cont.7) and everything else touched this pass were already reverted/left clean onlan-event-injection-pocbefore this correction was written.
2026-08-08 (cont. 9) — Implemented per-street targeted injection (arbitrary street + arbitrary name); found the visible on-screen streets never go through AddEvent at all
- What: Per user request, revisited
sub_1781BCand the surroundingMapTrack/RefreshEventsstatic analysis to finish the original plan's last step: instead of injecting into every pin unconditionally (temporary "visual confirmation" mode from cont.3), target exactly one chosen street and let the injected card's name be arbitrary. Foundsub_369040(RefreshEvents) reads a{char* begin, char* end}vector atMapTrack+0xE0/+0xE4of plain null-terminated event-group-name C-strings, populated at layout/prefab-bind time and available the momentHandleEvent(evtype=1025)first fires - independent of whether anyRaceEventhas actually resolved yet (unlike the existingTrackName-from-first-event logging, which needs a resolved event). ImplementedMAPTRACK_GROUPNAMEVEC_BEGIN/END_OFFSET, changedInjectSyntheticEventto take an expliciteventNameparameter (previously a fixed constant), and gated injection onstrcmp(groupName, kTargetGroupName) == 0. Live-tested: this correctly injects into exactly one of the 12 reachableMapTrackwidgets, by name, with zero effect on the other 11 - the targeting mechanism itself is proven sound. However, the 12 observed group names are allregion{1,2,4,5}_{foothills,desert,chicago,newyork}_track{1,2,3}- generic career-progression identifiers - and none of them textually or visually correspond to any of the 5 currently-visible/playable on-screen streets (МАККЛЕЙН, ПЕТЕРСОН СТРИТ, РЭЙНОЛЬДЗ ЛЭЙН, КЭМЕРОН ДРАЙВ, КРЮГЕР АВЕНЮ). To resolve the correlation empirically, temporarily broadened injection to fire into all 12 with a per-pin name built from its owngroupName("LAN: <groupName>") and checked every visible pin's card screen (МАККЛЕЙН, ПЕТЕРСОН СТРИТ) - no injected card appeared on either. - Why: Directly requested by the user ("сделаешь предыдущую задачу с инжектом нового события в произвольную улицу с произвольными именем"), after explicitly preferring static analysis over the day's unreliable live-debugging tools.
- Snapshot:
launcher/mpcore/src/main/cpp/lan_event_injection.h/main.cppon branchlan-event-injection-poc, not yet committed - currently contains the targeted-injection changes plus several TEMPORARY diagnostic-only hooks added during this investigation (see below), not yet cleaned up. - Verification: Live logcat confirmation of exact single-target injection (
kTargetGroupNamemode) and of the "inject into all 12, unique names" diagnostic mode; visual confirmation viaadb screencapthat neither МАККЛЕЙН nor ПЕТЕРСОН СТРИТ ever show an injected card regardless of which of the 12 targets is used. - Outcome/Next: Pushed the investigation further to find how the visible streets do get populated, since it's clearly not via the same path. Found
sub_369040(RefreshEvents) has a second caller besidesMapTrack::HandleEvent:sub_368FDC, a thin directRefreshEvents(this)wrapper with zero static call-site xrefs anywhere in the binary - only reachable through aboost::_bi::bind_t<void, MapScreen, shared_ptr<MapTrack> const&>callback object (confirmed via its RTTI typeinfo). Added a temporary compiled hook onsub_368FDC(Hook_MapTrackDirectRefresh/InstallMapTrackDirectRefreshTraceHook) to catch it live - it never fired at all during this session, including after tapping directly into МАККЛЕЙН's screen. Went one level more direct and hookedAddEventitself (sub_369AB0,Hook_AddEvent/InstallAddEventTraceHook, filtering out our own synthetic0xC0FFEE0*-prefixed keys) - the single most authoritative signal, since every real "this street just got a race event" moment must pass through it. Result: 62 genuine engine-initiatedAddEventcalls fired this session, all from the exact same caller address, all targeting the same 12region_*_track*widgets (5-6 events each) - zero calls for any visible street. This conclusively proves the visible/playable streets' events are not added viaAddEventat all during normal play, on this device, in this session - they're most likely already fully deserialized as part of the save-file load itself (baked-in from a previous playthrough) rather than resolved through the runtime prefab-cache system the region/career-track slots use. Was mid-way through testing one more hypothesis (that the earlierkTargetGroupName-matched injection did land correctly on some visible-street's underlying data, but the already-rendered card-list UI just doesn't live-refresh to show it) via a "navigate away and back" test when the app crashed/exited after an unrelated "no connection" network-check dialog; the game was relaunched and the diagnostic-mode injection re-confirmed firing cleanly (12/12, no crash), but the UI-refresh re-test itself was not completed before the user asked to stop here and record progress. Explicitly paused, not resolved - next session should either finish the UI-refresh hypothesis test (navigate to garage/profile and back, then recheck a visible street's card list) or pursue the boost::bind machinery further to find the actual populating mechanism for visible streets. Also outstanding: the temporary diagnostic hooks (group-name/direct-refresh/AddEvent tracing, and the "inject into all 12" diagnostic mode in place of the single-targetkTargetGroupNamematch) are still active in the working tree and should be cleaned up/reverted to the single-target design once the visible-street mechanism is found or the approach changes.
2026-08-08 (cont. 10) — Reverted the temporary diagnostic hooks from cont.9, kept the core targeted-injection feature
- What: Per user request, removed the investigation-only scaffolding added in cont.9 and left the working tree with only the genuinely useful result. Reverted
main.cppentirely viagit checkout(its whole diff was the two diagnostic-hook toggle flags +JNI_OnLoadcalls, nothing else). Inlan_event_injection.h, removedHook_AddEvent/InstallAddEventTraceHook(theAddEvent-level trace) andHook_MapTrackDirectRefresh/InstallMapTrackDirectRefreshTraceHook(thesub_368FDCtrace) in full, and reverted the "inject into all 12 with a per-pin diagnostic name" block back to the intended single-target design:if (kInjectSyntheticEvent && groupName && strcmp(groupName, kTargetGroupName) == 0) InjectSyntheticEvent(mapTrackThis, kSyntheticEventName);. Dropped the now-unused#include <cstdio>(was only needed for the diagnosticsnprintf). Kept everything that constitutes the actual deliverable from cont.9:MAPTRACK_GROUPNAMEVEC_BEGIN/END_OFFSET,kTargetGroupName, thegroupNameread/log insideHook_MapTrackHandleEvent, andInjectSyntheticEvent's expliciteventNameparameter (the "arbitrary name" half of the original request). Added a code comment on the injection gate pointing at this file's PROGRESS.md entry for the still-open "visible streets never call AddEvent" limitation, so it isn't lost context next time this file is read cold. - Why: Directly requested by the user ("Откати временные диагностические хуки"), after the UI-refresh hypothesis test from cont.9 was interrupted by an app crash and progress was recorded as paused.
- Snapshot:
launcher/mpcore/src/main/cpp/lan_event_injection.hon branchlan-event-injection-poc,main.cppback to its last-committed state (ca55b2e). Net diff is now 72 insertions/31 deletions in one file, confirmed to./gradlew :app:assembleDebugcleanly (BUILD SUCCESSFUL). Not yet committed. - Verification:
git diff --statreviewed line-by-line to confirm only diagnostic-only additions were removed and the targeting/naming mechanism was preserved; full rebuild confirms no compile breakage from the removal. - Outcome/Next: Working tree is back to a single coherent, uncommitted feature (per-street-by-groupName injection with an arbitrary card name), still limited to the 12 career-progression slots per cont.9's finding. The visible-on-screen-street population mechanism remains unfound - next session should pick up either the UI-refresh hypothesis test or the
boost::bindlead from cont.9 if that thread is resumed.
2026-08-09 (cont. 11) — New lead on the visible-street mystery: a separate Career component with its own PrefabDatabase, not the generic per-track cache
- What: Per user request, continued the "how do visible streets get populated" investigation from the save/profile-deserialization angle instead of the
boost::bindcallback-wiring angle. Along the way, re-verified (out of caution after noticingAddEvent's own internal dedup-check loop also readsMapTrack+0xE0/+0xE4, comparing elements as raw pointers rather than strings) that theMAPTRACK_GROUPNAMEVEC_BEGIN/END_OFFSETreading inRefreshEventsis still correctly interpreted (confirmed via a fresh full decompile ofsub_369040: thewhile(*++v9);loop unambiguously treats each element as a C-string) - the apparent contradiction is just two different, non-overlapping uses of the same field at different points in the object's lifecycle, not a bug in the already-committed feature. Then searched the binary forSaveGame/ISaveable/Profileclass references and found aboost::_bi::bind_t<..., im::app::metagame::SaveGame, eastl::basic_string<...> const&>callback (aSaveGamemethod taking a slot-name string) and a large family of classes implementingim::app::metagame::ISaveable(AchievementManager,Autolog,CachedSpeedwall,CurrentState,MenuState,Options,Profile, ...). Enumerating the RTTI type-info table further (.data.rel.roaround0xaa5700-0xaa6200) turned upim::app::metagame::Career,im::app::metagame::Progression, and criticallyim::components::PrefabDatabase<im::app::metagame::Career>- a separate,Career-specific prefab database template instantiation, distinct from the generic per-track prefab cache (sub_7566C/sub_242778) thatRefreshEvents/AddEventuse for the 12region_*_track*slots. CheckedCareer's own RTTI base class: it's a plain single-inheritanceim::components::Component(the same baseRaceEvent/CashRewardderive from) - i.e.Careeris a component, attachable to an Actor just like the objects our injection already fabricates. - Why: Directly requested by the user, pursuing the save/profile angle in preference to further boost::bind reverse-engineering.
- Snapshot: N/A - static analysis only (IDA MCP), no code changes this pass.
- Verification: RTTI cross-references read directly from
.data.rel.ro(type_info name strings, single/multi-inheritance base-class records) - not inferred, the class names and thePrefabDatabase<Career>/Componentbase relationship are read literally from the binary's own C++ RTTI metadata. - Outcome/Next: Strong new lead, not yet fully closed: the currently-visible/playable streets are most plausibly populated through this separate
Career/Progression/PrefabDatabase<Career>machinery rather than the genericAddEventpath this whole investigation (cont.3-cont.10) has been built around - which would fully explain why theAddEvent-level trace (cont.9, 62 genuine calls, zero for visible streets) never caught them. Not yet traced:Career's own vtable/methods, how a loadedCareerprefab's data actually reaches aMapTrackwidget's event vector (whether via a still-undiscovered call to the sameAddEvent/MapTrack+0xF0fields using a different key-resolution path, or via an entirely separate mechanismCareerowns itself), and whetherPrefabDatabase<Career>::Load(name, path)(the bound string-taking method glimpsed via theboost::bindsignature) is the actual entry point invoked when the save loads. Next step: findCareer's full vtable (sameget_bytestechnique used earlier forMapTrack's), and look for its constructor/factory xrefs the same wayMapTrack'ssub_395B38factory was found viasub_38DA44's type-name registry -Careeris very likely registered there too, under some type name worth searching for directly (e.g. "Career" as a literal string in the prefab-type registry).
2026-08-09/10 (cont. 12) — Traced Career's vtable and constructor; likely a car-garage component, not the street-population mechanism after all
- What: Continued digging per user request. Found
Career's constructor (sub_239DC0, installs vtableoff_AA5A58, mallocs 76 bytes, baseim::components::Componentctor viasub_670454, zero-initializes two separate 3-word fields at+12/+16/+20and+28/+32/+36- i.e. two vectors) and its factory (sub_239C58), which registers the literal string"Career"into what's very likely the same generic type-name-to-factory registryMapTrackwas found registered in (cont.7'ssub_38DA44/sub_395B38pattern - same_cxa_guard-protected static-init + string-build + registration-call shape). ReadCareer's actual vtable content (10 entries, far smaller thanMapTrack's ~60-entry one, consistent with a lightweightComponent) and decompiled the one entry outside the generic Component-boilerplate address range (sub_23A5C4): it reads a"Cars"property, iterates each entry's"Name"sub-property, resolves it through the same generic prefab-cache resolver (sub_7566C/sub_11B144) used throughout this project, and pushes the result into theCareer+12vector found zeroed in the constructor - i.e. this method populates a list of owned/resolved car prefabs, not tracks or streets. Separately, chased the"Location"property found earlier (read insidesub_2A4D70, already-known from earlier sessions asRaceEvent's own field deserializer) and found it's resolved throughsub_50D5B4the same wayRaceType/FinishLineNoSpawnZoneare (a string-to-enum lookup with a coded default fallback) - i.e.Locationis a category tag stored directly on eachRaceEvent(like a region/city classification), not a live reference to a specificMapTrackwidget. - Why: Continuing the user-directed investigation into the save/profile-deserialization angle from cont.11.
- Snapshot: N/A - static analysis only, no code changes.
- Verification: Vtable slot addresses read directly via
get_bytesonCareer's installed vtable pointer; the"Cars"/"Name"property-read pattern and push-into-+12-vector behavior read directly fromsub_23A5C4's Hex-Rays decompile, not inferred. - Outcome/Next: This tempers cont.11's optimism -
Careerlooks like it's most plausibly the player's garage/owned-cars component (matching itsPrefabDatabase<Career>+"Cars"property + aCareer+12resolved-car-list vector), not a per-street/track unlocking mechanism. The second zeroed vector atCareer+28/+32/+36is still unexplained (populated by neither of the two non-destructor vtable-adjacent code paths inspected so far) and remains a candidate worth checking, but the direct "Career populates visible streets" hypothesis from cont.11 is now weaker than it looked.RaceEvent's own"Location"field is a plain category tag, not a link to a widget - ruled out as the missing mechanism. Still open: how aMapTrackwidget for a currently-visible/playable street gets its event vector populated outside theAddEvent/RefreshEvents/sub_368FDCpaths already ruled out in cont.9. Given the breadth already covered this session (gdb/frida live debugging,AddEvent-level tracing,boost::bindcallback wiring,SaveGame/ISaveable/Career/ProgressionRTTI enumeration) without closing the loop, the next productive step is likely either (a) checking the unexplainedCareer+28/32/36vector's populating code path, or (b) a live compiled-hook trace (proven reliable this session, see cont.7) onCareer's constructor/the"Cars"method to empirically observe when/how manyCareerobjects exist and correlate theirthispointers and any adjacent MapTrack construction timing.
2026-08-10 (cont. 13) — Traced the full Career prefab-loading chain back to Progression's "career" property; second vector still unexplained
- What: Continued chasing
Career's architecture. FoundCareer'sGetComponent<Career>()(sub_25C9EC, confirmed via the samedynamic_cast(component_ptr, Component_typeinfo, Career_typeinfo, 0)shape already seen forRaceEvent/CashReward), traced its one callersub_25C55C(confirmed as the genericPrefabDatabase<Career>::LoadOrGet(name)- hash-lookup-or-insert, same shape as the RaceEvent prefab cache), traced its one callersub_25C404(a thin.prefabs.sb-suffix-ensuring path-builder wrapper), and traced its callers tosub_25A4D4- a callback-registration function that bindssub_25C404under a property name into some type's schema viasub_3E8DA0. Found the actual registration call site,sub_258D2C: it interns the literal string"career"(lowercase) and reads/attaches it as a named property called "career" on a singleton object whose vtable (off_AA5FA0) sits immediately adjacent to the already-known RTTI block forim::app::metagame::Progression/ManagedSingleton<Progression>- i.e.Progression(the same "career progression" singleton already flagged in cont.11) has a lazily-resolved"career"property that, when read, loads a namedCareerprefab file on demand. This is a real, concrete architectural finding: the player's currently-active career is a single namedCareerprefab, loaded on demand viaProgression's "career" property, not a hardcoded/baked object. - Went hunting for what populates
Career's second, still-unexplained vector at+28/+32/+36(the one thing that would confirm or kill the "Career holds the visible streets" hypothesis): found a second caller of the samesub_1738E8push-into-vector helper used by the"Cars"loader,sub_23B854- but decompiling it showed byte-for-byte identical code to the"Cars"loader (sub_23A5C4), just at a different address - i.e. a compiler-duplicated instantiation for an unrelated class that happens to also have a"Cars"-shaped property list, not aCareer-specific sibling method. Dead end for this specific search. - Why: Continuing the user-directed deep dive into the save/profile angle.
- Snapshot: N/A - static analysis only, no code changes.
- Verification: Each link in the
GetComponent<Career> -> LoadOrGet -> path-builder -> callback-registration -> "career"-property-on-Progressionchain confirmed via direct decompile of every function in the xref chain, not inferred from naming alone; thesub_23B854dead-end confirmed by literal decompile-output comparison (identical tosub_23A5C4). - Outcome/Next:
Career+28/32/36's populator is still unfound by pure xref-chasing fromCareer's own vtable/nearby code. Given the architecture is now much clearer (Progressionowns a lazily-loaded, single activeCareerprefab;Careeritself is a smallComponentwith a confirmed"Cars"garage-list property and one still-mysterious second list), the two remaining productive paths are the same two identified in cont.12: (a) find what other property name a different, not-yet-locatedCareer-specific function reads into+28/32/36(the"Cars"-shaped-duplicate dead end suggests brute-force xref chasing fromsub_1738E8won't find it directly - would need to search Career's surrounding code (0x239000-0x23C000ish) more broadly for any function referencinga1+28/+0x1Cas a write target, not just ones sharing the exact same push-helper call), or (b) switch to empirical/live confirmation now that the static architecture is well understood - a compiled hook (this session's consistently reliable technique, see cont.7) onCareer's constructor and the"Cars"-loader, logging the resolved prefab name and the+28/32/36vector's contents once populated, would likely resolve this faster than further blind static xref-chasing.
2026-08-10 (cont. 14) — CONCLUSIVE: Career is the player's car garage, not a street/track mechanism - cont.11's hypothesis is ruled out
- What: Per user's explicit choice to keep pursuing static analysis, did a broad string-literal scan of the entire
Careercode cluster (0x239000-0x23C000, every quoted-string reference in that range, not just targeted guesses) instead of continuing to chase the+0x1Coffset specifically (which had led to a dead-end generic component-resolution utility,sub_239F40, unrelated toCareer's own field layout). Result: every single property name in this entire code region is car-related -"Cars","Unlocked","Purchased","ColourIndex","InstalledParts"(read bysub_23A820/sub_23ADB4, evidently per-car-entry deserializers/serializers for the garage list), plus"CarOwnedRequirement","CarClassOwnedRequirement","ClassOwned","CarOwned"(car-ownership requirement/condition checker classes, likely used to gate purchases or unlocks elsewhere). Zero references to tracks, streets, events, regions, or anything matching theMapTrack/RaceEventdomain anywhere in this cluster. - Why: Directly settles whether
Career(cont.11's lead) is actually relevant to the original "how do visible streets get populated" question, after cont.12/13's narrower probes left it ambiguous. - Snapshot: N/A - static analysis only, no code changes.
- Verification: Exhaustive string-literal enumeration (regex
"[A-Z][a-zA-Z]+") across the full address range IDA attributes to this code cluster, not a handful of guessed keywords - this is as close to a conclusive negative as static analysis can give without full manual disassembly of every byte in range. - Outcome/Next: cont.11's "Career manages visible streets" hypothesis is ruled out.
Career= player's owned-car/garage record (which cars are unlocked, purchased, what colour/parts each has), entirely orthogonal to theMapTrack/street-unlock system this whole investigation (cont.3-cont.14) has been trying to find. TheCareer+28/32/36second vector is now most plausibly also car-related (a staging list during (de)serialization, or a class-requirement cache) rather than anything street-related - not worth further pursuit for this question. The original question - how aMapTrackwidget for a currently-visible/playable street gets its event vector populated outside the already-ruled-outAddEvent/RefreshEvents/sub_368FDC/Careerpaths - remains open after a very thorough multi-session investigation (live debugging infra fights,AddEvent-level tracing,boost::bindcallback-wiring, and now the fullSaveGame/ISaveable/Progression/CareerRTTI family). Given the breadth already covered, the most promising unexplored angles for a future session are: (a)Progressionitself (the singleton that owns the"career"property) may have its own separate street/region-unlock state independent ofCareer- its RTTI/vtable was never directly inspected, only inferred adjacent toCareer's; or (b) pivot from static to the live compiled-hook technique (proven reliable all session, see cont.7) directly onMapTrack's constructor orAddEvent, filtering specifically for calls whose caller address (lr) is not the one knownRefreshEventscall site (0x369210internally) - since anything populating a visible street's event vector must callAddEventfrom somewhere, and cont.9's trace only proved the 12 known calls share onelr, not that no otherlrvalue is possible in a longer/different observation window.
2026-08-10 (cont. 15) — Checked Progression directly per user request: also a dead end, and surfaced a methodology risk worth flagging
- What: Followed up on cont.14's remaining lead (a). Found
Progression's real vtable (_ZTVN2im3app8metagame11ProgressionE, object vptroff_AA5FA0) and its constructor (sub_258E0C, the same function already partially seen in cont.13 - confirmed it's a lazy-initManagedSingleton<Progression>::Instance(), mallocs 80 bytes, single base classManagedSingleton<Progression>only - i.e.Progressiondoes not inheritISaveabledirectly, unlike theAchievementManager/Autolog/Profile/etc. family from cont.11). The vtable itself is minimal - only 2 entries, and decompiling the first (sub_258B30) confirmed it's a plain destructor (releases aboost::shared_ptr-shaped field at instance offset 76 via refcount decrement, nothing else) - i.e.Progressionhas essentially no custom virtual behavior beyond the base singleton/destructor. A broad string-literal scan ofProgression's own tight code cluster (0x258700-0x259400) found almost nothing - just"career"(already known) and"Initialise"(a debug-assert message, not a property) - no second property name to chase, unlikeCareer's rich"Cars"/"Unlocked"/"Purchased"/...set. - Checking the destructor's field layout against the constructor's raw offsets exposed a real analysis gap: the earlier-assumed "
Progression+68/72/76is a 3-word{begin,end,cap}vector, just likeCareer's" was likely wrong - the destructor's refcount-release pattern on offset 76 alone looks much more like a 2-wordboost::shared_ptr<T>(ptr + control-block) at offset 72/76, with offset 68 a separate, single field - not a vector at all. Also flagged (not resolved): the"career"property-registration call (sub_258D2C, cont.13) operates on an object obtained viasub_171610(instance), which may be a separate property-schema/type-descriptor object rather thanProgression's raw 80-byte instance directly - meaning some earlier offset reasoning in this thread may have conflated the instance layout with the schema-descriptor layout. Not disproven, just flagged as an open uncertainty rather than papered over. - Why: Directly requested by the user, following up on cont.14's flagged next step.
- Snapshot: N/A - static analysis only, no code changes.
- Verification: Vtable/constructor addresses and the destructor's field-release pattern read directly from decompiles, not inferred; the string-literal scan used the same exhaustive regex technique as cont.14's conclusive
Careerscan. - Outcome/Next:
Progressionis now also a dead end for this specific question via the techniques that worked forCareer- it's a near-empty singleton wrapper whose only substantive job (visible in its own code) is lazily resolving the"career"property. This investigation has now ruled out every architecturally-plausible mechanism found via static analysis (AddEvent/RefreshEvents/sub_368FDCbroadcast paths in cont.9,Careerin cont.11-14,Progressionin cont.15) without finding the actual one. Given the cumulative depth (multiple sessions, live-debugging infrastructure work, and now several RTTI-family deep-dives), further blind static xref-chasing has clearly diminishing returns. The two concrete options left on the table, unchanged from cont.14: (a) untangle the generic property/schema system itself (thesub_171610/sub_3E8DA0/sub_67xxxx-family functions this whole investigation kept bumping into without fully understanding) - a substantial standalone task, not a quick follow-up; or (b) go empirical with a live compiled-hook trace onAddEventwith a longer observation window and nolr-based filtering, to catch any call this session's shorter traces might have missed. Recommend checking in with the user before choosing either, given the effort-to-progress ratio has been poor for the last severalcont.entries.
2026-08-10 (cont. 16) — Option (b) executed: extended live AddEvent trace across heavy in-app interaction, conclusively found nothing new
- What: Per user's choice ("давай b"), re-added the
AddEvent-level trace hook (Hook_AddEvent/InstallAddEventTraceHook, identical to cont.9's, nolr-based filtering - logs every genuine call regardless of caller) as a temporary addition, rebuilt, installed, and ran an extended, heavily-interactive session instead of the short launch-time-only window cont.9 used: tapped into МАККЛЕЙН and ПЕТЕРСОН СТРИТ's card screens (and each of their individual race-detail sub-screens, including opening "ТЬМА СГУЩАЕТСЯ" and "ПОБУДКА" fully), navigated through the Гараж (garage, showing 3/41 cars owned), the MOST WANTED/Blacklist panel (tapped into a locked rival entry), used the EASYDRIVE "next event" shortcut (which opened an existing McLane race), and found + opened a fifth, previously-unvisited pin - ЦЕНТР ГОРОДА ("Центр города"/City Center, 100% complete, home to the game's intro race "Перед вами Fairhaven"). Cleared logcat mid-session to isolate fresh activity from launch-time noise. - Why: Directly executes the option the user chose after cont.15 left both
CareerandProgressionas dead ends, to check whether a longer/more varied observation window catches a call path the short traces missed. - Snapshot:
launcher/mpcore/src/main/cpp/lan_event_injection.h/main.cpptemporarily gained the hook again during this pass; both fully reverted back to the clean, committedlan-event-injection-pocstate (git statusempty) once the trace was complete - no net changes left in the working tree. - Verification: Live logcat capture across the whole interactive session - 186 genuine
AddEventcalls captured post-clear (on top of an initial 62 from launch, confirmed to be the same call site replayed across two app launches by checking the static/module-relative offset behind the differing ASLR-shiftedlrvalues), all 186 sharing the exact same caller address and the exact same 12region_*_track*group names already known since cont.9 - zero new call sites, zero new group names, despite deliberately visiting every visible pin (including a fifth, previously-unchecked one, ЦЕНТР ГОРОДА) and every major UI screen the game has. - Outcome/Next: This is now about as thorough a negative result as this investigation can produce with the tools available: exhaustive static RTTI/xref analysis (cont.9-15) and an exhaustive live trace across real interactive play (this entry) both agree that
AddEventis never called for any visible/playable street, under any observed circumstance, this session. Whatever populates ЦЕНТР ГОРОДА/МАККЛЕЙН/ПЕТЕРСОН СТРИТ/etc.'s event vectors happens through a mechanism this investigation has not located - most likely a generic, data-driven property/schema deserialization path (see cont.15's flaggedsub_171610-family uncertainty) that doesn't route through the named functions this whole investigation has been built around, and would require understanding that broader subsystem from scratch to find. Recommend treating this specific question as closed for now (not answered, but exhaustively searched via every technique available this session) unless/until the user wants to invest in reverse-engineering the generic property/schema system as its own standalone effort.
2026-08-10 (cont. 17) — Resolved sub_171610: it's a plain singleton getter, not a hidden subsystem - closes cont.15's flagged uncertainty with no new lead
- What: Per user request, decompiled
sub_171610in isolation instead of as a side-note. It turned out to be trivial:int sub_171610() { return dword_AD40A8 ? dword_AD40A8 : (assert-log "Not initialised", dword_AD40A8); }- i.e. exactlyManagedSingleton<Progression>::GetInstance(), reading the same global (dword_AD40A8) thatProgression's own constructor (sub_258E0C, cont.13/15) sets. This resolves cont.15's flagged uncertainty:sub_258D2C'sv3 = sub_171610(v2)genuinely operates onProgression's real 80-byte instance, not a separate schema-descriptor object as speculated - the earlier offset-based reasoning aboutProgression+68/72/76was on solid ground after all, it just didn't find anything street-related there (cont.15's negative result stands, now on firmer footing). - Also decompiled the actual property-registration primitive,
sub_3E8DA0(called fromsub_25A4D4, cont.13, to bind the"career"string to its loader callback): it's a thin wrapper that packages the caller's(name, callback)into a generic boost::function-shaped object and calls a virtual method at vtable+28 on a separate registry singleton (sub_40E8E8()- yet another lazy-init_cxa_guard-protected singleton, 376 bytes, no RTTI name found nearby). I.e.sub_3E8DA0(registry, name, callback, ...)=PropertyRegistry::RegisterHandler(name, callback)- a single, central, generic name-to-handler registry thatCareer's"career"property (and by extension, presumably every other named property this whole investigation has bumped into -"TrackId","Cars","Location", etc.) all funnel through. - Why: Directly requested by the user, to settle whether this function hid anything relevant rather than leaving it as an open flag.
- Snapshot: N/A - static analysis only, no code changes.
- Verification: Both functions read directly via Hex-Rays decompile; the
dword_AD40A8identity match againstProgression's own constructor confirmed by direct comparison against cont.13's decompile ofsub_258E0C, not inferred from naming. - Outcome/Next: No new lead toward the visible-street mystery.
sub_171610/sub_3E8DA0/sub_40E8E8are exactly what they look like - a standard lazy-singleton + central property-registry pattern used pervasively across theim::app::metagamenamespace for prefab/save deserialization plumbing, not a hidden mechanism specific to streets. This is genuinely generic infrastructure (every "metagame" class this investigation has touched -Career,Progression, and implicitlyRaceEvent/CashReward/MapTrackthemselves - registers its named properties through some instance of this same pattern), so "reverse-engineer the property system to find the street mechanism" was never going to shortcut past the real problem: finding which class registers a street/location-relevant property and where that property's callback ends up touching aMapTrack. That remains genuinely unlocated. This closes out thesub_171610side-thread cleanly - cont.16's conclusion (treat the core visible-street question as exhaustively searched and closed for this session) stands unchanged.
2026-08-10 (cont. 18) — Answered directly: TrackId/Location aren't registered anywhere - they're plain prefab data fields, not sub_3E8DA0 bindings
- What: Per user request, searched specifically for where
"TrackId"or"Location"get registered forMapTrack. Found the"TrackId"string exists exactly once in.rodata(0xa19218) with zero code cross-references to it - consistent with whatRefreshEvents(sub_369040, cont.9) already showed: the actual usage builds the string byte-by-byte on the stack rather than referencing a shared literal. Combined withsub_3684CC's known implementation (an FNV-hash lookup into a per-instance dictionary stored on the calling object itself, not a query againstsub_40E8E8's central registry), this confirmsTrackId/Locationare generic deserialized data fields in a per-object property dictionary populated whenever any prefab loads - not named handlers bound viasub_3E8DA0'sRegisterHandlerpattern the way"career"was (cont.13). There is no single "registration site" for them to find, because they aren't behaviorally registered at all. - While confirming this, traced
sub_3E8DA0's callersub_7814Cback to its own caller,sub_78004- and this turned out to be a significant, useful find in its own right: it registers the actual prefab-cache path roots the whole project has been relying on -"published/data/cars"(offset+416),"published/data/races"(offset+320- the exact same offset our ownlan_event_injection.huses asGetCacheContext(mapTrackThis) + 320, now confirmed by name, not just by observed behavior), and then callssub_1F19A0(the large function from cont.15/16), which itself registers"published/prefabs/tracks/"(loader callbacksub_1F39E0, shared with an identical"published/prefabs/garage/"registration right after it) - i.e. the generic file-path-based prefab loader for track/scene content, a different registration axis entirely from the per-property one. - Why: Directly requested by the user, to settle the specific question left open at the end of cont.17.
- Snapshot: N/A - static analysis only, no code changes.
- Verification:
"TrackId"'s zero-xref status confirmed via direct search;sub_78004's decompile shows the literal"published/data/cars"/"published/data/races"/(insidesub_1F19A0)"published/prefabs/tracks/"strings and their exact byte offsets, not inferred. - Outcome/Next: Directly answers the user's question:
TrackId/Locationhave no registration site because they're passive data, not registered behavior. Confirms+320= the"published/data/races"prefab-cache root by name (previously only known by observed offset behavior). Does not open a new lead on the visible-street mystery - if anything it reinforces cont.16's conclusion, since the only two prefab-path roots found in this whole cache-initialisation function are for cars and races/tracks, both already-explored territory, with nothing resembling a third "local city streets" root. Recommend treating both thesub_171610side-thread and the core visible-street question as closed unless a genuinely new angle presents itself.
2026-08-10 (cont. 19) — Design pivot: lobby UX schema fixed in ARCHITECTURE.md §4a; car-upgrade screen correlated to CarMod, not CarPart
- What: Project direction shifted from RE-driven investigation to product/UX design for the multiplayer lobby (network layer explicitly deprioritized to last stage per user). Settled on Scenario 1 (Compose/View overlay → create lobby → pick track → native car-select sub-flow → ready checkmark) over Scenario 2 (lobby cards on real street pins), since the latter depends on the now-paused visible-streets-investigation. Wrote the full
Lobby/LobbyPlayer/ReadyStatedata schema into ARCHITECTURE.md §4a (new section), including the overlay entry-button decision (Compose/View, not native - consistent with §4's BitmapGraphics=pixels-only finding). - Separately, live-inspected the in-game car-upgrade screen (2 slots, "ВЫБОР УЛУЧШЕНИЯ" list) per user request and correlated it against binary/data findings: it's driven by
CarMod/CarDescription.Mods(Name/Description/Price/Value/ModType), confirmed via 7/7 exact price matches againstalfaromeo_4c_2012_desc.prefabs.sb's realCarModentries (TYRES_REINFLATING,CHASSIS_REINFORCED,BODY_IMPACT,CHASSIS_LIGHTWEIGHT,POWERTRAIN_POWERPACK,NITROUS_BURN,NITROUS_EARN) - not the separateim::app::car::CarPart/UpgradePartscatalog flagged in the prior session, which has no live instance data in any checked file and appears unused/vestigial in this build (noskipCost/orderTimeMinutes-style timer-gating fields exist onCarModat all, consistent with the user's recollection thatskipCostis always 0). This also supersedes an earlier guess thatModType/Categorywas a shared grouping enum - real data shows it's a unique ID per specific mod option, not a category. - Why: User-directed pivot toward UX design ("нужно сформировать дизайн мультиплеера"), then explicit request to correlate the live upgrade screen with binary findings ("Ты можешь увидеть этот экран сейчас в игре... исследуй этот экран и спсоставь это с тем что есть в бинарнике").
- Snapshot: N/A - design doc + static/data analysis only, no code changes.
- Verification: Screenshot-vs-data price matching (7/7 exact), not guessed;
CarMod/CarDescriptionstruct schemas read directly fromNFSMW12MobileTools-unpacked JSON. - Outcome/Next:
LobbyPlayer.mods: [int|null, int|null](twoCarMod.ModTypeslots) added to the schema per user request, for cross-player visibility (not balance computation - client resolves name/icon from the car's own already-loadedModscatalog). Design schema is now considered fixed pending implementation; next work returns to the pre-schema task list (see cont.20).
2026-08-10 (cont. 20) — Found the real native car-select screen: CarSelectionWidget + a full data-driven Flow graph (garage_pre_event → garage_pre_event_part2 → /race/race)
- What: Investigating design prerequisite #2 for Scenario 1 ("show the native car-select screen on demand"). Found
CarSelectionWidget- a real RTTI-confirmed class (constructorsub_35E5D4, vtableoff_AAAD54) registered by name in the same generic type-registry (sub_38DA44) previously used to findMapTrack's factory, via factory wrappersub_395718. Traced its origin togame_cache/published/layouts/layouts.sb(a single monolithic UI-layout database, loaded wholesale at startup bysub_75E40/AppBootstrap), where it's thewidget_classof a layout node namedcar_select_loadout(folder_tier_selection_support_layouts). - Found the actual Flow-graph screens that use this layout, in
game_cache/published/flow/menus/garage/(part of a 164-file/published/flow/directory, also loaded at startup, alongside a realFlowManagerclass):garage_pre_event.sb(screenId="RestrictedGarage", layoutcar_select_new, entered directly fromBACK→/menus/map_overworld, i.e. this is what a map race-pin tap opens) transitions onCONTINUEtogarage_pre_event_part2.sb(screenId="RestrictedGarageLoadout", layoutcar_select_loadout- confirmed to be the exact 2-slot upgrade screen investigated in cont.19), which itself transitions onCONTINUEto/race/race. Screens, transitions, and popups (NEED_CASH,FUEL_ALERT, etc.) are all polymorphic, type-registered objects instantiated by name, the same generic pattern as everything else in this binary. - Why: Directly requested by the user as the concrete next step after fixing the lobby schema ("начни с пункта 2" - the native car-select prerequisite from the user's own Scenario 1 breakdown).
- Snapshot: N/A - static/data analysis only, no code changes.
- Verification:
CarSelectionWidgetRTTI strings and registry entry read directly via decompile/xref, not inferred; layout/flow node names and transition graph read directly fromNFSMW12MobileTools-unpacked JSON of the actual shipped.sbfiles, not guessed. - Outcome/Next: This conclusively answers what needs to be invoked (the existing
garage_pre_event/garage_pre_event_part2Flow screens - no need to build a new UI or callCarSelectionWidget's constructor directly) and why it already looks right (it's the same screen normal races use, already class-filterable viacar_select_new's layout). Not yet found:FlowManager's runtime "transition to node/screen by name" trigger function - the actual call our hook would need to invoke to jump into this graph on demand from the lobby overlay, bypassing the map-pin tap.sub_15663C(found via the"FlowManager "log-tag string) turned out to be a relative-path-resolution utility, not the transition trigger itself - a dead end for this specific sub-question, not the class. Next concrete step: findFlowManager's public transition/goto API (likely dispatched via namedOutputsevents likeCONTINUE, given the flow data's own shape).
2026-08-10 (cont. 21) — Live hook on CarSelectionWidget's factory installed and tested: never fires, even navigating to the exact target screen fresh - identification was likely wrong, hook reverted
- What: Per user's explicit choice ("живой compiled-hook") to empirically find
FlowManager's transition-trigger call, added a temporary trace hook (Hook_CarSelectionWidgetFactory/InstallCarSelectionWidgetFactoryTraceHook, offset0x395718, same trampoline pattern as the other hooks inmain.cpp) meant to capturelrvia__builtin_return_address(0)whenCarSelectionWidget's factory runs, revealing the generic widget-dispatcher's address. Built, installed on the Pixel 6a, and confirmed via a continuous backgroundadb logcatcapture (started before app launch, to survive the log-eviction problem that hid earlier hook-install confirmations) that all 4 hooks, including this one, installed successfully ("Installed CarSelectionWidget factory trace hook at 0xba119718...", matchinglibapp_base(0xB9D84000) + 0x395718exactly). - Live-navigated a fresh app launch through the real in-game flow (map → МАККЛЕЙН → ПОБУДКА → confirm race → confirm car) all the way to the
car_select_loadoutscreen (2 empty upgrade slots, visually confirmed via screenshot) - the exact screen this hook should fire for ifCarSelectionWidgetis really what backs it. The hook never fired - zero"CarSelectionWidget factory hit"log lines in the full captured session, despite the hook being confirmed installed and despite reaching the target screen from a clean process start (ruling out "already constructed earlier this session" as an explanation). - Why: Directly executes the user's chosen next step from cont.20's two remaining options (live hook vs. further static tracing).
- Snapshot: Hook code was added to
launcher/mpcore/src/main/cpp/main.cpp, built, and tested live, then fully reverted viagit checkout --once the negative result was clear (git status --shortempty on thelan-event-injection-pocbranch) - no net changes left in the working tree, consistent with this project's standing practice for temporary RE-discovery hooks. - Verification: Hook-install success confirmed by direct log line (not assumed); the negative result (never fires) was checked against a fresh, from-scratch app launch and a live, deliberate navigation to the precise target screen, not a stale/cached session - a real empirical negative, not an artifact of missed logs (solved the earlier log-eviction risk specifically by capturing continuously from before launch).
- Outcome/Next: The cont.20 identification of
sub_395718/CarSelectionWidgetas the widget backing thecar_select_loadoutscreen is not confirmed live and is probably wrong - either the"CarSelectionWidget"/"car_select_loadout"property pairing found inlayouts.sbbelongs to a different, unrelated layout node than the one actually shown (the file has 39k+DATA_Elements, so a coincidental name collision is plausible), or this specific screen's root widget is constructed through an entirely different class/path not yet identified. Recommend not sinking further effort into identifyingFlowManager's exact transition-trigger call via this specific angle - the cumulative effort-to-progress ratio on this narrow sub-question has been poor across cont.20-21. The higher-value, already-solid finding from cont.20 stands on its own regardless: thegarage_pre_event/garage_pre_event_part2//race/raceFlow graph is real, data-driven, and is the right target to reach somehow - only the precise low-level mechanism for triggering it on demand (as opposed to via the existing map-pin tap path) remains unsolved. If this is revisited later, a more promising angle than chasing more factory functions would be to traceFlowManager::Update(sub_777B8, confirmed by name) and its neighboring calls (sub_156530/sub_1571E0/sub_157058/sub_1577F4) directly, or to hook at theOutputs/CONTINUEdispatch level referenced bysub_184CC4's registrations instead of a specific widget's constructor. - Bonus, unrelated to RE: while debugging on-device, found and fixed a GrapheneOS/Samsung device-testing obstacle worth remembering for future sessions - the Pixel 6a's
screen_off_pocket=1system setting was causing a persistent "Защита от случайного касания" (accidental-touch-protection) guard overlay to intercept nearly everyadb input tap, making UI automation extremely unreliable. Fixed viaadb shell settings put system screen_off_pocket 0. Also useful:adb shell wm dismiss-keyguardreliably bypasses the lock screen without needing swipe-gesture coordinates whendeviceLocked=0.
2026-08-10 (cont. 22) — Solved point 2 via a different route: synthetic-touch replay through a new native<->Kotlin GameEvents bridge, fully automated map-load-to-car-select-loadout chain confirmed live
- What: Per user's suggestion, abandoned the
FlowManager-internals angle entirely in favor of replaying the already-proven-working tap sequence (map pin -> event card -> confirm race -> confirm car) programmatically instead of reverse-engineering the transition call. Built the general native<->Kotlin event bridge the user explicitly asked for, plus a first consumer:launcher/mpcore/src/main/cpp/game_events.h(new, header-only): cachesJavaVM*/a global ref tonfs.mod.mpcore.GameEventsduringJNI_OnLoad(classloader-safe -FindClassonly ever called from the properly-scopedJNI_OnLoadthread, never from an arbitrary later-attached native thread), then exposesFireMapLoaded()/FireRaceStarted()/FireRaceEnded()whichCallStaticVoidMethodinto Kotlin, handling attach/detach for whichever native thread calls them.launcher/mpcore/src/main/java/nfs/mod/mpcore/GameEvents.kt(new):GameEventListenerinterface (onMapLoaded/onRaceStarted/onRaceEnded, all default no-op) +GameEventsobject as the register/dispatch hub, called from native via the@JvmStatic dispatch*()methods.launcher/mpcore/src/main/java/nfs/mod/mpcore/GameInput.kt(new): aSyntheticInputDispatcherinterface +GameInput.dispatcherholder - needed becausempcoreis a separate Gradle moduleappdepends on (not vice versa), so it can't referenceGameActivityMainby type without a circular module dependency;GameActivityMainimplements the interface and hands itself in instead.GameActivityMain.kt: addeddispatchSyntheticTap(x, y)- synthesizesACTION_DOWN/ACTION_UPMotionEvents directly intogameGLSurfaceView.dispatchTouchEvent().launcher/mpcore/src/main/cpp/lan_event_injection.h:Hook_MapTrackHandleEventnow callsFireMapLoaded()once (static bool guard) on the firstEVENT_TYPE_FLOW_SET_LAYOUT_SCREENcallback - a coarse but working "map screen is up" proxy, since a real map-screen-lifecycle hook hasn't been found.launcher/mpcore/src/main/java/nfs/mod/mpcore/CarSelectLoadoutTestTrigger.kt(new, explicitly throwaway/test-only per the user - "не должна быть UX-friendly"): registers aGameEventListenerwhoseonMapLoaded()schedules 4 hardcoded taps (map pin, event card, confirm race, confirm car) 2s apart starting 3s after the map loads, viaHandler.postDelayed.
- Bug found and fixed along the way: the first end-to-end run produced a fully clean log trail (JNI bridge init -> listener registered ->
dispatchMapLoadedfired from the game's own GLThread -> all 4 taps dispatched ->dispatchTouchEventreturnedconsumed=truefor every one) but nothing happened on screen. Root cause, found by readingGameGLSurfaceView.java:onTouchEvent()branches onevent.getSource()(SOURCE_TOUCHSCREEN/etc.) before forwarding to the native touch handler (nativeTouchScreenEvent/nativeTouchPadEvent) - a plainMotionEvent.obtain(...)defaults toSOURCE_UNKNOWN(0), matching neither branch, so the View layer "consumes" the event (returnstrue, satisfyingdispatchTouchEvent's contract) while silently dropping it before it ever reaches the game engine. Fixed with one line per event:down.source = InputDevice.SOURCE_TOUCHSCREEN(and same for theACTION_UPevent). - Why: Direct implementation of the user's explicit request/idea - trigger the car-select screen via a delay-after-map-load instead of chasing
FlowManager's internal API, plus build it as a reusable general event-bridge class rather than a one-off. - Snapshot: All changes are additive new files + small hook-site edits, left in place (not reverted) since this is a working building block, not a dead-end trace hook.
git statusonlaunchershows the new/modified files, not yet committed. - Verification: Fully live-verified, twice - first run reproduced the
SOURCE_UNKNOWNbug consistently (clean logs, zero visible effect); after the one-line fix, a from-cold-boot run (fresh install, fresh process) produced the exact same clean log trail and landed correctly on thecar_select_loadoutscreen (2 empty upgrade slots, screenshot-confirmed) with zero manual taps - the whole map-load -> pin -> event -> race-confirm -> car-confirm chain played automatically. - Outcome/Next: Point 2 (show the native car-select screen on demand) is now solved end-to-end, via touch-replay rather than a FlowManager API call - arguably a more robust approach anyway, since it only depends on the already-proven, stable UI touch path rather than an unversioned internal engine function. Known limitations of the current test wiring, to fix before this becomes a real feature: (1) tap coordinates are hardcoded to one device/resolution/save state - a real implementation needs to resolve target coordinates at runtime (e.g. from the injected event's actual
MapTrackwidget screen-space bounds) rather than hardcoding; (2) it currently always targets the one fixed LAN-injection career slot (kTargetGroupName) and the specific "ПОБУДКА" event on it - needs generalizing to an arbitrary lobby-selected track once dynamictrackId(subtask/point 1) is wired up; (3)onMapLoaded's trigger (firstMapTrack::HandleEventcall) is a coarse proxy, not a real screen-shown hook.onRaceStarted/onRaceEndedare scaffolded inGameEventsbut have no real native trigger wired up yet - next candidates if pursued: the race-FSM load functions fromRaceLoaderTask_LoadRaceFSM/RaceLoaderTask_DispatchInitialFSMEvents(already named, cont. from much earlier sessions) foronRaceStarted, and whatever shows the post-race results screen foronRaceEnded.
2026-08-10 (cont. 23) — De-hardcoding tap coordinates: found a real bounds rect and a real "scroll" camera entity via static RE, but live-crashed trying to read it - paused, hook disabled
- What: Per user's explicit request ("убери хардкод координат, читай реальные bounds MapTrack-виджета"), dumped
MapTrack's full memory live and found a consistent 4-float block at instance offsets+0x44/+0x48/+0x4C/+0x50={left, top, right, bottom}- same ~721x244 size across all 12 career-placeholderMapTracks, only position differs (table of all 12 in the session transcript). Empirically confirmed this rect is not simple screen-pixel space (values up to ~3880, camera-independent, don't match known on-screen tap positions under any simple linear hypothesis) - it's world/layout-space, consistent with the map being a tilted/perspective 3D scene rather than a flat pannable 2D canvas. - User confirmed the game has only one map (no separate region-select screen), ruling out an earlier hypothesis that the
region1_foothills/etc.-named rects belonged to a different, unvisited screen. - Static RE (per user's explicit direction, "искать camera/projection карты") found a strong lead:
im::app::flow::nfs::MapScreen(RTTI-confirmed, ctorsub_1781BC, vtable_ZTVN2im3app4flow3nfs9MapScreenE/off_A9F420) registers a named layout entity"scroll"viasub_1332B8(a genericFindOrCreateLayoutEntity(registry, name)- same shared Transform-based object family asMapTrack/CarSelectionWidget, base ctorsub_4D39D0), storing the raw entity pointer atMapScreen+0x1D0. Since this entity shares the same Transform layout (position floats at instance+36/+40, scale at +44/+48) already reverse-engineered fromMapTrack's own base class, it's a strong candidate for the map's live pan/zoom state - no separate"zoom"-named entity was found nearby, so scale (+44/+48) may double as zoom. - Bug/incident: added a temporary hook on
sub_1781BC(InstallMapScreenCtorTraceHookinmain.cpp) to capture the liveMapScreen*into a new global (g_mapScreenInstance), plus a read of the "scroll" entity's position/scale inHook_MapTrackHandleEvent(lan_event_injection.h). This reproducibly crashed the game (SIGSEGV, fault addr0x0, in the GLThread) on every launch, before ever logging a single successful hook fire - confirmed via isolation (disabling just this one hook,kEnableMapScreenCtorTraceHook = false, removed the crash entirely on a clean relaunch; everything else - including the already-workingonMapLoadedautomated tap chain from cont.22 - stayed stable). Root cause not yet found:sub_1781BCdecompiles to a genuinely massive (~62,000-char) function - likely a broader screen-setup/orchestration routine, not a small self-contained constructor - and the crash could stem from something more subtle than the trampoline pattern itself (which is identical to 5+ other hooks that work fine elsewhere in this codebase). The sibling function sharing the same vtable symbol,sub_17B8FC, was checked and is MapScreen's destructor (resets vtable to base, releases ~15 chained shared_ptr members) - not a safer alternative hook point. - Why: Direct continuation of the user's own explicit two-part request this session (de-hardcode taps; if that needs deeper work, go after the camera/projection system rather than stopping at "screen coordinates don't match").
- Snapshot:
InstallMapScreenCtorTraceHook/Hook_MapScreenCtor/g_mapScreenInstanceand the scroll-entity read inHook_MapTrackHandleEventare left in the source, but disabled (kEnableMapScreenCtorTraceHook = false) - the currently-built/installed APK on the test device is the stable (hook-off) variant. The still-enabledMapTrackrect dump (+0x44..+0x50, unconditional per HandleEvent call) remains active and harmless (never crashed on its own). - Verification: Crash reproduced twice in a row with the hook on (100% repro on this device/build), then confirmed absent on a clean rebuild+relaunch with only that one hook flipped off - a real isolated cause, not a guess.
- Outcome/Next: The
"scroll"entity lead is genuine and well-supported by static evidence, but actually reading it live has an unresolved native crash blocking it. This has now cost a full rebuild/install/crash-debug cycle without reaching a working coordinate transform, on top of the earlier failed 1:1-scale and world-canvas hypotheses from earlier in cont.23. Recommend a checkpoint with the user before continuing further: either (a) debug thesub_1781BChook crash itself (unclear how deep - could be a one-line fix or a genuinely awkward hook target), or (b) find a different, safer way to reach a liveMapScreen*(e.g. hook a smaller method that takesMapScreen* thisas an existing parameter, rather than its huge constructor -sub_1817A8/sub_18184C/sub_1818F0/sub_181A38from theboost::bindsetup code earlier in this investigation all referenceMapScreen-typed callbacks and are much smaller, untried), or (c) pause the fully-dynamic-coordinate goal here and fall back to the already-working hardcoded-coordinate test chain (cont.22) as a documented interim limitation.
2026-08-10 (cont. 24) — Found the real coordinate transform safely (no crash), got within a real pin's hit-area of the target but not exact yet
- What: Per user's request, checked
sub_1817A8/sub_18184Cfirst - both decompile to pureboost::_bi::bind_ttype-erasure "manager" functions (opcode-dispatched clone/destroy/typeid-compare/get-typeinfo on the wrapper's own small-buffer storage), never touching a liveMapScreeninstance. Confirmed dead end, not pursued further. - Instead, recognized that
sub_17C120- the functionsub_1781BC's crash was trying to reach data from - is already hooked and proven stable all session: it'sMapScreen's per-frameTick, the exact functionInstallSoakTestDisableHook(much earlier session) already wraps safely. Decompiling it in full for this task revealed camera-clamping code operating directly onMapScreen+316/+320(float scroll x/y, no separate "scroll" entity needed after all) and two rect-bearing pointers atMapScreen+456("content", the full scrollable map bounds) andMapScreen+464("viewport", the visible window) - both using the same 4-float rect shape (indices 17-20) already found onMapTrack. - Extended the existing, already-safe
Hook_MapScreenTick(inlan_event_injection.h) to log these three pieces of data once, ~1s after aMapScreenstarts ticking (letting the camera settle) - confirmed zero crashes across a fresh install+launch. Live values captured:scroll=(1557.0, 1622.5),content=[-3, 0, 4280, 3159],viewport=[0, 0, 1993, 928]. - Tested the simplest hypothesis - plain translation,
screen = world - scroll, no extra scale factor (justified bycontent's ~4280x3159 span andviewport's ~1993x928 span both being roughly screen-pixel-scale, not wildly different orders of magnitude) - againstkTargetGroupName's ("region1_foothills_track1") already-known rect (left=2990 top=1995 right=3713 bottom=2238, center(3351.5, 2116.5)): predicted tap point(1794.5, 494.0). Live-tapped that exact point on the actual device. - Result: landed inside a real pin's hit-area (ПЕТЕРСОН СТРИТ's event-list screen opened) - not empty space, but not the intended target either. This is a meaningfully positive result (the transform is in the right neighborhood, not off by an order of magnitude or a wrong axis), but not yet precise/unambiguous enough to reliably hit the intended (likely much smaller, currently-invisible/locked) target pin specifically, especially if hit-areas from different pins overlap in screen space.
- Why: Direct continuation of the user's chosen next step (try the smaller
sub_1817A8/sub_18184Cfunctions first), which led to recognizing the safer, already-provenTickhook as the better vehicle once those two were ruled out. - Snapshot:
Hook_MapScreenTickinlan_event_injection.hnow includes the one-shot scroll/content/viewport logging (harmless, gated behind atickCount > 60+ one-shot bool, same file already committed to logging patterns elsewhere in this hook).kEnableMapScreenCtorTraceHookremainsfalse(the crashing hook from cont.23 is still disabled, unused code left in place but inert). - Verification: Crash-free across a fresh install+launch (checked directly, not assumed); the transform hypothesis was tested by an actual live tap at the computed coordinate, not just eyeballed - a real (if not fully conclusive) empirical result.
- Outcome/Next: Very close, not yet finished. Open questions before this is a solid, reusable "get real screen coordinates for any MapTrack" primitive: (1) is the remaining offset a genuine small transform error (missing a scale factor, a viewport-origin offset, or the ~227px/~152px right/bottom UI-chrome margins noted between
viewport(1993x928) and the full device screen (2220x1080) not being accounted for), or (2) is it actually correct and the mismatch is becausekTargetGroupName's hidden/locked pin and the real "ПЕТЕРСОН СТРИТ" pin's hit-areas simply overlap at that point, and Android/the engine resolved the tap to whichever pin was checked/on top first? Recommend a follow-up empirical pass: dump ПЕТЕРСОН СТРИТ's own screen-space hit-rect too (would need a hook that fires for real streets, still not found - see cont.20/21's dead end on that specific point) to check for overlap directly, or simply try a small grid of nearby taps around(1794.5, 494.0)to see if a different nearby point lands on the actual injected "LAN: Test Lobby" card instead.
2026-08-10 (cont. 25) — Course correction: synthetic touch-replay ruled out for production; found the real FlowNode::FireOutput-equivalent primitive instead
- What: User explicitly corrected the direction this investigation had drifted into: synthetic taps (cont.22-24) are only valid as an RE/verification tool ("ходить по меню и смотреть изменения") - never as the actual production trigger mechanism, since a real multiplayer feature can't depend on hardcoded/calibrated screen coordinates, camera state, or device resolution ("с точки зрения мультиплеера это ад"). This retires the whole coordinate-calibration sub-effort (cont.23/24) as a test-only artifact, not a step toward the real implementation - correctly refocuses back to cont.20/21's original goal: find
FlowManager's actual internal transition-trigger API. - Resuming that search, re-examined
sub_1BB59C(seen earlier insideMapScreen::Tick/sub_17C120's handling of a"NEXT_EVENT"string) - it's a 2-instruction adjustor thunk (LDR R0,[R0,#8]; B sub_1581A0), forwarding tosub_1581A0(flowNode, outputNamePtr, ctx)withMapScreen+8(a Flow-node object, notMapScreenitself) as the real first argument. - Decompiled
sub_1581A0in full: guards against a transition already being queued (flowNode+256/+260/+52/+44), then callssub_159684(*(flowNode+28), outputNamePtr)to check whether the node's schema actually defines an output with that name; if yes, queues the transition request (stores the name + a refcounted context arg intoflowNode+56.. +64, setsflowNode+52=1) forFlowManager::Updateto process on a later frame; if no, logs"Node <x> has no output for <y>. Ignoring"- a literal, unambiguous confirmation this is the engine's ownFlowNode::FireOutput(name)(or equivalent) primitive, the exact same mechanism that executesOutputs["CONTINUE"] -> "garage_pre_event_part2"-style transitions already seen in the.sbflow-graph data (cont.20). - Why: Direct continuation of the (re-corrected) goal - a real, coordinate-independent way to trigger Flow-screen transitions programmatically.
- Snapshot: Static analysis only, no code changes this entry.
- Verification: Read directly from Hex-Rays decompile of
sub_1581A0/sub_1BB59C, including the literal"Node "/" has no output for "/". Ignoring"log-string reconstruction, which is about as close to ground-truth confirmation as static analysis gets without a live test. - Outcome/Next: This is likely the real trigger primitive Point 2 has been looking for since cont.20. Not yet done: (1) decompile
sub_159684(the has-output-named-X check) to fully understand the node-schema layout atflowNode+28; (2) confirm what a validctx/a3argument needs to look like (currently unclear if it can be null/zero or needs a real object); (3) identify which currently-reachable Flow node (e.g.MapScreen's own node via+8, confirmed reachable through the already-safeg_mapScreenInstance) actually has a usable output name defined that leads towardgarage_pre_event/the target screen, sinceMapScreen's own node likely only has outputs for things reachable directly from the map (not necessarily a direct path to a specific race's garage screen) - may need to fire a chain of outputs across multiple nodes rather than one single call. No live testing done yet on this specific call - next step before attempting a hook is finishing the static read ofsub_159684and the node-schema layout, given this session's repeated crash incidents from hooking under-understood functions.
2026-08-10 (cont. 26) — Found MapOverworld's real Outputs, including a demo-leftover shortcut straight into car-select - full chain confirmed from real shipped data
- What: Decompiled
sub_159684(the has-output-named-X check insidesub_1581A0) - a plain balanced-BST (std::map-shaped) key lookup comparing a name-hash against tree node keys, confirmingOutputsis exactly themap<nameHash, target>structure already seen in every.sbflow file - nothing unexpected, closes out the low-level mechanics ofsub_1581A0/FireOutput. - Found and unpacked
game_cache/published/flow/menus/map_overworld.sb(theMapOverworldscreen -im::app::flow::nfs::MapScreen's own Flow definition) and extracted its fullOutputsmap (26 entries). Most are unsurprising (GARAGE->garage grid,STORE,OPTIONS, popups, etc.), but three stand out:EVENT/NEXT_EVENT->event_detail(the normal path, tapping a pin), andGAMESCOM_EVENT->garage/garage_select_car- a leftover trade-show-demo shortcut that jumps directly into car selection, skipping event/track selection entirely. - Unpacked
garage/garage_select_car.sb: confirmedscreenId="RestrictedGarage",layout="car_select_new"- identical shape togarage_pre_event.sb(cont.20), i.e. this genuinely is a real, playable car-select screen, not a stub. Its ownCONTINUEoutput leads togarage_select_rollout. - Unpacked
garage/garage_select_rollout.sb: confirmedscreenId="RestrictedGarageLoadout",layout="car_select_loadout"- this is conclusively the exact target screen (2 upgrade slots, matched against the live screenshot back in cont.19/20). Its ownCONTINUEleads straight to/race/race. - Full confirmed chain, from real data, zero guessing:
MapOverworld --[GAMESCOM_EVENT]--> garage_select_car --[CONTINUE]--> garage_select_rollout --[CONTINUE]--> /race/race. IfGAMESCOM_EVENTstill fires correctly in this build, this reaches the target screen in one singleFireOutputcall from the map screen, without ever needing to pick a specific event/street first - a much better fit for a lobby feature than the normal per-event path anyway. - Why: Direct continuation of the user's instruction to keep investigating statically after
sub_1581A0was found. - Snapshot: Static/data analysis only, no code changes.
- Verification: All screenIds/layouts/outputs read directly from
NFSMW12MobileTools-unpacked JSON of the real shipped.sbfiles (map_overworld.sb,garage_select_car.sb,garage_select_rollout.sb) - not inferred or guessed.garage_select_car/garage_select_rollout's screenId/layout pairing is an exact match to the already-live-verified screens from cont.19/20. - Outcome/Next: Static case for the trigger mechanism is now about as strong as it can get without a live test. Remaining unknowns before attempting a hook: (1) whether
GAMESCOM_EVENTis still wired to anything live in this build (demo-only code paths sometimes get stripped or gated behind a flag - genuine risk, unverified) - if dead, the normalEVENT/NEXT_EVENT->event_detailpath is the fallback (would need to also learnevent_detail.sb's own outputs to keep chaining towardgarage_select_car, not yet unpacked); (2) whatsub_1581A0's third argument (ctx/a3, a{namePtr, refcountedObj}-shaped pair) needs to contain - nothing found yet indicating it can safely be null; (3) confirmingMapScreen+8is really the right node to callFireOutputon for these top-level map outputs specifically (traced via thesub_1BB59Cthunk used for"NEXT_EVENT"inMapScreen::Tick, so at least that one case is solid precedent). Recommend a careful, isolated live test next (mirroring theTick-hook safety approach from cont.24, not a risky new hook) oncectxsemantics are better understood.
2026-08-10 (cont. 27) — FireOutput's exact calling convention confirmed byte-for-byte; ctx can be null for the common case; reuses an already-known function
- What: User confirmed live that
GAMESCOM_EVENTis dead in the current build (not reachable in normal play) - noted as a future cleanup candidate (strip from the.sbfiles), and deprioritized as the trigger path; focus shifted to fully understandingsub_1581A0's third argument (ctx) instead, since that's needed regardless of which output ends up being fired. - Found 53 call sites to the
sub_1BB59C"fire output" thunk (confirming it's a pervasive, generic mechanism, not MapScreen-specific) and picked the smallest, cleanest one -sub_1A44BC(0xdc bytes) - which fires"CONTINUE". Its raw disassembly (not just Hex-Rays pseudocode, which mis-inferred the argument count for this register-passing thunk) gives the exact, confirmed calling convention:ForR0 = screen/node "this" R1 = pointer to an *interned* name slot (NOT a raw C-string pointer - must call sub_406644(&slot, "NAME") first, exactly matching this project's own already-wired InternString/INTERN_STRING_OFFSET=0x406644 from lan_event_injection.h) R2 = pointer to a 2-word ctx buffer BL sub_1BB59C ; = FireOutput(node, internedNamePtr, ctx)"CONTINUE"specifically, the real shipped code explicitly zeroes both ctx words (MOV R0,#0; STR R0,[ctx]; STR R0,[ctx+4]) before the call - i.e.ctx = {0, 0}is a real, live-used, safe value for at least this common no-payload case, not a guess. - Why: Direct continuation of the user's request to fully understand the
ctxargument before any live test is attempted. - Snapshot: Static analysis only, no code changes.
- Verification: Read directly from raw ARM disassembly (register-level), not Hex-Rays pseudocode (which under-reported the argument count here) - the most concrete confirmation level available short of a live test.
- Outcome/Next: The
FireOutputprimitive is now fully specified and ready to call from a hook:FireOutput(node, &internedName, ctx), whereInternStringis a function this project already has typed and wired (InternStringFn,INTERN_STRING_OFFSET). Remaining before a live attempt: outputs that carry real payload data (like"EVENT", which needs to say which event/street was tapped) almost certainly need a non-nullctx-{0,0}is confirmed safe only for payload-free outputs like"CONTINUE"/"BACK". SinceGAMESCOM_EVENTis confirmed dead, the practical next question is what"EVENT"/"NEXT_EVENT"onMapOverworldactually need inctxto identify a target - worth checking a call site that fires one of those specifically (not yet located) before the first live test, to avoid guessing a payload shape.
2026-08-10 (cont. 28) — Found the exact "fire EVENT with a real payload" call - reuses our own already-working LAN-injection machinery end-to-end
- What: User confirmed
GAMESCOM_EVENTis dead in the live build (future cleanup candidate: strip from.sbfiles) and asked to find a real payload-carrying call site for"EVENT"specifically. Found 4 standalone"EVENT"string literals plus a distinct"EVENT ID "log-prefix string; the log string's only xrefs all point to one small function,sub_17A99C(0x140 bytes) - decompiled in full:I.e.sub_17A99C(a1 /*screen*/, a2 /*event handle/key*/): Log("EVENT ID " + a2) // debug log only ResolveHandle(&v16, GetCacheContext(a1)+320, &a2) // = sub_173350 - THE SAME function // and +320 cache offset already // used by our own InjectSyntheticEvent wrapper = sub_161A40(malloc(0x10), v16) // builds a small event-context object // from the {RaceEvent*, tag, Actor*} triple sub_240294(wrapper, v16) // extra setup on the wrapper ctxBox = { vtable=off_A9F5F0, ref=1, ref=1, ptr=wrapper } // standard refcounted-wrapper pattern InternString(&nameSlot, "EVENT") // = sub_406644, already wired in this project FireOutput(a1, &nameSlot, &ctxBox) // = sub_1BB59Csub_17A99C(mapScreenNode, eventHandle)is a ready-made, complete "fire the EVENT output for this specific event handle" function - no need to hand-assemble the ctx wrapper ourselves at all, just call this one function with the same kind ofhandle/keyour ownInjectSyntheticEventalready produces (e.g. thekey=0xc0ffee00used for the current synthetic "LAN: Test Lobby" event).- Also decompiled
sub_161A40(the wrapper constructor) far enough to flag a risk: it dereferences the resolvedActor*(ResolveResult.actorPtr) and walks its component chain, with defensive (non-fatal-looking, log-and-continue) null checks logging"Dereferencing a NULL component pointer."/"...actor has been deleted."if that's missing - meaning our synthetic event'sactorparameter (already a real argumentInjectSyntheticEventaccepts, seelan_event_injection.h) needs to be a real, valid Actor pointer, not null, for this path to behave cleanly - not yet verified what value we currently pass there.
- Also decompiled
- Why: Direct continuation of the user's request to find a real (non-
{0,0})ctxexample, specifically for"EVENT". - Snapshot: Static analysis only, no code changes.
- Verification: Read directly from Hex-Rays decompile of
sub_17A99C/sub_161A40; the reuse ofsub_173350/+320/sub_406644isn't inferred - these are the exact same offsets/functions already declared and used successfully inlan_event_injection.h(ResolveHandleFn,GET_CACHE_CONTEXT_OFFSET,RESOLVE_HANDLE_OFFSET,INTERN_STRING_OFFSET). - Outcome/Next: This closes essentially the entire remaining gap for Point 2's real trigger mechanism. The live-test plan is now concrete: call
sub_17A99C(g_mapScreenInstance, <our synthetic event's key>)(e.g.0xc0ffee00) from a hook, which should fire"EVENT"with a correctly-built context pointing at our own already-injected LAN event, landing onevent_detailfor it - no touch simulation needed anywhere in the chain. Before attempting this live: (1) check whatactorvalueInjectSyntheticEvent/Hook_MapTrackHandleEventcurrently passes for the synthetic event, sincesub_161A40wants a real one; (2)sub_240294(the "extra setup on the wrapper") wasn't decompiled yet - worth a quick check it's not doing something load-bearing we'd be skipping by not replicating it exactly (though callingsub_17A99Cdirectly rather than hand-assembling avoids this concern entirely, since it's included automatically).
Note for this session only (not a durable project fact - do not carry into memory/docs): a second Android device (a Pixel 6, unrelated project) may show up in adb devices output alongside the Galaxy A9 used for this project - always target the correct device explicitly (e.g. adb -s <serial>) rather than assuming a single-device default, to avoid accidentally operating on the wrong phone.
2026-08-10 (cont. 29) — Live test #1: RaceEvent+12 null-deref found and fixed via exact backtrace/disasm
- What: User gave the explicit go-ahead ("живой тест, и да прибудет с тобой сила") to wire up and run
sub_17A99C/FireEventOutputfor real. WiredFIRE_EVENT_OUTPUT_OFFSET 0x17A99Cintolan_event_injection.h, extended the existing Soak-TestHook_MapScreenTick(already ticking every frame, already resolving the liveMapScreen*intog_mapScreenInstance) with a one-shot timer: after 120 ticks, callFireEventOutput(mapScreen, 0xC0FFEE00)for our own already-injected synthetic "LAN: Test Lobby" event.- First run crashed, SIGSEGV fault addr
0xc. User asked to test a refcount hypothesis first (sub_161A40bumps*(actorPtr+8)) - raisedFakeActor::refcount1→2, crash persisted at the identical address, hypothesis disproved, reverted to 1. - Real root cause found via exact backtrace + raw ARM disasm at the faulting PC (not just decompile, which had mis-attributed the read):
sub_27D1ECunconditionally reads*(a1+12)with no null check.RaceEventCtor(sub_2A4B58) deliberately zeroesRaceEvent+12at construction - it's normally filled later by the prefab-deserialization pipeline, which a synthetic/injectedRaceEventnever goes through. - Fix: in
InjectSyntheticEvent,calloc(1, 16)a dummy buffer and pointRaceEvent+12(RACEEVENT_CATEGORYTAG_OFFSET) at it. Verified by the fault address moving from0xcto a new value on retest, confirming the first crash site was actually cleared rather than coincidentally still failing nearby.
- First run crashed, SIGSEGV fault addr
- Why: Direct continuation of cont.28's plan - first real end-to-end exercise of the production trigger path.
- Snapshot:
lan_event_injection.h- addedFIRE_EVENT_OUTPUT_OFFSET/FireEventOutputFn,RACEEVENT_CATEGORYTAG_OFFSET+ the calloc'd dummy inInjectSyntheticEvent, and the one-shot test call inHook_MapScreenTick. - Verification: Live on-device (Galaxy A9,
adb -s 2a40cdac1f0d7ece), logcat backtraces + IDA disasm at the exact crash PC. - Outcome/Next: One crash down, more expected - continue fixing them one at a time rather than trying to fully pre-populate every field a real prefab-loaded
RaceEventwould have.
2026-08-10/11 (cont. 30) — MILESTONE: FireEventOutput completes successfully; two more crashes found and fixed in unrelated subsystems; a further crash in Flow-transition processing found but not yet fixed - work paused here on user's call
- What: Continued the "fix crashes one at a time" loop from cont.29, per explicit user direction each round ("Проверь actor в InjectSyntheticEvent" → "Сначала гляни sub_240294" → "Try again" → "Продолжай чинить это конкретное аудио-поле"):
- Crash #2 (fault addr
0x8, insideim::app::sounds::CopSounds::Tick=sub_304AA0, confirmed via vtable range0xAA9260-0xAA92A8containing the return address0xAA9290): an ambient global audio subsystem, unrelated to our RaceEvent, reading component data our synthetic actor doesn't have. Rather than trying to satisfy its data requirements, applied the same mitigation already used for the pre-existing Soak Test hook: hookedsub_304AA0(COPSOUNDS_TICK_OFFSET) to a complete no-op (Hook_CopSoundsTick, does not call the original). Installed unconditionally fromJNI_OnLoadviaInstallCopSoundsTickSkipHook().- One retest still crashed at the same address with the hook's install log line mysteriously missing (while 3 sibling hook-install logs were present that run). Added a diagnostic log at function entry, rebuilt, confirmed on the next run the hook installs correctly every time - treated as a one-off transient/timing artifact, not a real bug.
- Crash #3 (fault addr
0x8, insidesub_240548, reached fromsub_240294): a component-name-cache lookup (instance-pointer → cached-name-string). Has a safe early return (if (!*a2) return off_AC80E0;), but on a cache miss (expected - our synthetic RaceEvent was never registered) falls through to a "build an error string from RTTI class name" fallback that crashes inside a generic low-level container/RTTI utility (sub_66DB94/sub_66FFB0). Fix: hookedsub_240548(GET_COMPONENT_NAME_OFFSET) to unconditionally return the function's own existing safe sentinel value (EMPTY_STRING_SENTINEL_OFFSET=off_AC80E0, the same value the function itself would return on its!*a2fast path) - bypasses the crashing fallback without calling the original. Installed unconditionally viaInstallGetComponentNameSkipHook(). - After both fixes:
FireEventOutputcompleted and returned successfully for the first time ("LIVE TEST: FireEventOutput returned without crashing" logged) - this is the actual goal of the whole cont.25-29 arc: a real engine call, not touch simulation, successfully firing a named Flow output. - Crash #4 then appears ~0.4s after that successful return, in a new function
sub_2C62A0(fault addr0x0, offset0x2C6374). Timing strongly suggests this isFlowManager::Updateprocessing our now-queued transition on a later frame, notFireEventOutputitself. Decompiledsub_2C62A0: familiarGetComponent<T>(sub_8FF8C, already known-safe) pattern repeated several times against our syntheticActor/RaceEvent+4, ending in an unguarded triple-dereference (*(_DWORD*)(*(_DWORD*)(*(_DWORD*)(a1+48)+44)+152)) as the leading suspect - not yet confirmed via disasm-at-exact-PC, not yet fixed. - User then called a stop ("Останавливаемся") to bank progress here rather than continue chasing crash #4/#5+.
- Crash #2 (fault addr
- Why: Systematic root-cause-then-fix loop, continuing until the user chose to stop; each crash found via the same successful methodology (real backtrace + disasm at the exact fault PC, not guessing from decompile alone).
- Snapshot:
lan_event_injection.h-Hook_CopSoundsTick/InstallCopSoundsTickSkipHook,Hook_GetComponentName/InstallGetComponentNameSkipHookadded;main.cpp- both installed unconditionally fromJNI_OnLoad.Hook_MapScreenTick's one-shot test call from cont.29 unchanged. - Verification: Live on-device (Galaxy A9), logcat backtraces at each new fault address, screenshots per user's explicit "Скринами проверяй" instruction.
- Device notes (session-only, not carried to memory): hit an adb device-disconnect (
kill-server/start-serverfixed it), a screen stuck inDozing/display-off that no adb command could wake (required a physical power-button press by the user), and a stale crash dialog blocking a subsequent clean launch (cleared viaKEYCODE_BACK+am force-stop). - Outcome/Next (paused here on user's explicit "Останавливаемся"):
- Resume by pinning down crash #4's exact faulting instruction via
disasmat0x2C6374(same technique as crashes #1/#3) - the leading suspect is one of the three chained dereferences offa1+48, most likely becausea1(looks like the Screen/FlowManager-side object, not our synthetic object) expects a fully-populated scene/UI hierarchy that a real, prefab-loaded transition would have and ours doesn't. - This crash sits after the actual "fire the output" call already succeeded - so the core Point-3-prerequisite goal (a real, non-touch production trigger for Flow transitions) is functionally proven; what remains is making our specific synthetic/incomplete
RaceEvent+Actorsurvive the rest of the transition pipeline, which may mean more of the same "find the exact null deref, fix or skip-hook it" loop, or could mean the synthetic object needs to satisfy more of the pipeline's expectations up front instead of patching each consumer reactively - worth deciding which approach once crash #4 is understood. - Once a transition fully completes, verify the game visibly reaches
garage_select_car/car_select_loadoutvia screenshot (per standing "Скринами проверяй" instruction), matching the already-confirmed real Flow chain from cont.26. - Standing constraint remains in force: touch/tap simulation stays RE-verification-only;
FireEventOutput/FireOutputis the correct, now largely-proven production path.
- Resume by pinning down crash #4's exact faulting instruction via
2026-08-11 (cont. 31) — MILESTONE: reached the real Event Details screen for our synthetic event via FireEventOutput alone, no crash, screenshot-verified; found and corrected a PC-correlation bug from cont.30
- What: User asked to add a diagnostic hook for crash #4 and rebuild. Installed
Hook_Sub2C62A0(entry hook onsub_2C62A0, logginga1/a2and each step of thea1+0x30 -> +0x2C -> +0x98chain before calling the original) - live test showed the crash still occurred (same fault addr0x0, same 4 "Dereferencing a NULL component pointer"/"...actor has been deleted" fatal log lines beforehand) but the diagnostic hook's own log line never fired - provingsub_2C62A0was never actually entered before the crash, i.e. cont.30's identification of the crash site was wrong.- Root cause of the misidentification: cont.30 took the tombstone's self-reported
backtrace: #00 pc 002c6374as if it were directly usable as an IDA file offset. It isn't - that value is relative to whatever specific VMA/segment mapping debuggerd's unwinder resolved, not tolibapp_base. The correct file offset is(raw PC register) - (our own resolved libapp_base); using this run's own logged values (libapp_base=0xb9d9d000, tombstonepc ba3cc374) gives0x62F374- a completely different, unrelated address, off by a page-aligned0x369000from the wrong value. - Decompiling
0x62F374(functionsub_62F340) showed a hand-optimized SWARstrlen()implementation; the crashing line is*(_DWORD*)v1reading the first word of the input pointer with no null check - a plainstrlen(NULL), matching fault addr0x0exactly. This is a generic leaf routine used everywhere in the binary, almost certainly reached while the engine tries to build an RTTI/class-name debug string for our under-registered synthetic actor - the same family of issue as cont.29/30'ssub_240548fix, but a different call site not covered by that guard. - Fix: replaced the (now-disproven)
sub_2C62A0hook withHook_Strlen/InstallStrlenNullGuardHookatSTRLEN_OFFSET 0x62F340- a minimalif (!s) return 0;guard before falling through to the real implementation for every other (real) string. Leaf function, noPUSH, so the trampoline just relocatesMOV R1,R0; TST R0,#3. - Live result: rebuilt, installed, ran. Logcat showed the same 4 fatal-but-non-crashing log lines as before, then
Strlen null-guard: called with NULL, returning 0 instead of crashing(our hook catching the real fault), then the game continued: loaded a large batch of new layout entities (event_details,event_frame,event_details_speedwall*,medal_targets_event_details,speedwall_header/footer, etc.), loggedA.N. produce offset for race selection screen, played theui/ui/event_menu_intransition sound - and did not crash.dumpsys activity activitiesconfirmedGameActivityMainstill resumed/foregrounded (not bounced back to launcher). - Screenshot verification (per standing "Скринами проверяй" instruction): confirms the game is showing the real Event Details screen, titled
XXXXX[LAN: TEST LOBBY]XXXXX- our own synthetic event's name, rendered by the game's own UI (theXXXXX[...]XXXXXwrapping is the engine's own missing-localization-key marker, not something we added - further proof this is genuine engine-driven rendering, not a mock). Shows medal reward tiers (+$250,000 gold/silver/bronze), an empty speedwall/leaderboard table, and a confirm-race checkmark button. Track name shows a leftover placeholder (XXXXX[REVERSED_REGION2_DESERT_TRACK3]XXXXX, cosmetic - we never set a track name field on the synthetic event) rather than a crash.
- Root cause of the misidentification: cont.30 took the tombstone's self-reported
- Why: Direct continuation of "поставь диагностический хук и пересобирай"; the diagnostic hook did its job precisely by producing a clean negative result that overturned the previous session's (wrong) hypothesis, rather than by confirming it.
- Snapshot:
lan_event_injection.h- removedHook_Sub2C62A0/InstallSub2C62A0DiagHook, addedHook_Strlen/InstallStrlenNullGuardHook(STRLEN_OFFSET 0x62F340);main.cpp-InstallStrlenNullGuardHook()replacing the removed diag-hook call inJNI_OnLoad. - Verification: Live on-device (Galaxy A9,
adb -s 2a40cdac1f0d7ece), logcat, and a pulled screenshot (screencap->adb pull) showing the real Event Details screen for the synthetic event. - Outcome/Next: This is the actual achievement of the Point-3 prerequisite goal: a fully native, non-touch production trigger (
FireEventOutput) drives the game from the map screen all the way into a real Flow-rendered gameplay screen for a synthetic/injected event. Remaining work is incremental, not architectural:- From here, the already-confirmed real chain (cont.26) continues
event_detail-> (confirm race) ->garage_select_car-> (confirm car) ->garage_select_rollout/car-select-loadout ->/race/race. Advancing pastevent_detailneeds the same kind of output-firing call (not touch) - likely anothersub_17A99C-style or directFireOutputcall for whatever output name the confirm-race button fires, still to be identified. - The cosmetic track-name placeholder is a simple data-completeness gap (set
RaceEvent's track-name field on the synthetic event), not a correctness bug - low priority relative to advancing the Flow chain further. - Standing constraint remains in force: touch/tap simulation stays RE-verification-only;
FireEventOutput/FireOutputis the correct, now proven-in-production path. - Methodological note for future crash diagnosis: always compute crash-site file offsets as
(raw tombstone PC register) - (our own logged libapp_base), never take the tombstone's self-reportedbacktrace: #00 pc ...line at face value - it can be relative to a different memory mapping and silently point at the wrong function.
- From here, the already-confirmed real chain (cont.26) continues
2026-08-11 (cont. 32) — Fixed the "XXXXX[...]XXXXX" missing-localization marker: our synthetic event's name and track now render as clean text
- What: User asked why the event card showed
XXXXX[LAN: TEST LOBBY]XXXXXand requested substituting real strings so it displays correctly. Static search for the marker (literal "XXXXX" strings, UTF-16 byte patterns, nearby localization-sounding functions/strings likeLocale::,StringId, "Place Holder Multilang") found nothing conclusive - several dead ends (ToS-URL-by-locale function, Flow-output-name popups that only look like loc keys, an unrelatedOptionsSettingsScreen-specific placeholder check).- Switched to a live, timing-correlated diagnostic instead of more static guessing: hooked
InternString(sub_406644, offset already known/used) to log every string interned during a budgeted window right afterFireEventOutputfires, and separately hookedsub_87738(the shared "fatal-style log" leaf function used everywhere for"Dereferencing a NULL component pointer."etc.) to log its caller's return address via__builtin_return_address(0)- sincesub_87738itself is generic/shared, only its caller identifies which higher-level function is doing what. - The
InternStringtrace showed our raw"LAN: Test Lobby"gets interned, immediately followed by asub_87738call from a caller whose address (after correcting forlibapp_base, per cont.31's methodology note) resolved tosub_162F14, specifically the callsub_40A29C(&v105, ctx, RaceEvent+RACEEVENT_EVENTNAME_OFFSET)- i.e. exactly the site reading our own event-name field.sub_40A29Cis a 1-line wrapper aroundsub_40A2B0, decompiling which revealed the real mechanism: it interns the given key, looks it up in a string table (sub_40A580); on a hit it copies the found text; on a miss it either returns empty text (if a debug flag atcontext+32is false) or buildsL"XXXXX[" + rawText + L"]"- theL"XXXXX["literal is real (an earlierfind_bytessearch for"58 00 58 00 58 00"(UTF-16 "XXX") had actually already turned it up at0xa1f1b4, but was wrongly dismissed as coincidental without checking). - Since our synthetic
RaceEvent's name/track fields were never registered as real localization-table keys (they never go through the normal prefab/loc pipeline), every lookup misses and takes the wrapped-raw-text path.
- Switched to a live, timing-correlated diagnostic instead of more static guessing: hooked
- Fix: rather than reverse-engineer
sub_40A580's table-registration format (unknown hash/prefab structure) or reimplementsub_40A2B0's EASTL wide-string-building logic, hookedsub_40A2B0to call the original function unmodified, then post-process its already-built output buffer: if it starts with"XXXXX[",memmovethe inner text over the prefix (shrink-only, no realloc) and shorten the container's end pointer - the begin pointer (the real allocation base) is never touched, so a laterfree()on it stays safe. Skips (leaves as-is) if the bracket content is empty, so a genuinely-unset field still visibly reads as "not filled in" rather than silently becoming a blank string. This is a small, generic fix (not specific to our event) - any raw-text fallback anywhere in the game benefits. - Snapshot:
lan_event_injection.h- addedHook_InternStringDiag/InstallInternStringDiagHookandHook_FatalLogCallerTrace/InstallFatalLogCallerTraceHook(temporary diagnostics, left in place but harmless/budget-gated), andHook_ResolveDisplayText/InstallResolveDisplayTextHook(0x40A2B0, the actual fix);main.cpp- all three installed unconditionally fromJNI_OnLoad. - Verification: Live on-device (Galaxy A9). Screenshot after the fix shows the event card title as clean
LAN: TEST LOBBYand the track line as cleanREVERSED_REGION2_DESERT_TRACK3(both previously wrapped). One remainingXXXXX[]XXXXXon a bottom action bar is a field we never populate at all (empty inner content, correctly left alone per the fix's own skip condition) - not a regression, just an unset field made visible instead of silently blanked. - Outcome/Next: Cosmetic goal achieved. No further action expected unless the user wants the still-empty bottom-bar field populated too (would need identifying which RaceEvent/context field that specific widget binds to, same investigative pattern as this entry).
2026-08-11 (cont. 33) — Filled the remaining empty bottom-bar field: found it was an out-of-bounds read past our undersized categoryTag buffer, not a missing binding
- What: User asked to fill the still-empty
XXXXX[]XXXXXbottom action bar from cont.32. Extended the same live-diagnostic technique: added a budget-gated log toHook_ResolveDisplayTextfor the empty-content case (loggingkey/caller), and a second hook directly onsub_40A29C(the tiny wrapper aroundsub_40A2B0) sincesub_40A2B0's own caller-trace always resolves to the wrapper's epilogue, not the real higher-level call site - hooking one level up exposes both the real caller and the field POINTER itself (not just its already-dereferenced null value).- First pass:
key=0x0(a genuinely null pointer, not just an unregistered raw string) from callersub_40A29C+0x10-ish, i.e. reached via the wrapper as expected. - Second pass (hooking
sub_40A29Cdirectly, diffing the field pointer against a newly-addedg_lastSyntheticRaceEventglobal): the field pointer was nowhere near our RaceEvent (nonsensicalRaceEvent+4813788offset) - meaning it isn't a RaceEvent field at all, so the earlier framing ("which RaceEvent field is this bound to") was the wrong question. - The caller resolved (same
libapp_base-correction methodology as cont.31/32) tosub_162F14again, specifically the block:v56 = *(_DWORD*)(v54+12); ...; sub_40A29C(&v105, v52, v56+28);wherev54 = v2[76]is our RaceEvent and+12isRACEEVENT_CATEGORYTAG_OFFSET- our own cont.29 fix, acalloc(1, 16)dummy buffer pointed at fromRaceEvent+12to stop an earlier crash. This code reads*(categoryTagPtr + 28)- 12 bytes past our 16-byte buffer's end - undefined behavior that happened to read adjacent zeroed heap memory (hence null, not a crash) rather than the real category-tag struct's actual "label" field, which apparently lives at that offset in genuine, prefab-loaded objects.
- First pass:
- Fix: enlarged the
categoryTagbuffer from 16 toCATEGORYTAG_BUFFER_SIZE(64) bytes - eliminates the out-of-bounds read entirely - and populatedCATEGORYTAG_LABEL_OFFSET(28) with a real interned string ("LAN RACE"). Since this is raw (unregistered) text like the event name, it goes through the exact same lookup-miss path from cont.32 and gets theXXXXX[...]wrapper automatically stripped by the already-installedHook_ResolveDisplayTextfix - no new display logic needed. - Snapshot:
lan_event_injection.h-RACEEVENT_CATEGORYTAG_OFFSETcomment extended, addedCATEGORYTAG_BUFFER_SIZE/CATEGORYTAG_LABEL_OFFSET;InjectSyntheticEventnow callocs 64 bytes instead of 16 and writes the interned"LAN RACE"pointer at+28; added (as temporary diagnostics, budget-gated, left in place)Hook_ResolveDisplayText's empty-case log,g_lastSyntheticRaceEventglobal, andHook_ResolveDisplayTextWrapperDiag/InstallResolveDisplayTextWrapperDiagHookonsub_40A29C(0x40A29C). - Verification: Live on-device (Galaxy A9). Screenshot confirms the bottom bar now reads clean
LAN RACEtext, no crash, no moreDIAG sub_40A29C NULL fieldlog line. - Side observation (not a regression, not acted on): the same screenshot's track-name line reverted to
XXXXX[]XXXXX(previously showed a coincidentalREVERSED_REGION2_DESERT_TRACK3in cont.32's screenshot).RaceEvent's track-name field (RACEEVENT_TRACKNAME_OFFSET) has never been intentionally set byInjectSyntheticEvent- the earlier text was uninitialized/adjacent heap memory that happened to contain that string, and enlarging thecategoryTagallocation shifted the heap layout enough that the coincidence no longer holds. Confirms that field's prior content was never reliable in the first place; left alone since it wasn't part of this request. - Outcome/Next: Both text fields the user has pointed out so far (event name, bottom action bar) now show intentional, correct text. If the track-name gap matters later, it needs the same treatment: pick a real offset on
RaceEventfor the track name and populate it intentionally (currently unset,ANALYSIS.md §6ymarksRACEEVENT_TRACKNAME_OFFSET 72as a plain eastl-style string field), rather than relying on incidental memory contents.
2026-08-11 (cont. 34) — Track-name subtitle: populating RACEEVENT_TRACKNAME_OFFSET did NOT fix it; extensive follow-up investigation found no working lead - left unresolved, user OK'd stopping
- What: User asked to fill the track-name subtitle row too (cont.33's noted gap). Populated it the "obvious" way first:
RaceEvent+72/+76/+80({begin,end,capacity} raw-text field, format already confirmed correct by existing debug-dump code inHook_MapTrackHandleEvent) withkTargetGroupName("region1_foothills_track1", the real MapTrack this event is attached to). Live-tested: no visible change - the row still showedXXXXX[]XXXXX, proving the on-screen widget does not read this field at all (the cont.32/33 screenshots'REVERSED_REGION2_DESERT_TRACK3text was never coming fromRaceEvent+72- it was incidental/adjacent heap memory content the whole time, as cont.33 already suspected). - Follow-up investigation (four independent techniques, all dead ends):
- Widened live diagnostics: extended
Hook_ResolveDisplayTextto also log the other lookup-miss branch (plain-empty result, no"XXXXX["marker - the!*(context+32)path insidesub_40A2B0), and broadenedHook_ResolveDisplayTextWrapperDiag(onsub_40A29C) to log every call unconditionally instead of only null-field ones. Raised budgets to 3000. Result: the entire burst only contains ~26 realsub_40A29Ccalls, and 100% of them are already-known (event name atRaceEvent+88,categoryTag+24null,categoryTag+28"LAN RACE") or unrelated ambient HUD/achievement strings (AUTOLOG_*,UI_ONLINE,POST_RACE_NEW_PERSONAL_BEST, etc. from one repeated, unrelated caller). The track-name row never callssub_40A29C/sub_40A2B0at all - confirmed twice, at two different budget sizes, ruling out "budget too small" as the explanation. - Static layout-file archaeology: found
layouts.sb.jsonalready unpacked inNFSMW12MobileTools/(1.6M lines, 4431 named widget entities). Locatedevent_details's root layout entry and confirmed_name_header/_progress_bar(seen erroring as "Unable to locate layout entity" in live logs) don't exist anywhere in the file - i.e. they're normal, harmless misses that happen for every event (real or synthetic), not a lead. Manually walking the full nested entity tree to find the specific subtitle widget wasn't tractable given the scale (thousands of entities, deep nesting viaDataIdsMap/hex-id cross-references). - Traced
sub_162F14's real scope: re-examining its full decompile (already in hand) showed it's entirely about the speedwall/leaderboard section (medal placement + cash reward text, car-class-restriction string building, player-name/score row template) - event name is read there too, but only incidentally (for a "Speedwall for [event]"-style label), not because this function owns the screen's top header/subtitle. - vtable sibling exploration:
sub_162F14is reached only via a vtable slot (0xa9ed30, xref-confirmed), not a direct call - found and batch-decompiled ~10 neighboring vtable slots (0x162adc,0x162da0,0x162e5c,0x162e60,0x162ebc,0x1a70bc,0x1a70d4,0x1a7754,0x1a77ec,0x1a7890) hoping to find a sibling "populate header section" method. All turned out to be generic widget-lifecycle boilerplate (destructor/refcount teardown,OnEventwith an"EVENT"string-match check,HandleAnimationEvent-style dispatch, a sound-trigger callback) - i.e. this is a normal component base-class vtable, not a family of section-populator methods as hypothesized. Dead end.
- Also caught and corrected a real Hex-Rays misdecompilation along the way:
sub_162F14's pseudocode showssub_4D913C(v9, &v105)as a 2-argument call, but raw disassembly ofsub_4D913Citself proves it only readsR0(PUSH{R4,R10,R11,LR}; MOV R4,R0; ...; STR R0,[R4,#0x70]- never touchesR1) - it resets some unrelated field to a fixed empty-string sentinel (off_AC80E0) and has nothing to do with rendering the resolved text. Confirms (again, see cont.31) that Hex-Rays' argument-count inference for these register-passing ARM calls cannot be trusted without cross-checking raw disasm.
- Widened live diagnostics: extended
- Conclusion: the subtitle text is not sourced from
RaceEvent+72, is never requested through thesub_40A29C/sub_40A2B0resolve-and-display pipeline that successfully drives the other two (now-fixed) fields, and its actual widget/binding could not be identified via static layout data, function-scope tracing, or vtable-neighbor inspection. Most likely explanation: it's a conditionally-shown element (e.g. gated on a "reversed track" or similar flag/field our minimal syntheticRaceEventnever sets) whose populating code path simply never runs for this event - not a matter of a lookup returning empty, but of the lookup never being attempted. Confirming that would require a fundamentally different technique (live debugger attach - already documented elsewhere in this project as flaky on ARM32 - or brute-force auditing of many more candidate functions with no strong hypothesis left to narrow the search). - Decision: presented this status to the user directly; they confirmed it's low priority and to stop here rather than continue open-ended searching.
- Snapshot:
lan_event_injection.h-InjectSyntheticEventnow populatesRACEEVENT_TRACKNAME_OFFSET/RACEEVENT_TRACKNAME_CAPACITY_OFFSETwithkTargetGroupName(harmless, correctly-formatted, just not what the screen reads - left in place since it's still a reasonable value for whatever does eventually consume that field, e.g. the debug-dump logging that already reads it). All cont.32-34 diagnostic hooks (InstallInternStringDiagHook,InstallFatalLogCallerTraceHook,InstallResolveDisplayTextWrapperDiagHook) remain installed but budget-gated/inert unless a budget global is manually raised above 0 again for a future investigation. - Outcome/Next: Stopping point, by user agreement. Final state: event name and bottom action bar both show correct, intentional text (cont.32/33); the track-name subtitle row remains an unresolved
XXXXX[]XXXXXplaceholder - a known, low-priority cosmetic gap, not a functional blocker. If revisited later, the most promising unexplored angle is a live debugger breakpoint onsub_40A2B0/sub_4D8ABC-family functions during the transition (despite documented ARM32 debugging flakiness on this project), since static/live-hook tracing has now been exhausted without success.
2026-08-11 (cont. 35) — MILESTONE: advanced past event_details to garage_select_car ("confirm race") entirely natively; root-caused why the first synchronous-fire attempt silently failed
- What: User asked to advance the Flow chain one more step - "confirm race" (event_details' own checkmark) into
garage_select_car- warning in advance that confirming the car afterward is known to crash (from their own prior touch-based testing).- Confirmed the real Output data first:
event_detail.sb.json's ownOutputsmap (0300) showsoutput="EVENT"→node="garage/garage_select_car"- the exact same output name ("EVENT") asMapOverworld's, just scoped to a different screen's Flow node. Hypothesis: firingFireOutputagain, this time on the event_details screen object instead of the map screen, with a payload-freectx={0,0}(matching theCONTINUE-style pattern), should reachgarage_select_car. - Needed a pointer to the live
event_detailsscreen - unlikeMapScreen, it has no dedicated Tick hook. AddedHook_LayoutScreenCtoronsub_1A571C(LayoutScreen::LayoutScreen, already fully decompiled in cont.32 - confirmed via live diagnostic to construct every named-layout screen:SplashScreen,UpdateCheck,TutorialCheck,MapOverworld,EventDetails,RestrictedGarage, etc., each identifiable by*(const char**)(a1+4)) to capture whichever screen is constructed right after the firstFireEventOutputsucceeds. - First attempt failed silently: firing
FireOutputsynchronously from insideHook_LayoutScreenCtor, right afterevent_detailsfinished constructing, returned without crashing but produced no visible transition (confirmed via two screenshots, one taken 1s and one 6s after the fire - screen never changed). Diagnostic confirmed the captured screen WAS correctly"EventDetails"(not a wrong/nested object), ruling out that explanation. - Root cause: firing too early/reentrant - still inside the tail end of the first transition's own construction callback. Also newly confirmed:
MapScreen::Tick(the hook previously used for timing the first fire) stops firing onceevent_detailsis on top of the screen stack - a tick-count-based delay (as used for the first fire) isn't available for the second one. - Fix: deferred the fire using wall-clock time instead of a tick counter or immediate synchronous call -
Hook_LayoutScreenCtornow just records the target screen pointer + aCLOCK_MONOTONICtimestamp; the actualFireOutputcall happens from insideHook_InternStringDiag(confirmed live to keep firing every ~0.25s onevent_details' own active update loop, unlikeMapScreen::Tick) once ≥1.5s of real time has passed.
- Confirmed the real Output data first:
- Live result: Success.
LIVE TEST 2: FireOutput returned without crashing, immediately followed byDIAG LayoutScreenCtor: screenName="RestrictedGarage"- the exactscreenIdconfirmed back in cont.20 forgarage_select_car.sb. Screenshot confirms the real car select screen: a Dodge Challenger SRT8 392, class filter ("КЛАСС - ВСЕ"), price ($17,000), stat bars (speed/acceleration/handling), prev/next car arrows, and a confirm checkmark - reached entirely through nativeFireOutputcalls, zero touch simulation, continuing the standing production-path constraint. - Snapshot:
lan_event_injection.h- addedFireOutputFn/FireOutput(rawsub_1BB59Cprimitive,FIRE_OUTPUT_OFFSET 0x1BB59C), movedInternStringFn/InternString's declaration earlier in the file (needed byHook_InternStringDiagnow), addedHook_LayoutScreenCtor/InstallLayoutScreenCtorHook(LAYOUTSCREEN_CTOR_OFFSET 0x1A571C) with its screen-name diagnostic log, addedg_firedEventTest/g_firedSecondEventTest/g_secondEventTarget/g_secondEventTargetSetAt/MonotonicMillisSince()globals, and the deferred-fire block insideHook_InternStringDiag.main.cpp-InstallLayoutScreenCtorHook()added toJNI_OnLoad. - Verification: Live on-device (Galaxy A9,
adb -s 2a40cdac1f0d7ece), logcat + pulled screenshot (screencap/adb pull) confirming the real garage/car-select screen. - Outcome/Next: Confirmed real Flow chain (cont.26) now live-verified two hops deep:
map_overworld→ (EVENT) →event_detail→ (EVENT) →garage_select_car. Per the user's advance warning, the next hop - confirming the car selection, intogarage_select_rollout/car-select-loadout - is expected to crash; that's the next thing to chase, using the same "find real Output name, fire on the right screen with the right timing" methodology now proven twice over. The wall-clock-deferred-fire-via-an-already-firing-hook pattern (rather than tick-counting or firing synchronously) is now the established technique for advancing through screens that lack their own dedicated Tick hook - worth reusing directly for the next hop instead of rediscovering it.
2026-08-11 (cont. 36) — Advanced to garage_select_rollout (the car-upgrade-loadout screen, the original goal); root-caused the next crash as real-race-loading needing actual scene data
- What: Continued the chain one more hop:
garage_select_car.sb.json's own Outputs confirmedoutput="CONTINUE"→node="garage_select_rollout". Extended the same deferred-fire pattern (capture screen inHook_LayoutScreenCtor, fire fromHook_InternStringDiagafter ≥1.5s) for a third hop. - Live result: Success -
DIAG LayoutScreenCtor: screenName="RestrictedGarageLoadout"(the exactscreenIdforgarage_select_rollout.sb, confirmed back in cont.20), and a screenshot showing the actual car upgrade/loadout screen: two upgrade slots ("ПУСТО (УЛУЧШ.)"), a start-race flag button ("ВСЕГО 0$"), the confirmed car rendered with plates. This is literally the original screen this entire investigation arc was chasing (the "2 upgrade slots" UI from the very first request in this session), reached three hops deep entirely via nativeFireOutput, zero touch. - Next hop attempted and crashed as warned:
garage_select_rollout.sb.json's ownoutput="CONTINUE"→node="/race/race"- the actual race-loading transition. Fired it (fourth hop); it crashed on a background thread (Thread-10, not the GLThread) ~4s later.- Crash-site methodology (per cont.31's rule: always use
raw tombstone PC - our own logged libapp_base, never the tombstone's self-reported offset):pc=0xba04e744, this run'slibapp_base=0xb9da2000→ real offset0x2AC744, inside a function namedRaceLoaderTask_ResetStartingLine. - Log immediately before the crash showed three real engine assertions firing:
Assertion failed (m_StartLine),Assertion failed (m_FinishLine),Assertion failed (m_EndOfTrack)- all insideOnSceneLoaded*callbacks. Decompiling the crash site showed why these aren't immediately fatal but crash later anyway: each missing locator is logged as a warning (matching the project's now-familiar "Dereferencing a NULL component pointer" pattern) but the code doesn't stop using the resolved pointer afterward -sub_690A94(v77, *(_DWORD*)(*(_DWORD*)(a1[23] + 4) + 64))dereferencesa1[23]unconditionally a bit later, anda1[23]was left null exactly because them_StartLinelocator was never found (fault addr0x4matchesNULL+4precisely). - Root cause, one level up: these locators (
m_StartLine/m_FinishLine/m_EndOfTrack) are resolved from the actual 3D track scene's own named locator objects (sub_672D64/sub_870E8) - real race loading needs a genuinely loaded track scene, which our syntheticRaceEventhas never referenced (no realTrackId/scene reference was ever set - a long-standing open question in this project, noted since cont.18: "TrackId/Locationaren't registered anywhere - they're plain prefab data fields"). This is architecturally different from every crash fixed so far (all of which wereRaceEvent/Actorcomponent-completeness gaps patchable with a calloc'd buffer or a skip-hook) - it needs an actual track scene loaded, not a bigger dummy buffer.
- Crash-site methodology (per cont.31's rule: always use
- Decision point: reported the crash and root cause to the user and asked whether to continue chasing it or stop, having reached the original target screen.
2026-08-11 (cont. 37) — Pivoted to controlled scene exit instead of chasing race-loading; found and fixed a real use-after-free bug in our own code along the way
- What: User redirected the goal: crashing on this scene is unacceptable regardless of cause. Production behavior should be: on confirming the car, controllably exit back to the map (not proceed into race loading), eventually recording accepted upgrades and opening a lobby overlay (both explicitly deferred - not needed yet, just noted as follow-up work). Also asked two forward-looking questions: (1) can
car_selectbe opened without going through the event/stakes menu, and (2) can a race be launched directly from the map/track. Answered both inline: (1) untested but plausible in principle since Flow nodes are addressable by name regardless of path, thoughgarage_select_car'sRestricted=true/GarageType=preraceproperties suggest it expects context normally set up byevent_detail's own confirm handler - real risk of new missing-context crashes, needs live testing to confirm; (2) irrelevant to the crash just found, sinceRaceLoaderTask_ResetStartingLine's missing-scene-locator crash is about race loading itself lacking a real track reference, independent of which UI screens were visited to get there - the realTrackIdfix (cont.36) is needed regardless of navigation shortcuts. - Implementation: found the real, already-shipped "BACK" output on each screen (from their own
.sbOutputs data) forms exactly the exit chain needed - the same path a player pressing back 3 times would take:garage_select_rollout--BACK-->garage_select_car--BACK-->event_detail--BACK-->map_overworld. Reusing the game's own real, already-battle-tested back-navigation code is safer than inventing a new exit path.- Refactored the by-then-three-times-hand-duplicated deferred-fire stages (cont.35/36's
g_secondEventTarget/g_thirdEventTarget/g_fourthEventTargetetc.) into one generic mechanism: akOutputChain[]array of output names ("EVENT","CONTINUE","BACK","BACK","BACK") plus a singleg_chainIndex/g_chainTarget/g_chainTargetPendingstate, walked one entry at a time byHook_LayoutScreenCtor(capture) andHook_InternStringDiag(deferred fire) - same proven timing pattern, no more duplicated blocks, and trivial to extend/reorder for future hops.
- Refactored the by-then-three-times-hand-duplicated deferred-fire stages (cont.35/36's
- New crash found and fixed - in our own code, not the game's: firing the full 5-step chain hit a SIGSEGV whose backtrace named
Hook_MapTrackHandleEvent+336directly (inside our ownlibmpcore.so, notlibapp.so- no offset-correction ambiguity this time, the symbol resolved cleanly). Root cause: navigating back tomap_overworlddestroys and reconstructs the realMapScreen/MapTrackobjects, re-firingMapTrack::HandleEvent(the same hook that originally droveInjectSyntheticEvent) - and a leftover TEMPORARY debug block (from the long-abandoned tap-coordinate-calibration investigation, cont.23/24, explicitly ruled out as a production approach back in cont.25's "тапы не нужны" correction) dereferencedg_mapScreenInstance- a global captured once byHook_MapScreenTickon the first map visit and never invalidated - now pointing at the freed originalMapScreenobject. Classic use-after-free. Fix: deleted both leftover TEMPORARY dump blocks (the MapTrack bounds-rect dump and theg_mapScreenInstancescroll-entity dump) fromHook_MapTrackHandleEvententirely - their whole purpose (calibrating tap coordinates) was already obsolete, and removing them eliminates the crash at the source rather than patching around it. Also removed the now-fully-unusedMAPSCREEN_SCROLLENTITY_OFFSETconstant. - Live result: Full success. All 5 chain steps fired cleanly (
LIVE TEST CHAIN[1]through[5]), ending withDIAG LayoutScreenCtor: screenName="MapOverworld"and no crash. Screenshot confirms a fully healthy, interactive map screen (pins, nav bar, our injected event marker still present) - the complete cyclemap → event → car select → loadout → controlled exit → mapnow works end-to-end, natively, crash-free. - Snapshot:
lan_event_injection.h- removed the per-hopg_secondEventTarget/g_thirdEventTarget/g_fourthEventTarget/g_firedSecondEventTest/etc. globals and their three hand-duplicatedHook_LayoutScreenCtor/Hook_InternStringDiagblocks, replaced withkOutputChain[]/g_chainIndex/g_chainTarget/g_chainTargetPending; deleted the two TEMPORARY debug-dump blocks (andMAPSCREEN_SCROLLENTITY_OFFSET) fromHook_MapTrackHandleEvent. - Verification: Live on-device (Galaxy A9), full logcat capture across all 5 hops, pulled screenshot confirming the final map-screen state.
- Outcome/Next: The car-confirm → controlled-map-return cycle is now solid and crash-free - the practical foundation for the real multiplayer flow. Remaining work, in priority order: (1) persist the player's accepted car upgrades before/during the exit (not yet investigated - need to find where confirmed mods are read/written), (2) open the lobby overlay after returning to the map (explicitly deferred by the user), (3) if actual race-loading is wanted later, the
RaceLoaderTask_ResetStartingLinecrash (cont.36) needs a realTrackId/scene reference on the syntheticRaceEvent- a materially bigger investigation than anything solved so far, since it requires understanding how a real track scene gets loaded and referenced, not just patching a data-completeness gap. (4) The two open design questions (car_select without event_detail; direct race launch from the map) remain untested hypotheses, not yet worth pursuing until (1)-(2) are done.
2026-08-11 (cont. 38) — Found the real mod-selection event and handler: UIRolloutSelectedEvent → sub_37BE74/sub_37BF34; confirmed selection commits instantly, no separate "confirm" step exists
- What: User asked to find where the engine reads the selected upgrades on confirm (item (1) from cont.37's remaining-work list). Static C++ archaeology first (chasing
im::app::car::CarDescription/im::app::metagame::Profile+ProfileChangeSetboost::bind RTTI strings throughsub_256188→sub_256028→vtable0xaa5f48→sub_252A9C→sub_251DBC) dead-ended on a generic "subscribe to component change-event" utility with nothing mod-specific - same unproductive pattern as cont.34's subtitle search. Pivoted to a live/empirical approach instead, per this project's standing RE-verification-via-touch allowance (seePROGRESS.mdcont.25/project_visible_streets_investigation methodology precedent): realadb shell input tap(not the project's own disabled synthetic-tap infra) on the live loadout screen, observed via the existingHook_InternStringDiag/Hook_FatalLogCallerTracediagnostics (budgets raised 3000/5000 → 8000/8000). - Live discovery: opening a mod slot showed the real
CarModcatalog on-screen -НЕТ(free)/ВОССТ. ШИНЫ(TYRES_REINFLATING, 1000$)/ЗАЩ. ОТ СТОЛКН.(1000$)/УСИЛЕННОЕ ШАССИ(1500$) - matching the previously-confirmedCarModschema. Selecting an option updates the slot display and the total cost immediately - confirms selection commits instantly, there is no separate "confirm" action to hook. - Methodology hazard caught mid-investigation: an early offset computation used a
libapp_basevalue logged from a different app launch than the one that produced the caller address (ASLR re-randomizes per launch; thelibapp_base=0x...log line kept scrolling out of the ring buffer before it could be captured alongside later diagnostic lines). Recognized as unsafe before finalizing anything - redid the test with a clean launch, capturinglibapp_basevialogcat -d | grep libapp_basewithin ~3s ofam start, before more log volume could push it out. - Correctly-paired result: with
libapp_base=0xb9c9c000confirmed for the run, tapping a slot then selectingВОССТ. ШИНЫproduced one single-fireDIAG sub_40A29Ccall (as_str="MOD_NAME_TYRES_REINFLATING",caller=0xba017fec) distinct from the ~20x-repeating list-population caller (0xba174fe0, fires once per catalog entry every time the picker menu opens). Real file offset:0xba017fec - 0xb9c9c000 = 0x37BFEC, inside functionsub_37BF34(0x37BF34). - Decompiled
sub_37BF34(a1, a2):a1= the loadout slot's UI/component object,a2= an event pointer. Readsv4 = *(a2+8)(0 = "no mod"/empty, else aCarModreference); if non-zero, resolves the mod's component data (sub_890EC/sub_B25CC), looks up its localized name/description viasub_40A29C(name) andsub_388D84(description) and writes them into the slot's label widgets (a1+264/a1+272), sets the slot's icon (via a vtable call at*(a1+280)+92), and recomputes something usinga1+292/a2+8at the end (sub_25DEF4) - almost certainly the running total cost. Ifv4==0(empty/"НЕТ" selected), takes the else-branch: sets the label to"UI_EMPTY_MOD"and hides the description widget instead. Clears a dirty flag ata1+289on exit. - Traced one level up -
sub_37BE74(0x37BE74,sub_37BF34's only caller): a standardim::Event-dispatch handler on the slot component.lpsrc[1]is the event's type-tag; type 1057dynamic_casts the incoming event toim::app::events::UIRolloutSelectedEventand, if it matches, callssub_37BF34(a1, thatEvent)directly - i.e.a2insub_37BF34IS theUIRolloutSelectedEvent*itself, and the selectedCarModis read straight fromUIRolloutSelectedEvent+8(the event's own payload field), not from some separately-stored slot-state field. Type 1056 (UIRolloutSlotButtonClickEvent, the click that opens the picker) is handled separately - comparesv7[2](clicked slot id) againsta1+292(this slot's own id) purely to invalidate a stale dirty-flag when a different slot was clicked, unrelated to reading the selection itself. - Answer to the user's question: there is no "confirm" step to hook -
im::app::events::UIRolloutSelectedEventis the commit. It's posted the instant the player taps an option in the rollout/dropdown list, delivered to the slot's event handler (sub_37BE74), which reads the chosenCarModdirectly from the event's own+8field and immediately writes it into the slot's display (sub_37BF34). To persist accepted upgrades (cont.37's deferred item (1)), the natural hook point issub_37BF34itself: at entry,*(int*)(a2+8)is exactly "theCarModjust selected for this slot" (0 = cleared/empty) anda1identifies which slot (a1+292looks like a slot-id field, matching the id compared against insub_37BE74's 1056-handler) - both pieces of information needed to build a{slot_index → CarMod}record without touching any UI code. - Snapshot: no code changes this session - purely investigative (live diagnostics + IDA decompilation).
g_internStringLogBudget/g_fatalLogCallerBudgetremain raised to 8000/8000 inlan_event_injection.hfrom this investigation's needs; harmless (budget-gated, inert once exhausted) but worth reverting toward the earlier 3000/5000 defaults if diagnostic log volume becomes a problem in future sessions. - Outstanding from cont.37, still not restored: the 3
"BACK"entries inkOutputChain[]are still commented out (chain currently halts ongarage_select_rolloutinstead of exiting to the map) - this was intentionally left disabled to give room for this session's manual tap-based investigation and must be uncommented before any further production-path testing. - Outcome/Next: The mod-selection commit path is now fully understood and pinpointed to two exact functions/offsets. Next step (not started): add a hook on
sub_37BF34(0x37BF34) that readsa1(slot id viaa1+292) and*(int*)(a2+8)(selectedCarMod) and stores them into a small persistent{slot → CarMod}array, then restore the"BACK"chain and use that array right before/during the exit tomap_overworldto actually persist the accepted upgrades - fulfilling cont.37's deferred item (1). Item (2) (lobby overlay) and the two open design questions remain deferred as before.
2026-08-11 (cont. 39) — Implemented the sub_37BF34 hook; found and fixed a broadcast-event bug live: a naive version recorded the same pick into BOTH slots
- What: Implemented cont.38's proposed next step - a hook on
sub_37BF34(MODSLOT_SELECTED_OFFSET 0x37BF34) that records each accepted mod pick into a small in-memory{slotId -> CarMod}table (ModSlotSelection g_modSlotSelections[MAX_TRACKED_MOD_SLOTS], update-in-place byslotId, matching the field read ata1+292). Confirmed the real ARM prologue live via IDA disasm before writing the trampoline (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C- same pattern already used by several other hooks in this file, e.g.InstallResolveDisplayTextHook/InstallLayoutScreenCtorHook), rather than assuming it held. Registered viaInstallModSlotSelectedHook()inmain.cpp'sJNI_OnLoad. Built clean (:mpcore:externalNativeBuildDebug,:app:assembleDebug) and installed on-device (Galaxy A9) for live testing, since this project verifies UI-facing behavior in the actual running app rather than trusting a clean compile alone. - First live test found a real bug: tapping the FIRST slot and selecting
ВОССТ. ШИНЫ(TYRES_REINFLATING) logged twoModSlot: slot N recordedlines back-to-back -slot 0 -> CarMod=0x1ANDslot 1 -> CarMod=0x1- from a single tap, even though the screenshot taken immediately after clearly showed only the TOP slot visually updated (bottom stayedПУСТО (УЛУЧШ.), total cost1 000$matching only one mod applied). Root cause:im::app::events::UIRolloutSelectedEventis broadcast to every slot'ssub_37BE74listener, not just the one whose picker was open - both slots'sub_37BF34calls receive the same event object asa2, so both read the identical*(a2+8)value. Unlike event type 1056 (UIRolloutSlotButtonClickEvent, the click that opens a picker), whichsub_37BE74gates by comparing the clicked slot id againsta1+292before acting, type 1057 (UIRolloutSelectedEvent) has no such gate insub_37BE74- it callssub_37BF34unconditionally for every listener. The real gate lives one level deeper:sub_37BF34's entire body is wrapped inif (*(_BYTE*)(a1+289))(the field already visible in cont.38's decompile, previously read only as "a dirty flag cleared at the end") - the non-target slot's call takes the early "do nothing, return 0" path. Our original hook reada1/a2unconditionally before the original function ran, so it recorded both slots regardless of which one the flag would actually let through. - Fix: added
MODSLOT_DIRTY_FLAG_OFFSET 289and changedHook_ModSlotSelectedto check*(uint8_t*)(a1+289) != 0before recording - mirrors the exact same gate the engine itself checks, so the hook only records for the slot the pick actually applies to. - Second live test confirms the fix: relaunched, opened the SECOND (bottom) slot this time, selected
ЗАЩ. ОТ СТОЛКН.(collision protection) - logcat showed exactly one line,ModSlot: slot 1 recorded -> CarMod=0x5, and the screenshot confirmed only the bottom slot visually updated (top stayed empty). No crash either time. - Side observation:
CarModvalues read at*(a2+8)are small integers (0x1,0x5), not raw pointers - confirms this field is a catalog index/id (resolved into a real component pointer later insidesub_37BF34viasub_890EC), not theCarMod*itself as cont.38's writeup loosely phrased it. Doesn't change the hook's correctness (the raw id is exactly what's needed to persist "which mod was picked"), just a precision correction to last session's terminology. - Snapshot:
lan_event_injection.h- added the "Mod-selection tracking (cont.38/39)" section (MODSLOT_SELECTED_OFFSET,MODSLOT_DIRTY_FLAG_OFFSET,ModSlotSelection/g_modSlotSelections/g_modSlotSelectionCount,RecordModSlotSelection,Hook_ModSlotSelected,InstallModSlotSelectedHook) right before the Soak Test disable section.main.cpp- addedInstallModSlotSelectedHook();call inJNI_OnLoad, right afterInstallLayoutScreenCtorHook(). - Verification: Live on-device (Galaxy A9,
adb -s 2a40cdac1f0d7ece), two full pick cycles (slot 0 then, after a clean relaunch, slot 1), each cross-checked against a screenshot of the actual UI state, not just the log line. - Outcome/Next:
g_modSlotSelections[]now correctly reflects exactly what's on-screen, per-slot, immediately after each pick - the data half of "persist accepted upgrades" (cont.37's deferred item (1)) is done. Still needed to close that item out: (a) restore the 3 commented-out"BACK"entries inkOutputChain[](still disabled from cont.38's diagnostic pause), (b) actually consumeg_modSlotSelections[]at the point of exiting tomap_overworld(write it wherever the multiplayer lobby schema expects accepted upgrades - not yet designed). Item (2) (lobby overlay) and the two open design questions from cont.37 remain deferred as before.
2026-08-11 (cont. 40) — Restored the BACK×3 exit chain and wired upgrade persistence to a new GameEvents bridge; a risky verification attempt found (and fixed by reverting) a real crash in the pre-existing chain mechanism, not in this session's new code
- What: User asked to (a) uncomment the
"BACK"×3 chain entries left disabled since cont.38's diagnostic pause, and (b) finish "persist accepted upgrades on exit to map" (cont.37/39's deferred item). RestoredkOutputChain[]to its full 5-entry production shape (EVENT,CONTINUE,BACK,BACK,BACK). - Persistence implementation: extended the existing native→Kotlin
GameEventsbridge (game_events.h/GameEvents.kt, already used foronMapLoaded/onRaceStarted/onRaceEnded) with a newonUpgradesAccepted(slotIds: IntArray, carMods: IntArray)event andFireUpgradesAccepted()(JNINewIntArray/SetIntArrayRegionmarshaling, since this is the bridge's first event carrying data rather than a plain no-arg lifecycle ping). AddedPersistAcceptedUpgrades()inlan_event_injection.h, which copiesg_modSlotSelections[]into parallel arrays and callsFireUpgradesAccepted(). Wired the call intoHook_LayoutScreenCtor, gated ong_firedEventTest && !g_upgradesPersisted && g_chainIndex >= kOutputChainLength && screenName == "MapOverworld"- i.e. fires exactly once, exactly when the BACK-chain's final hop lands back on the map (the "controlled exit" moment cont.37 asked for), not any earlier hop, since a player could still change a slot up until actually leaving the loadout screen.g_upgradesPersisted(a one-shot guard, forward-declared near the top of the file sinceHook_LayoutScreenCtoris defined before the mod-tracking section that owns the data) prevents a double-fire ifMapOverworldis ever reconstructed again later for unrelated reasons. - First-pass verification (production 1500ms timing, no manual mod selection): full 5-hop chain fired clean, ending with
PersistAcceptedUpgrades: exiting to map, dispatching 0 accepted slot(s) to Kotlin/GameEvents: onUpgradesAccepted dispatched to Kotlin successfully (0 slot(s))- confirms the whole persistence plumbing (gating, array copy, JNI array marshaling, Kotlin dispatch) works end-to-end and survives the full production BACK×3 exit with zero crashes, for the empty-selection case. - Attempted to also verify the non-empty case, discovered the deferred-fire chain is tighter than adb tap latency allows: selecting a mod needs two taps (open picker, pick option), but each
adb shell input tapcall has ~700-900ms of its own overhead - close to the full 1.5s window between a chain target being captured and the next hop auto-firing. A tight single-script poll+tap attempt (no round-trip through the agent) still landed the second tap ~100ms after the chain had already auto-fired BACK, i.e. selection didn't complete in time. - Escalated by temporarily widening the deferred-fire delay to 6000ms (commented
TEMP cont.39: ... revert before shipping) purely to get verification headroom - and this did surface a real crash, SIGSEGV fault addr 0x74 inGLThread, right asLIVE TEST CHAIN[3]firedBACKon the capturedRestrictedGarageLoadoutpointer. Root cause: the user's live observation (mid-test) caught it directly - "промах по кнопке открытия меню улучшений и сразу после этого игра вылетела" (a missed tap on the upgrade-menu-open button, then an immediate crash). The mistapped input landed on something that opened an unexpected"GarageView"screen (never seen in any prior run of this chain) partway through the widened 6s wait. SinceHook_LayoutScreenCtor's target-capture is guarded by!g_chainTargetPending(only captures once per hop),GarageView's construction didn't updateg_chainTarget- it stayed pointed at the originalRestrictedGarageLoadoutinstance, which by the time the deferred timer fired 6s later may have already been torn down/replaced by theGarageViewtransition. FiringFireOutput("BACK")against that stale pointer crashed. - This is a real fragility in the pre-existing deferred-chain mechanism (present since cont.35/37, not introduced this session): the whole design assumes the captured target screen stays valid until its hop fires, which holds at the original 1.5s delay (tight enough that no other, unrelated screen transition has time to happen first) but breaks down if that window is widened, or in general if any out-of-band navigation (misplaced tap, background event, etc.) occurs while a hop is pending. Not fixed - this session's actual deliverable (persistence + restored chain) uses the original, already-proven 1.5s timing; the widened-delay build was a throwaway test-only artifact, immediately reverted (
MonotonicMillisSince(...) >= 1500, TEMP comment removed) once the crash was understood, never left in a shippable state. - Final re-verification (production 1500ms timing, post-revert): rebuilt, reinstalled, relaunched clean - full 5-hop chain fired without any manual interaction, ending in the same clean
PersistAcceptedUpgrades .../onUpgradesAccepted dispatched ... (0 slot(s))pair, zero crashes. Confirms the reverted build is exactly as solid as the pre-widening baseline. - Snapshot:
lan_event_injection.h-kOutputChain[]restored to its full 5-entry shape (comment updated to reflect cont.38's pause being over); addedg_upgradesPersisted/forward-declaredPersistAcceptedUpgrades()near the top; added the persistence-dispatch block inHook_LayoutScreenCtor; addedPersistAcceptedUpgrades()definition next toRecordModSlotSelection.game_events.h- addedg_onUpgradesAcceptedMethod, resolved it inInitGameEvents, addedFireUpgradesAccepted(slotIds, carMods, count)with its own JNI int-array marshaling.GameEvents.kt- addedonUpgradesAccepted(slotIds, carMods)toGameEventListener(default no-op) anddispatchUpgradesAccepted()toGameEvents. - Verification: Live on-device (Galaxy A9), three full end-to-end runs of the 5-hop chain (two at production 1500ms timing, one at a temporary 6000ms that surfaced the stale-target crash above and was reverted) - all cross-checked against full logcat captures, not just spot lines.
- Known gap, not closed this session: the non-empty-selection case (
g_modSlotSelectionsactually containing a pick at the momentPersistAcceptedUpgradesruns) was NOT proven end-to-end through the live production chain - only proven independently in two pieces: (1) per-slot recording correctness (cont.39, screenshot-verified against real UI state, twice), and (2) the persist-and-dispatch mechanism surviving the full chain cleanly (this session, with a 0-slot result). The gap between them is a straightforward array copy with no pointer dereferencing or engine calls, so risk is low, but it's not the same as a closed-loop proof. If this matters later, the practical way to close it is a slower one-shot test build (e.g. the chain's very first hop only,kOutputChain = {"EVENT"}, giving unlimited manual time on the loadout screen before separately, manually, firing the remaining BACK×3 sequence) rather than widening the deferred-fire delay itself (now known to be unsafe, per the crash above). - Outcome/Next: cont.37's deferred item (1) ("persist accepted upgrades before/during exit to map") is now implemented and wired end-to-end, modulo the non-empty-case gap noted above.
GameEvents.onUpgradesAcceptedhas no real consumer yet - it's the hook point for whatever eventually needs this data (item (2), the lobby overlay, still deferred; no lobby/network schema exists yet). The stale-chain-target fragility discovered above is a pre-existing risk worth keeping in mind for any future work that touches the deferred-chain timing, but is out of scope to fix right now since production timing (1.5s) has never been observed to trigger it.
2026-08-11 (cont. 41) — Found the REAL click handlers behind every confirm-checkmark tap, via live manual-tap discovery on a genuine (non-synthetic) event; bonus: proved a real event races end-to-end with zero crashes, confirming cont.36's root-cause theory
- What: User laid out a longer-term roadmap - learn to open car_select reliably, apply upgrades+color correctly, and on the real last checkmark tap return to the map, with overlay wiring coming last - then chose "hook the real confirm checkmark" as the starting piece, adding "как бы минуя окно события" (as if bypassing the event window) as a refinement. Since everything so far (cont.35-40) drives navigation via a timer-based test harness (
kOutputChain, fired ~1.5s after each screen appears, regardless of any real tap), the actual goal here is finding the game's own click-handler code so a REAL checkmark press can be intercepted/redirected directly, instead of relying on the timer. sub_1BB59C(FireOutput's real target) turned out to be a 2-instruction PC-relative-branch thunk (LDR R0,[R0,#8]; B sub_1581A0) - unsafe to hook with this file's usual 2-word-copy trampoline (a relocatedB's encoded offset resolves to the wrong target once moved). Hookedsub_1581A0instead (the real function the thunk tail-calls into, confirmed via disasm to have a safePUSH {R4-R6,R10,R11,LR}; ADD R11,SP,#0x10prologue) - functionally identical for every caller, since the thunk always redirects there regardless of who calls it (this project's ownFireOutputpointer included, which still resolves to the thunk's address). AddedHook_FireOutputDiag/InstallFireOutputDiagHook(FIRE_OUTPUT_REAL_OFFSET 0x1581A0), loggingflowNode, the resolved output-name string, and__builtin_return_address(0)(budget-gated,g_fireOutputLogBudget, set live at install time rather than tied to the old tick-based trigger).- Added
kEnableAutoChainTest(defaulttrue) tolan_event_injection.h- flips off the entire timer-drivenkOutputChainauto-navigation (leavingInjectSyntheticEvent/the map pin itself untouched) so real taps drive navigation instead, for this kind of discovery work. Temporarily setfalsefor this session's testing, reverted totruebefore finishing. - Live manual-tap walkthrough (auto-chain disabled): tapped the real map pin ("МАККЛЕЙН") → an existing REAL event card ("ПОБУДКА", not our synthetic one - chosen deliberately to sidestep cont.36's known synthetic-RaceEvent race-loading crash while hunting for click handlers) → event_detail's real confirm checkmark → car_select's real confirm checkmark → the loadout screen's mod-slot picker (a REAL, non-empty mod selection this time) → the loadout screen's real flag/checkmark button. Every hop's
DIAG FireOutputline was cross-referenced againstDIAG LayoutScreenCtor's next screen andInputDispatcher's tap timestamps to confirm which caller belongs to which real tap (not to be confused with any residual test-harness firing, since the harness was off). - Results, with real file offsets (this run's
libapp_base=0xb9d84000):- Event-card tap ->
EVENT: caller0x17aa58, which decompiled to insidesub_17A99Citself (i.e.FireEventOutput, the same primitive this project already calls programmatically) - meaning the true UI click handler is one level further up (whatever callsFireEventOutput), not yet identified, since__builtin_return_address(0)only shows the immediate caller ofFireOutput. - event_detail's checkmark ->
EVENT, AND car_select's checkmark ->CONTINUE: same caller both times,0x1a7958, decompiling tosub_1A7920(0x1A7920) - a tiny generic function (sub_40685C(v4, a2); return sub_1BB59C(a1);, i.e. "intern this output name, fire it") with 32 xrefs including several vtable data slots - a generic "Flow-output button" widget class'sOnClick, reused across most of the UI, with the specific output name configured per button instance (from layout/prefab data). This is why the same function fires different output names for different screens. - The loadout screen's own flag/checkmark ->
CONTINUE: caller0x16c764, decompiling to insidesub_16C660(0x16C660, size0x7f0- a large, screen-specific function, not the generic button handler). Its logic: checks "new cars unlocked" popups (CARSELECT_NEW_CARS_UNLOCKED/CAR_UNLOCK_POPUP_BODY_*), checks tutorial-gating popups (TUTORIAL_DEALERSHIP_POPUP/TUTORIAL_RESTRICTION_POPUP/TUTORIAL_MODS_POPUP, the last one gated on the real event ID literally being"event_03_race"), and only then fires the real output -"CONTINUE"normally, or"GAMESCOM_CONTINUE"if a demo-build flag is set (the same GAMESCOM demo-config system dead-ended on in cont.38's static search - confirmed here to be a real, still-present branch, just not the mod-selection lead it was chased for).
- Event-card tap ->
- Bonus discovery, unplanned but highly informative: since this whole walkthrough used a REAL event (not our synthetic
RaceEvent), tapping the real loadout checkmark proceeded pastPreRaceLoadingScreeninto an actual, fully playable race (screenshot confirms live gameplay: position 6/6, real speed/HUD, wall-scrape sparks) - zero crashes,pidofconfirmed the process stayed alive throughout. This directly confirms cont.36's root-cause theory: theRaceLoaderTask_ResetStartingLinecrash was specifically about our syntheticRaceEventnever referencing a real track scene - a real event with real track data sails through the exact same code path that crashes on synthetic data. - Mod-selection persistence also reconfirmed with real (non-synthetic) data: selected
ВОССТ. ШИНЫon the real loadout screen's first slot -ModSlot: slot 0 recorded -> CarMod=0x1fired correctly, and the screen visibly showed the mod applied (1000$ total) - the same dirty-flag-gated hook from cont.39 works identically on real and synthetic RaceEvents, as expected (it never touched RaceEvent internals in the first place, only the slot-widget/event-payload fields). - Snapshot:
lan_event_injection.h- addedkEnableAutoChainTesttoggle (guarding thetickCount-based auto-fire block inHook_MapScreenTick), added the "REAL FireOutput click-handler discovery" section (FIRE_OUTPUT_REAL_OFFSET,g_fireOutputLogBudget,Hook_FireOutputDiag,InstallFireOutputDiagHook).main.cpp- addedInstallFireOutputDiagHook();toJNI_OnLoad. Left in place (budget-gated, harmless) for future re-use rather than removed, matching this project's established pattern for diagnostic hooks (cont.32'sInstallInternStringDiagHooketc.). - Verification: Live on-device (Galaxy A9), one complete manual walkthrough with
kEnableAutoChainTest=false, cross-checkingDIAG FireOutput/DIAG LayoutScreenCtor/InputDispatcher/ModSlotlog lines against screenshots and real tap timestamps at every hop. RevertedkEnableAutoChainTesttotrueand confirmed the app was cleanly force-stopped afterward (it was mid-race at investigation end). - Outcome/Next: the real click-handler landscape is now mapped:
sub_1A7920(generic Flow-output button, used by event_detail/car_select's confirm and 30+ other spots) andsub_16C660(loadout-screen-specific confirm handler, which itself calls through toFireOutputsame as everything else). Since every real handler ultimately funnels through the sameFireOutput/sub_1581A0primitive already hooked this session (Hook_FireOutputDiag), the cleanest path to "redirect the real checkmark press away from race-loading" (the original cont.37/40 goal, now unblocked) is likely to extendHook_FireOutputDiagitself: check whether the resolved output name is"CONTINUE"and whetherflowNodematches the currently-tracked loadout screen's own flow node (*(screenOwner+8), computable fromg_lastLayoutScreenInstance), and if so substitute the BACK-chain instead of letting the real"CONTINUE"/race-load proceed - without needing to touchsub_16C660's substantial unlock/tutorial-popup logic at all. Not yet implemented - this is the concrete next step. The "открыть car_select минуя event_detail" (open car_select bypassing the event window) refinement from the user's message is also still open -sub_17A99C's decompile (the event-card-tap path) shows it resolves the event ID through the same handle-cache/ResolveHandlemachinery already used elsewhere in this project, suggesting a direct call toFireEventOutput-equivalent logic bypassing the event_detail screen might be feasible, but this hasn't been tested.
2026-08-12 (cont. 42) — Implemented the real-checkmark interception; found it's insufficient for this test event due to a car_select auto-cascade the substitution can't stop; hardened the chain against the resulting crash but the redirect itself remains unsolved
- What: User asked to continue: after the real last checkmark, we should get ALL the information for our lobby - i.e. finish wiring cont.41's discovered click handlers into an actual redirect (away from race-loading, into the proven controlled BACK exit) plus persistence, instead of just observing them.
- Implementation, attempt 1: extended
Hook_FireOutputDiag(hooked onsub_1581A0, cont.41) to detect the specific call:outputName == "CONTINUE"AND the currently-tracked screen (g_lastLayoutScreenName/g_lastLayoutScreenInstance, now updated inHook_LayoutScreenCtoron every screen construction) is"RestrictedGarageLoadout", with a redundantflowNode == *(screenOwner+8)check. On match: suppress the call, mark a new independent state machine (g_realExitChainActive, separate from the old test-harness'sg_chainIndex/kOutputChain) active, and kick off the same proven BACK x3 exit via the existing deferred-fire machinery, reusingHook_LayoutScreenCtor's screen-capture andHook_InternStringDiag's 1.5s-deferred-fire pattern. - Caught before testing: since
Hook_FireOutputDiagwas declaredvoidbut the realsub_1581A0returnsint- and this specific caller,sub_16C660, actually uses that return value afterward (v13 = sub_16CE9C(v10), followed by more tutorial/unlock-popup logic) - simply suppressing the call and returning nothing would handsub_16C660an undefined R0 value to keep computing with, a real correctness risk (not just a style issue - avoid-typed function gives the compiler no reason to preserve a callee's return register across the call). Fixed before ever running it: changedHook_FireOutputDiag's signature toint, changedFireOutputRealFn's typedef to match, and - rather than suppressing the call outright - substituted the output name from"CONTINUE"to"BACK"and let the realsub_1581A0implementation run for real (same technique already used throughout this file for firing named outputs -InternStringa new name, pass it through). This givessub_16C660a well-defined return value from a real transition (functionally identical to what a real tap on the screen's own<<back-arrow would produce), while still redirecting navigation away from race-loading. - First live test (production 1.5s timing, real event "ПОБУДКА", real mod selection): the substitution fired correctly (
REAL CONFIRM: intercepted CONTINUE from RestrictedGarageLoadout ... substituting BACK), and the screen wentRestrictedGarageLoadout -> RestrictedGarage(car_select) as expected - but then, entirely on its own, in under 1 second and with zero further taps from us, cascaded straight back throughRestrictedGarage -> RestrictedGarageLoadout -> PreRaceLoadingScreen. The still-fixed-3-hop deferred chain (a leftover assumption from the original design) then fired its 2nd scripted "BACK" against a target captured earlier that was by now stale/destroyed by this unexpected cascade, crashing (SIGSEGV fault addr0x74,GLThread) - the exact same class of bug as cont.40's stale-pointer crash, but this time triggered by real, un-forced game behavior instead of a deliberately-widened test delay. - Root-cause hypothesis (not yet confirmed via further RE, but well-supported): the test event "ПОБУДКА" only has one available/unlocked car (Ford Focus RS500 - no alternate cars appeared despite the car_select screen's
<< >>switch-car arrows being visible) and is class-restricted (КЛАСС - КАЖДЫЙ ДЕНЬ, shown locked).car_select's own construction/tick logic most likely detects "nothing for the player to actually choose here" and auto-advances forward on its own regardless of which direction the screen was reached from (a real "BACK" tap into car_select would probably trigger the exact same auto-cascade, not something specific to our hook) - a legitimate friction-reduction UX pattern colliding with this project's assumption that BACK-navigating a screen leaves it sitting there waiting for the player. - Hardened the chain against the crash (not yet a fix for the redirect goal, but a real robustness improvement): replaced the fixed-3-hop assumption with live tracking -
g_realExitChainAttempts/REAL_EXIT_CHAIN_MAX_ATTEMPTS(capped at 10) instead of a hardcoded count, firing "BACK" againstg_lastLayoutScreenInstance/g_lastLayoutScreenName(always the CURRENTLY displayed screen, read fresh at fire-time) instead of a pointer snapshotted when the hop was queued, checking after each ~1.5s delay whether"MapOverworld"has been reached, and - critically - addedIsKnownBackableCarFlowScreen(), a whitelist (RestrictedGarageLoadout/RestrictedGarage/EventDetails) that makes the chain stop and log instead of firing "BACK" blindly if it ever finds itself on an unrecognized screen (e.g.PreRaceLoadingScreen, which may not even have aBACKoutput configured). The persistence trigger (PersistAcceptedUpgrades) was decoupled from the old hop-count in the same pass - now gated on a simple one-shotg_realExitChainTriggeredflag (set once, at interception time) instead of "did we count exactly 3 hops", since hop count is no longer a meaningful invariant. - Second live test (same conditions, hardened chain): no crash - the chain correctly detected it had landed on
"PreRaceLoadingScreen"(REAL EXIT CHAIN: landed on unexpected screenName="PreRaceLoadingScreen" ... stopping rather than firing BACK blindly) and stopped safely. However, this also means the redirect itself did not succeed - a screenshot confirmed the app auto-cascaded all the way into an actual, playable race (real damage bar, real HUD, real gameplay) despite our substitution, since the underlying auto-continue mechanism turned out to be independent of what string we pass toFireOutputat this one call site. - Snapshot:
lan_event_injection.h- movedg_lastLayoutScreenInstance/g_lastLayoutScreenNamedeclarations up near the top of the file (now needed byHook_InternStringDiag, defined earlier than their original spot next toHook_LayoutScreenCtor); added the full "Real controlled-exit chain" state block (g_realExitChainActive/g_realExitChainTriggered/g_realExitChainAttempts/g_realExitChainTargetSetAt/g_realExitChainTargetPending/g_realUpgradesPersisted,REAL_EXIT_CHAIN_MAX_ATTEMPTS,IsKnownBackableCarFlowScreen); changedHook_FireOutputDiag/FireOutputRealFnto returnintinstead ofvoid; added the interception block (checksisRealLoadoutContinue, substitutes "BACK", arms the live-tracking deferred loop); added the live-tracking deferred-fire block inHook_InternStringDiag; added theg_realExitChainTriggered-gated persistence trigger inHook_LayoutScreenCtor, replacing the old hop-count-gated one. - Verification: Live on-device (Galaxy A9), two full attempts with
kEnableAutoChainTest=false(manual real taps only) - first attempt crashed (analyzed above, informed the fix), second attempt (post-fix) survived without crashing but did not achieve the redirect.kEnableAutoChainTestreverted totrueand the app force-stopped (was mid-race) before finishing, leaving the device in a clean, safe state. - Outcome/Next: The interception mechanism itself (detecting the real "CONTINUE" from the loadout screen, with a properly-typed return value and a crash-hardened live-tracking exit chain) is solid infrastructure and didn't crash on its second try - but it does not yet achieve the actual goal for events like "ПОБУДКА" where car_select auto-advances on its own. Two possible directions, not yet explored: (a) find and neutralize whatever state/condition makes car_select auto-advance (deeper RE into
sub_16C660's siblings or car_select's own ctor/tick, looking for the "single available car" gate) so BACK-navigating it actually stops there like it does for events with multiple car choices; (b) intercept even earlier - hooksub_16C660itself (or gate our substitution on some flag that suppresses car_select's auto-advance specifically when reached via our redirect) rather than relying on FireOutput's argument alone, since that's proven insufficient on its own. The upgrade-persistence half of "get all info for the lobby" remains implemented and would fire correctly once the redirect itself works; the "car info" half (make/model/color) hasn't been started - the earlier session'sCAR_MAKE_FORD/CAR_MODEL_FOCUS_RS500sightings (cont.38) are localization keys, not the underlying car-id field, and locating that field is still open, unstarted work.
2026-08-12 (cont. 43) — SOLVED: real-checkmark redirect now works end-to-end; root-caused the auto-continue, abandoned a risky direct hook after it broke touch input, fixed it by widening the already-safe FireOutput interception instead
- What: User asked to dig deeper into
sub_16C660and find the actual source of the auto-continuation discovered in cont.42. - Root cause found: checked
sub_16C660's own xrefs (only 2: a vtable click-dispatch slot, and one direct call site) and decompiled the direct caller,sub_188024(0x188024). It's the car-select-family screen's own per-frame Tick function, and its very first action, unconditionally, every single frame, issub_16C660((_DWORD*)a1)- no gating, no player-input check. Combined with cont.41's finding thatsub_16C660's own body fires the real"CONTINUE"once its internal state (a1[111]/sub_1EF580()) judges the session "fully configured" (a real car chosen, nothing left to decide) - this fully explains cont.42's cascade: it's not that BACK-navigating into car_select somehow re-triggers a stale "confirmed" flag; car_select's own Tick re-evaluates and re-fires"CONTINUE"every frame, completely independent of how the screen was reached, for as long as that condition holds (which our BACK substitution never clears). - First attempted fix: hook
sub_16C660directly (confirmed safe-to-relocate prologue via disasm:PUSH {R4-R11,LR}; ADD R11,SP,#0x1C) and skip its entire body whenever our redirect is active (g_realExitChainActive), stopping the tick-driven re-fire at the source rather than fighting it downstream. Live-tested and found to break touch input: with the hook installed, navigation reliably got stuck onRestrictedGarage(car_select) - taps that worked perfectly without the hook (confirmed via an isolated control test, hook removed, otherwise identical build) produced no response at all, reproduced 5/5 tries with the hook vs 1/1 clean without it. Budget-gated diagnostic logging inside the hook showedsub_16C660itself running completely normally through the hook (~60 calls/sec, each"returned 0", no hang, no crash) right up until the point touch stopped registering - the mechanism of the regression was never identified. Rather than keep guessing at a function called this frequently (far higher call rate than anything else hooked in this project - previously the highest-frequency hook wasHook_InternStringDiag, itself only firing ~4/sec), abandoned this approach entirely - too risky to ship a hook whose failure mode is silent, unexplained input breakage.InstallConfirmCarSelectionHook()is left defined in the source (with a clear "do not install, breaks input" comment) but is not called fromJNI_OnLoad. - Working fix: widened the already-proven-safe
FireOutput-level interception instead. Since the tick's auto-continue still has to go through the sameFireOutput/sub_1581A0call that's already safely hooked, extendedHook_FireOutputDiag: once the redirect has started (g_realExitChainActive), also catch any repeated"CONTINUE"fired from any known car-select-flow screen (IsKnownBackableCarFlowScreen, cont.42's whitelist) and substitute"BACK"again, every time, for as long as it keeps happening - not just the first interception. Live-tested: the tick re-fires far faster than actual screen navigation can process (all firing against the same still-staleflowNodeuntil the real transition catches up) - the originalREAL_EXIT_CHAIN_MAX_ATTEMPTS(10, sized for "3 real hops") burned out in ~150ms without the real navigation ever getting a chance to catch up, so raised it to 300 (cheap safety margin - each attempt is a fast, harmlessFireOutputsubstitution, not a real per-hop cost). - Live result: full success. One clean end-to-end run (real map-pin tap → real event-card tap → real event_detail checkmark → real car_select checkmark → real mod selection on loadout → real loadout checkmark): the widened interception caught and redirected the auto-cascade through 57 substituted
"BACK"fires (the vast majority against the same staleflowNodeduring the initial burst, a handful of genuinely-progressing ones as the screen stack actually unwound:RestrictedGarageLoadout → RestrictedGarage → EventDetails → MapOverworld), landed cleanly onMapOverworld, and correctly firedPersistAcceptedUpgrades: exiting to map, dispatching 1 accepted slot(s) to Kotlin/GameEvents: onUpgradesAccepted dispatched to Kotlin successfully (1 slot(s))- the REAL, non-empty upgrade pick (ВОССТ. ШИНЫ/TYRES_REINFLATING) selected on the REAL loadout screen, delivered end-to-end. Zero crashes, app confirmed still alive (pidof) and crash-log-clean afterward. - Snapshot:
lan_event_injection.h-REAL_EXIT_CHAIN_MAX_ATTEMPTSraised from 10 to 300;Hook_FireOutputDiag's interception widened withisRepeatedAutoCascadeContinue(catches repeated CONTINUE from anyIsKnownBackableCarFlowScreenonceg_realExitChainActive, substituting BACK again each time, resetting the deferred-fallback timer); added (but did not wire up)CONFIRM_CAR_SELECTION_OFFSET/ConfirmCarSelectionFn/orig_ConfirmCarSelection/Hook_ConfirmCarSelection/InstallConfirmCarSelectionHookandg_confirmCarSelectionLogBudget, clearly marked as investigated-and-abandoned (breaks input, root cause of that regression unknown).main.cpp-InstallConfirmCarSelectionHook()call commented out with a detailed explanation,InstallFireOutputDiagHook()/others unchanged. - Verification: Live on-device (Galaxy A9), multiple rounds this session - an isolated hook-vs-no-hook comparison (confirming
sub_16C660's direct-hook regression was real and reproducible, not device flakiness - one clean control run succeeded, five consecutive hook-installed runs all failed identically), then a full real-tap walkthrough with the widened fix confirming complete success including real non-empty upgrade persistence. Device left in production config (kEnableAutoChainTest=true,Hook_ConfirmCarSelectionnot installed) and app force-stopped afterward. - Note on tooling, not code:
adb shell screencap/exec-out screencapintermittently returned a stale/constant 14233-byte black PNG for a stretch of this session's testing, unrelated to any app or hook state (confirmed viadumpsys power/dumpsys windowshowing the app genuinely focused and rendering, and vialogcat-based verification proceeding normally throughout) - abandoned screenshot-based verification for the remainder of this session's testing in favor oflogcat-only verification, which remained reliable throughout. - Outcome/Next: The user's original request ("после нажатия на последнюю галочку мы должны получить всю информацию для нашего лобби") is now fulfilled for the upgrades half: pressing the real confirm checkmark on the loadout screen reliably returns to the map (crash-free, verified against a real, non-trivially-auto-cascading event) and dispatches the real accepted-upgrades data to Kotlin via
GameEvents.onUpgradesAccepted. Remaining, explicitly not started: (1) car identification (make/model) -CAR_MAKE_FORD/CAR_MODEL_FOCUS_RS500(cont.38) are localization keys, not the underlying car-id field; that field's real location is still unknown. (2) Car color - completely unexplored, no RE done yet. (3) The lobby overlay itself (item (2) from cont.37, still deferred - no consumer foronUpgradesAcceptedexists yet). (4) The two open design questions from cont.37 (car_select without event_detail; direct race launch from map) remain untested. (5) Minor cleanup:g_confirmCarSelectionLogBudget's diagnostic Log calls could be trimmed now that the hook is confirmed unused, though leaving them costs nothing since the hook never installs.
2026-08-12 (cont. 44) — Confirmed car_select can be opened at an arbitrary moment (not just at boot), reusing the same proven FireEventOutput primitive, triggered externally via a debug broadcast standing in for a future lobby-overlay button
- What: User asked a design question after seeing cont.43's demo: does any of this help open car_select whenever the player wants (e.g. a "select car" button in a future lobby overlay), not just automatically at map load? Ruled out the "ГАРАЖ" (garage) nav-bar button per the user's own knowledge - it's just the owned-cars list, tapping a car from there also drops into
car_select, nothing new to investigate there. User asked to try "variant 1" first (call the already-proven event-opening primitive on demand instead of at boot) and fall back to variant 2 (further garage-button investigation) only if stuck, documenting throughout. - Implementation: added
TriggerOpenCarSelectOnDemand()inlan_event_injection.h- reuses the exact primitive already live-tested this session (FireEventOutput(g_mapScreenInstance, 0xC0FFEE00u), the same callHook_MapScreenTick's own boot-time auto-fire makes, and the same one a real map-pin tap ultimately resolves to viasub_17A99C) - just callable at any time instead of only once, automatically, ~2s after boot behind thekEnableAutoChainTest/tickCount>120gate. No new native risk: this is a call site this project has exercised repeatedly and safely all session. - Exposed to Kotlin:
Java_nfs_mod_mpcore_MultiplayerCore_triggerCarSelectTest(main.cpp) →MultiplayerCore.triggerCarSelectTest()(MultiplayerCore.kt). Since no real lobby-overlay button exists yet to call this from, wired a debug-only trigger instead:GameActivityMain.ktregisters aBroadcastReceiverfor a custom action (nfs.mod.mpcore.TEST_OPEN_CARSELECT,RECEIVER_EXPORTEDviaContextCompat.registerReceiverfor Android 13+'s required-flag rule, unregistered inonDestroy), letting the whole path be exercised fromadb shell am broadcast -a nfs.mod.mpcore.TEST_OPEN_CARSELECT -p com.ea.games.nfs13_mod- i.e. from outside the app, mirroring how a real UI button's click handler would eventually call into this same Kotlin function. - Live result: success. Let the existing auto-chain test harness (
kEnableAutoChainTest=true) run its own full cycle first and land back onMapOverworld(~26s after boot), then waited an additional ~43 seconds of idle time on the map before sending the broadcast - specifically to demonstrate "arbitrary moment," not just "immediately after the last thing that happened to construct a screen." The broadcast reached the app,TriggerOpenCarSelectOnDemand: firing FireEventOutput(MapScreen=0xb5912c00, key=0xC0FFEE00) on demandfired, andEventDetailsconstructed cleanly a moment later - zero crashes, app confirmed alive (pidof) and crash-log-clean afterward. - Snapshot:
lan_event_injection.h- addedTriggerOpenCarSelectOnDemand().main.cpp- added the JNI export.MultiplayerCore.kt- addedexternal fun triggerCarSelectTest().GameActivityMain.kt- addedcarSelectTestReceiver(aBroadcastReceiverfield), registered inonCreateright afterloadCore()viaContextCompat.registerReceiver(..., RECEIVER_EXPORTED), unregistered inonDestroy; added theIntentFilterimport. - Verification: Live on-device (Galaxy A9), one full run - auto-chain completing normally, then a genuinely-idle 43-second gap, then the external broadcast trigger, then confirmed
EventDetailsopened and the app stayed alive. - Known gaps, not addressed here (same one-shot-cycle limitation already flagged in cont.39/40/44's own code comments): (1) firing this a second time in the same app session would re-run the whole event_detail→car_select→loadout flow, but the downstream one-shot guards (
g_upgradesPersisted/g_realUpgradesPersisted) would silently no-op the persistence dispatch on that second pass - fine for "can it open on demand at all" (this test), not yet fine for "can the player reopen car_select repeatedly from the lobby," which is the more realistic real-world use case and hasn't been addressed. (2)EventDetailsstill visibly appears for a moment before car_select - no attempt was made to skip through it programmatically (e.g. auto-firing its own "EVENT" checkmark-equivalent immediately after it constructs, the same technique cont.35 already proved works) since the user's question was specifically about whether on-demand opening works at all, not about hiding the intermediate screen; skipping it is a separate, likely-easy follow-up using tools already in hand. - Outcome/Next: Variant 1 fully answers the user's question - car_select CAN be opened at any moment via the lobby (once a real button exists to call
MultiplayerCore.triggerCarSelectTest()), with no new native risk since it's the same primitive already proven safe. Variant 2 (further garage-button investigation) is now moot per the user's own confirmation. Next steps, in rough priority order: (a) make the flow genuinely repeatable per-session (reset the one-shot guards, or redesign them as per-open-cycle state) so the player can open/close car_select from the lobby more than once; (b) decide whether to auto-skipEventDetails's brief visible flash; (c) the still-open car-identification/color/lobby-overlay-consumer items from cont.42/43 remain unstarted.
2026-08-12 (cont. 45/46) — Live demo requested: auto-open car_select ~2s after boot; user caught that it only reached EventDetails, not car_select - fixed by auto-skipping EventDetails with a new minimal one-hop deferred-fire
- What: User wanted to literally watch this work on-device: open car_select ~2 seconds after the map first loads on a fresh launch (not via the debug broadcast from cont.44 - fully automatic, so they could just watch the phone screen).
- cont.45 - boot-time trigger: added
kAutoOpenCarSelectAtBoot/g_firedAutoOpenCarSelecttoHook_MapScreenTick, callingTriggerOpenCarSelectOnDemand()(cont.44's primitive) once attickCount > 120(~2s) - deliberately not reusingkEnableAutoChainTest's block (temporarily set tofalsefor this demo) since that also auto-continues through the entire scriptedkOutputChain(CONTINUE, BACK×3) afterward, which would auto-navigate past whatever screen the user wanted to actually look at. - First live run: worked exactly as coded -
MapOverworld→ (2s) →EventDetails. User caught, watching the live device screen, that this landed onEventDetails(the event's own name/stakes screen), not car_select - correct catch:TriggerOpenCarSelectOnDemandonly ever fires the sameFireEventOutputa real map-pin tap fires (cont.35), which stops atEventDetailsby design; reaching car_select needs a second hop (EventDetails's own "EVENT" output, cont.41). - cont.46 - auto-skip fix: added a new, minimal, independent single-hop deferred-fire mechanism (
g_autoSkipEventDetailPending/g_autoSkipEventDetailTarget/g_autoSkipEventDetailTargetPending, plus the matching capture block inHook_LayoutScreenCtorand fire block inHook_InternStringDiag) - same proven>=1500mswall-clock-deferred pattern used everywhere else in this file (firing synchronously inside a ctor callback silently no-ops, per cont.35's original discovery), but deliberately not reusingkOutputChain/g_firedEventTest, so it stops exactly at car_select instead of continuing on into loadout and back out like the scripted test harness would.TriggerOpenCarSelectOnDemandnow armsg_autoSkipEventDetailPending = trueright before firing its ownFireEventOutputcall. - Second live run: success.
MapOverworld→ (2s) →TriggerOpenCarSelectOnDemandfires →EventDetailsconstructs → (1.5s) →AUTO-SKIP EventDetails: about to FireOutput(EVENT) ... (deferred, opening car_select)→RestrictedGarage(car_select) constructs. Zero crashes, app confirmed alive (pidof) afterward. User watched the full sequence live on-device. - Snapshot:
lan_event_injection.h-kEnableAutoChainTesttemporarilyfalse(wastrue, needs reverting once this demo phase is over); addedkAutoOpenCarSelectAtBoot/g_firedAutoOpenCarSelectand the newg_autoSkipEventDetail*state block near the top of the file (both need to be visible toHook_InternStringDiag, defined early); added the boot-trigger call inHook_MapScreenTick; added the capture block inHook_LayoutScreenCtor; added the deferred-fire block inHook_InternStringDiag;TriggerOpenCarSelectOnDemandnow arms the auto-skip before firing. - Verification: Live on-device (Galaxy A9), two full boot-to-car_select runs (first one deliberately showing the EventDetails-only result the user correctly flagged, second one showing the fixed full skip-through), both crash-free, cross-checked against
logcat(screenshot capture remained unreliable per cont.43's note, not revisited). - Outcome/Next:
kAutoOpenCarSelectAtBoot+theEventDetailsauto-skip are demo/debug scaffolding (analogous to cont.44's debug broadcast) - not yet cleaned up for production:kEnableAutoChainTestneeds reverting totrue(or a real decision made about whether it's still needed at all now that cont.44/46 provide a cleaner on-demand path), andkAutoOpenCarSelectAtBootshould probably becomefalseby default once this demo phase is done, with the underlying mechanisms (TriggerOpenCarSelectOnDemand+ theEventDetailsauto-skip) kept available for the eventual real lobby-overlay "select car" button to call. The auto-skip mechanism itself is now reusable infrastructure - the same pattern could later be reused/extended for other "open screen X, skip past intermediate screen Y" needs if they come up.
2026-08-12 (cont. 47/48) — MAJOR: found the real FlowNode-transition machinery via RTTI, and used it to achieve a genuine one-hop jump straight to car_select, bypassing EventDetails entirely - live-verified crash-free and fully functional afterward
- What: User asked "Можно ли в Car select прыгать сразу, без EventDetails?" (can we jump straight to car_select, without EventDetails?) - a materially harder version of cont.44/46's demo-timing work, revisiting the open question from cont.37/42 about whether car_select can be reached without going through the event/stakes screen. User explicitly chose to keep digging (twice, at two decision points) rather than settle for the safe "tune the auto-skip delay down" fallback, accepting the higher risk this entailed.
- cont.47 - finding the real mechanism: static archaeology of
sub_1581A0(FireOutput's real target, cont.41) showed it only marks a transition "pending" if the CURRENT FlowNode's own Outputs tree (a per-node red-black tree atnode+28, keyed by interned-string-pointer, membership-checked viasub_159684) actually contains the requested output name - explaining precisely why map can't just fire a custom output directly to car_select (its own tree, from real.sbdata, only has"EVENT"→event_detail). Chasing the actual "process the pending transition" consumer via xrefs and vtable/RTTI archaeology hit real walls (im::app::flow::FlowNode's vtable isn't a plain absolute-address data reference in this PIE library -find/data_refcouldn't locate it), and a live empirical field-watch (polling+44/+52/+56/+60/+64/+256/+260every frame after firing) answered the wrong question (turned outFireEventOutput's raw-key-based first hop bypasses this whole named-output mechanism entirely - it's a separate resolution path, only hops after the first one go through it). Found it properly by decompilingGetOutputNode's (sub_159700, RTTI-confirmed via its own"outputIt != m_Outputs.end()"assertion string and"GetOutputNode"function-name string) single caller:sub_1573EC- reads the pending output name (+56), resolves it viaGetOutputNode, swaps the FlowNode's own+28("current node") to the resolved target, performs the real transition (sub_1C4318/sub_1C4420), then clears the pending state. This isFlowNode's own per-frame transition processor. - The single biggest unlock: hooking
sub_1573ECobservationally (deliberately not substituting anything yet, given how tightly its refcounting is coupled to the resolved values) revealed that*(screenOwner+8)(the "FlowNode" pointer, cont.41) is the exact same single shared object for every screen - confirmed live:MapScreen flowNode=0xd70a5bc0and theEventDetails-owning tick's owna1=0xd70a5bc0were identical in the same run (and reproduced with a different value,0xd70a6ac0, in a later run - consistent per-session, varies only across app launches). Only+28(which static graph-node data is "loaded") actually differs per screen - there's one persistent global FlowNode-executor, not one object per screen. - cont.48 - the real jump: since map's own tree genuinely lacks a car_select edge but event_detail's own tree (parsed from real
.sbdata, cont.26) genuinely has one, and since it's the same shared object either way, the safe move was to temporarily redirect which already-real, already-valid tree the shared FlowNode consults - not fabricate a target.Hook_FlowNodeTicknow captures event_detail's own+28value (g_capturedEventDetailsTreeRef) the first time it's about to process a real"EVENT"pending transition (i.e. exactly the value representing event_detail's genuine Outputs tree).TriggerTrueDirectCarSelectJump()then: gets the shared FlowNode via*(mapScreenOwner+8), temporarily overwrites its+28with the captured event_detail tree reference, and firesFireOutput(mapScreenOwner, "EVENT", ctx)-sub_1581A0's own membership check now succeeds naturally (against the substituted-but-genuine tree), andsub_1573EC's own unmodified resolution/refcounting/construction logic runs end-to-end on entirely real data. Never touches or fabricates the resolved target itself - only redirects which real source tree gets consulted. - First live attempt crashed - but cleanly, in our own unstripped code:
TriggerTrueDirectCarSelectJump+104, fault addr0x1c(=28 decimal) -flowNode+28dereferenced withflowNodeitself NULL. Root cause:g_mapScreenInstance(only ever written byHook_MapScreenTick, never invalidated) had gone stale - fired ~90s after the map had last been the active screen, well after the auto-cascade had moved on through several more screens. Fixed with a straightforward null guard (log + bail instead of dereferencing). A closer look at the timing of a second failed attempt (fired only ~200ms after capturing the tree ref, still loggedflowNode is null) revealed the real nature of the staleness: it's not simply "how much time has passed" -*(screenOwner+8)reads back NULL specifically while that screen is mid-transition-out (the field gets cleared as part of hand-off, seemingly before the object itself is destroyed) - i.e. this only works reliably while MapScreen is the genuinely, currently active screen, which is exactly the real-world condition a lobby-overlay button would satisfy anyway (the player has to be looking at the map to tap it). - Second live attempt: full, clean success. Returned to
MapOverworldfor real (system back button navigation:RestrictedGarage → EventDetails → MapOverworld, confirming the game's own real "BACK" outputs are unaffected by any of this), then fired the broadcast while map was genuinely active:TriggerTrueDirectCarSelectJump: flowNode=0xd70a6ac0, temporarily swapping +28 from 0xbb4c7be0 (map's own) to 0xbb8ac760 (captured EventDetails tree)→FireOutput returned without crashing→DIAG LayoutScreenCtor: screenName="RestrictedGarage"- EventDetails never constructed at all. True one-hop jump,MapOverworldstraight to car_select. Zero crashes, app confirmed alive (pidof) afterward. - Verified car_select is fully, genuinely functional afterward, not just superficially reached: tapped its real confirm checkmark - fired the REAL
"CONTINUE"(caller0xb9e28958, the same genericsub_1A7920handler identified in cont.41) and landed cleanly onRestrictedGarageLoadout(the loadout screen), exactly as it does when reached the normal way. This directly answers cont.37's original open risk concern ("car_select'sRestricted=true/GarageType=preraceproperties suggest it expects context normally set up by event_detail's own confirm handler") - empirically, no such missing context caused any problem, at least through this much of the flow (loadout screen construction and normal checkmark interaction). - Snapshot:
lan_event_injection.h- addedg_capturedEventDetailsTreeRef(state block near the top);Hook_FlowNodeTick/InstallFlowNodeTickHook(FLOWNODE_TICK_OFFSET 0x1573EC, observational + the one-time capture);TriggerTrueDirectCarSelectJump()(with the null-guard fix).main.cpp- addedInstallFlowNodeTickHook()and the new JNI export.MultiplayerCore.kt- addedexternal fun triggerTrueDirectCarSelectJump().GameActivityMain.kt- addedtrueDirectCarSelectTestReceiver(debug broadcastnfs.mod.mpcore.TEST_TRUE_DIRECT_CARSELECT), registered/unregistered alongside the existing cont.44 receiver. - Verification: Live on-device (Galaxy A9), multiple rounds - RTTI/xref archaeology (read-only, IDA-side), an observational live hook confirming the shared-FlowNode discovery (crash-free), one crashing live attempt at the real jump (cleanly diagnosed, own-symbol crash), then a corrected, fully successful live attempt confirmed via logs both for the jump itself and for car_select's continued normal functionality afterward.
- Outcome/Next: This is a real, working "true skip" - materially different from cont.45/46's fast-timing-based flash-minimization, and directly answers the user's question with a live yes. Not yet production-ready: (1)
g_mapScreenInstancestaleness is a real, general risk for any of these Trigger* functions (not new to this one -TriggerOpenCarSelectOnDemandhas the identical unchecked dereference and should get the same null-guard treatment); (2)g_capturedEventDetailsTreeRefis captured once per session from a real event_detail visit - a lobby button wired directly toTriggerTrueDirectCarSelectJumpwith no prior event_detail visit at all this session would currently just log-and-ignore (needs either a one-time hidden warm-up pass, or a different capture strategy); (3) only tested reaching car_select and one checkmark-tap-deep into loadout - the fuller downstream flow (mod selection, the cont.42/43 confirm-interception/persistence machinery, actual race-avoidance) hasn't been re-verified specifically against a true-jump-reached car_select, though there's no structural reason to expect it to behave differently now that car_select's own construction has been shown to be identical either way.
2026-08-12 (cont. 49) — Post-demo cleanup: reverted both boot-time demo flags to their production defaults
- What: User asked to put
kEnableAutoChainTestandkAutoOpenCarSelectAtBootback into production state, now that the cont.45-48 live demos (boot-time car_select opening, the EventDetails auto-skip, and the true direct-jump experiment) were done. - What "production state" means for each flag, since they serve different purposes:
kEnableAutoChainTest→true. This is the original cont.35-41 timer-driven test harness (fires the synthetic "LAN: Test Lobby" event's ownEVENT→CONTINUE→BACK×3 cycle automatically, ~2s after boot, entirely independent of the cont.44+ on-demand mechanisms).trueis its long-standing default throughout the whole session - it was only ever flipped tofalsetemporarily during cont.42/43/45/46's live testing so it wouldn't race against whatever was being manually driven at the time.kAutoOpenCarSelectAtBoot→false. This flag did not exist before cont.45 - it was added specifically as demo scaffolding for the user's "давай сейчас откроем car_select через 2 секунды" request, to auto-fireTriggerOpenCarSelectOnDemand()at boot so the user could watch it live without needing to send an adb broadcast.falseis its correct resting state now that the demo has been watched and confirmed working - the real production trigger going forward is a lobby-overlay button callingTriggerOpenCarSelectOnDemand()(cont.44) orTriggerTrueDirectCarSelectJump()(cont.48) via JNI, not a boot-timer.
- Verification: rebuilt (
:app:assembleDebug), reinstalled on-device (Galaxy A9), confirmed the app launches and reachespidof-visible normal running state with no changes to its startup behavior beyond what these two flags already controlled. - Snapshot:
lan_event_injection.honly -kEnableAutoChainTest = true;(comment noting it's back to its long-standing default, TEMP marker removed),kAutoOpenCarSelectAtBoot = false;(comment reframed from "flip to true for the live demo" to "flip to true only for another live boot-time demo" - the resting/shipped value isfalse). No other files touched - the underlying mechanisms themselves (TriggerOpenCarSelectOnDemand,TriggerTrueDirectCarSelectJump, theEventDetailsauto-skip, both debug broadcast receiversTEST_OPEN_CARSELECT/TEST_TRUE_DIRECT_CARSELECT) are untouched and remain fully available - only the automatic-at-boot triggering of the demo path was disabled. - Outcome/Next: Native module is back to a clean, coherent baseline: the original test harness runs automatically as it always has (
kEnableAutoChainTest), the cont.44-48 on-demand car_select machinery is present but dormant until called (either via JNI from a real UI trigger, or via the two debug broadcasts for ad-hoc testing), and no boot-time behavior changed from before this whole cont.45-49 side-quest started except by explicit, already-reverted opt-in flags. Still open, unchanged from cont.48:TriggerOpenCarSelectOnDemand's own unguardedg_mapScreenInstancedereference should get the same null-guard treatmentTriggerTrueDirectCarSelectJumpalready has;g_capturedEventDetailsTreeRef's cold-start (no prior event_detail visit this session) case is still unhandled; the fuller downstream flow through a true-jump-reached car_select (mod selection, confirm-interception/persistence) hasn't been re-verified end-to-end.
2026-08-12 (cont. 50) — Closed cont.49's own follow-up: added the same null guard to TriggerOpenCarSelectOnDemand; verification was blocked for a while by a red-herring "device hang" that turned out to just be the screen asleep
- What: User asked to add the same null guard to
TriggerOpenCarSelectOnDemandthatTriggerTrueDirectCarSelectJumpalready got after cont.48's live crash - closing the exact gap cont.48/49's own "Outcome/Next" notes had flagged as still open. - Implementation: extracted
void* flowNode = *(void**)((uint8_t*)g_mapScreenInstance + 8);as an explicit local (previously computed inline and stored straight intog_flowNodeWatchPtrwithout a null check), added the sameif (!flowNode) { Log(...); return; }early-return pattern asTriggerTrueDirectCarSelectJump, and reused the now-guaranteed-non-nullflowNodelocal everywhere the function previously re-read*(g_mapScreenInstance+8). Mechanical, no behavior change on the success path. - Verification saga: repeated attempts to trigger the negative path (fire the broadcast before the map is genuinely active) worked immediately and consistently -
TriggerOpenCarSelectOnDemand: flowNode is null (g_mapScreenInstance likely stale/destroyed) - ignoring, no crash, confirmed multiple times across multiple launches. Getting the positive path (map genuinely active, guard passes, real transition happens) took much longer than expected: several consecutive app launches appeared to hang indefinitely with zeroDIAG LayoutScreenCtoroutput even after 60-90+ seconds and even surviving a full user-initiated device reboot -free -hshowed the device genuinely low on memory (217MB-527MB free of 5.5G) partway through, so a batch of cached-empty background processes (Samsung/Google bloat services already in Android's own lowest-prioritycch-emptystate, i.e. already marked safe-to-kill) were cleared viaam killto rule out memory pressure as the cause. Root cause of the apparent hang turned out to be unrelated to any of that: the user clarified the phone's screen had simply gone to sleep - Android suspends an app's rendering/update loop while the display is off, which looks indistinguishable from a genuine native-code hang purely fromadb logcat(no error, no ANR, process stays resident, just zero forward progress) unless you know to check display/wake state specifically. The moment the device woke up, the ENTIRE previously-"stuck" boot sequence (SplashScreen through a fullkEnableAutoChainTestcycle back toMapOverworld) completed in about 12 seconds, exactly matching this session's normal timing throughout. - Positive-path result, once actually tested: fired the broadcast with the map genuinely on-screen and awake -
TriggerOpenCarSelectOnDemand: firing FireEventOutput(MapScreen=..., key=0xC0FFEE00) on demand→DIAG MapScreen flowNode=0xdb2cc080(non-null, guard passed transparently) →FireEventOutput returned without crashing→EventDetailsconstructed → (auto-skip's proven 1.5s deferred fire) →RestrictedGarage(car_select) constructed. Zero crashes, app confirmed alive (pidof) throughout, identical behavior to every pre-guard run of this same path earlier in the session - confirming the guard adds zero regression risk on the success path, exactly as expected from a pure early-return addition. - Snapshot:
lan_event_injection.honly -TriggerOpenCarSelectOnDemandgained theflowNodelocal + null guard, mirroringTriggerTrueDirectCarSelectJump's existing cont.48 fix. No other files touched. - Verification: Live on-device (Galaxy A9) - negative path (guard fires, no crash) confirmed 2+ times across separate launches; positive path (guard passes, full transition succeeds) confirmed once, cleanly, immediately after the device-sleep red herring was identified and the device was genuinely awake and idle on the map.
- Process note for future sessions: when a launched app appears to make zero logcat progress for an extended period with no error/ANR/crash and the process stays resident, check the device's actual screen/wake state (
adb shell dumpsys power | grep mWakefulnesswould have caught this immediately) before assuming a code-level hang or resource exhaustion - this cost significant back-and-forth (memory cleanup, a full reboot) that a wake-state check up front would have skipped entirely. - Outcome/Next: Both
Trigger*on-demand functions (TriggerOpenCarSelectOnDemand,TriggerTrueDirectCarSelectJump) now consistently guard against a stale/inactiveg_mapScreenInstancebefore dereferencing its FlowNode, closing that specific gap for good. Remaining open items are unchanged from cont.48/49:g_capturedEventDetailsTreeRef's cold-start case (no prior event_detail visit this session) is still unhandled byTriggerTrueDirectCarSelectJump, and the fuller downstream flow through a true-jump-reached car_select hasn't been re-verified end-to-end.
2026-08-12 (cont. 51) — MAJOR BUG FOUND AND FIXED: kEnableAutoChainTest's test harness was hijacking real player navigation, not just our own synthetic event - defaulted it to false
- What: User, playing normally with production config (
kEnableAutoChainTest=true, restored in cont.49), reported a bizarre flow they couldn't explain: open a real street (МАККЛЕЙН) → open a real event (ПОБУДКА) →event_detail→ tap the real confirm checkmark →car_selectopens → under a second later, the confirm happens by itself → the mod/loadout screen opens → then, abruptly, BACK intocar_select→ then abruptly BACK again intoevent_detail- all without the user touching anything after their own checkmark tap. - First hypothesis (car ownership) was wrong and said so directly: initially (mis)diagnosed this as the cont.42/43 "single-car auto-continue" engine behavior and spent significant effort trying to find a real event with 2+ owned cars to compare against (checked the garage - player owns exactly 3 of 41 cars; checked several real events - none currently accessible have 2+ owned-car choices, all show either 0 or 1 real option). This entire investigation turned out to be a red herring - car ownership/choice was never the actual cause.
- Real root cause, found by re-reading the user's exact step-by-step sequence: it precisely matches
kOutputChain's own scripted"EVENT"→"CONTINUE"→"BACK"→"BACK"→"BACK"cycle (cont.35-41's original test harness, meant to drive only our own synthetic "LAN: Test Lobby" event). Two facts combine into the bug: (1)Hook_LayoutScreenCtor's deferred-chain capture logic (if (g_firedEventTest && !g_chainTargetPending && g_chainIndex < kOutputChainLength) { g_chainTarget = a1; ... }) grabs whicheverLayoutScreenconstructs next, with no check that it actually belongs to our own synthetic event's flow - it was written when only our own scriptedFireOutputcalls were the source of new screens, and never updated once real, independent player navigation entered the picture (cont.44 onward). (2) cont.47's own discovery that*(screenOwner+8)is a single shared FlowNode-executor object for every screen, not one per screen - so firing a queued output against "the FlowNode" affects whatever screen is genuinely active, regardless of which screen's flow originally armed the fire. Combined: the harness's own boot-timer fires its "EVENT" hop in the background (~2s after boot) while the user is still reading event_detail; by the time the user's own checkmark tap constructscar_select, the harness's capture logic - still watching for "whatever constructs next" - grabs the user's own, realcar_selectas its target; ~1.5s later its deferred "CONTINUE" fires against it (the "confirm happens by itself" the user saw), advancing into the real loadout screen; the harness's own two remaining"BACK"hops then fire against whatever's next, walking the user backward through their own realcar_selectandevent_detail- exactly the sequence reported. - Fix: changed
kEnableAutoChainTest's default fromtrueback tofalse. Confirmed this doesn't affect the two things that actually matter: real in-game navigation (game's own native Flow code, entirely independent of this flag) and the on-demand triggersTriggerOpenCarSelectOnDemand/TriggerTrueDirectCarSelectJump(cont.44/48, gated by neither this flag nor anything the harness touches). The synthetic event/map pin still gets injected either way (kInjectSyntheticEvent, a separate always-on flag) - only the automatic, timer-driven navigation of it is now off by default. - Live re-verification, real event, same exact flow the user reported: map → МАККЛЕЙН → ПОБУДКА →
event_detail(real tap) →car_select(real tap) →RestrictedGarageLoadout(real tap) → waited 13+ seconds with zero further input: no auto-confirm, no auto-back, screen sat completely stable exactly as it should, app confirmed alive throughout. Full, clean fix. - Side finding, worth flagging even though it wasn't the bug: this same real-event test also did not reproduce cont.42/43's "auto-continue" behavior at all (the loadout screen just sat there normally, no unprompted
CONTINUE) - suggesting that earlier finding may have been specific to our own syntheticRaceEvent's minimal/incomplete data confusing whatever "is this session fully configured" checksub_16C660/sub_1EF580()performs (a real, correctly-populatedRaceEventmight simply never satisfy that condition prematurely the way our stripped-down synthetic one did), rather than a general "only one available car" engine behavior as previously assumed. Not confirmed with further RE - noted here as a correction to earlier framing, not a new investigation. - Snapshot:
lan_event_injection.honly -kEnableAutoChainTestflipped fromtruetofalse, with a substantially rewritten comment explaining the hijack mechanism and why the new default is correct. - Verification: Live on-device (Galaxy A9) - the exact bug reproduction path (real taps, matching the user's own reported sequence) re-run after the fix, confirmed stable with no auto-navigation for 13+ seconds on the loadout screen.
- Outcome/Next: This was a real, previously-undetected interaction bug between this project's own test scaffolding and genuine gameplay - worth remembering for any future diagnostic/test-harness code added to this project: anything that captures "whichever screen appears next" or fires outputs against a shared/global engine object needs an explicit ownership/identity check, not just a timing assumption, once real player input can also be driving the same systems.
kEnableAutoChainTest=trueremains available for a deliberate, isolated re-run of the original cont.35-41 test/demo cycle, but must not be left on during any session where real gameplay might also be happening.
2026-08-12 (cont. 52) — Made the loadout-confirm interception conditional on event provenance (synthetic vs. real) - real events now proceed into actual race loading, unblocked
- What: After playing through cont.51's fix normally, user asked to actually enter a real race and predicted it would fail. It did -
Hook_FireOutputDiag's cont.42/43 loadout-confirm interception (redirectingRestrictedGarageLoadout's "CONTINUE" to "BACK", originally written to stop our own synthetic event from crashing on a missing track scene, cont.36) was unconditional, so it also silently blocked every real event's confirm from ever reaching actual race loading. User's fix instruction, plus an explicit standing principle for all future work: "очень плохо перехватывать все / Нужно иметь ввиду что событяи могут быть как синтетические так и нормальноигровые" - never intercept indiscriminately; any hook must account for both synthetic (our own LAN lobby) and real/normal-game events. - Mechanism added - event-provenance tracking, using only existing hooks (no new ARM hooks needed): two new flags,
g_nextEventDetailsIsSynthetic(armed) andg_currentEventIsSynthetic(latched/consumed).TriggerOpenCarSelectOnDemand()and the legacykOutputChainharness's ownFireEventOutputcall (insideHook_MapScreenTick) each setg_nextEventDetailsIsSynthetic = trueimmediately before firing - both are always firing our own synthetic event (key0xC0FFEE00).Hook_LayoutScreenCtorlatchesg_currentEventIsSynthetic = g_nextEventDetailsIsSynthetic(then resets the arm-flag) wheneverEventDetailsconstructs - the one common point every event's flow (real or synthetic) passes through. A real player's own map-pin tap never touches the arm-flag, so this correctly defaults tofalse(real) for genuine navigation.TriggerTrueDirectCarSelectJump()(cont.48) bypassesEventDetails' construction entirely by design, so it latchesg_currentEventIsSynthetic = truedirectly instead of going through the arm/consume path.Hook_LayoutScreenCtoralso resetsg_currentEventIsSynthetic = falsewheneverMapOverworldconstructs - a natural session boundary, so the flag can never leak into a later, unrelated real event visit.
- The actual gate: added
&& g_currentEventIsSyntheticto both ofHook_FireOutputDiag's existing interception conditions (isRealLoadoutContinue,isRepeatedAutoCascadeContinue) - the redirect-to-BACK logic itself is unchanged, it now simply only fires when the currently-active event is confirmed to be our own synthetic one. - Verification, both scenarios, live on-device (Galaxy A9):
- Real event (МАККЛЕЙН → ПОБУДКА, all real taps) →
event_detail→car_select→RestrictedGarageLoadout→ tapped the real confirm checkmark: logcat showed a clean, uninterruptedDIAG FireOutput: ... output="CONTINUE"→DIAG LayoutScreenCtor: ... screenName="PreRaceLoadingScreen"→BuildTrackScenePath hook fired: track -> region4_chicago_track4- no "REAL CONFIRM: intercepted..." log line at all. Screenshot confirmed an actual race in progress (Focus RS500 racing against AI opponents on a real track). Exited cleanly via the in-race pause menu back to the map. - Synthetic event (
nfs.mod.mpcore.TEST_OPEN_CARSELECTdebug broadcast, same primitive as cont.44) → auto-skip throughEventDetails→car_select→ confirmed car →RestrictedGarageLoadout→ tapped confirm: logcat showed the expectedREAL CONFIRM: intercepted CONTINUE from RestrictedGarageLoadout ... substituting BACKfollowed by the full cascade (REAL EXIT CHAIN: caught a repeated auto-continue ...×44) andREAL EXIT CHAIN: reached MapOverworld after 44 BACK attempt(s)- i.e. still correctly redirected back to the map, exactly as before this change, with zero regression.
- App process (
pidof) confirmed alive throughout both runs; no crash in either path.
- Real event (МАККЛЕЙН → ПОБУДКА, all real taps) →
- Snapshot:
lan_event_injection.honly. New state block (g_nextEventDetailsIsSynthetic,g_currentEventIsSynthetic) near the top; arm calls inTriggerOpenCarSelectOnDemand()and the legacy harness'sHook_MapScreenTickfire site; direct latch inTriggerTrueDirectCarSelectJump(); latch/reset logic added toHook_LayoutScreenCtor;g_currentEventIsSyntheticgate added to both conditions inHook_FireOutputDiag. No other files touched. Rebuilt (:app:assembleDebug), installed, live-tested - no code changes pending. - Standing principle for future work in this project (the user's explicit general instruction, not just a one-off fix): any interception/hooking logic added going forward must explicitly distinguish synthetic (our own test/LAN-lobby) state from real/normal-game state before acting - never intercept indiscriminately just because a screen name or output name matches. This mirrors cont.51's lesson (identity/ownership checks, not just timing assumptions) but generalized as a standing rule, not just a retrospective fix for one bug.
- Outcome/Next: The loadout-confirm interception is now correctly scoped - real players can finally complete real races through this flow, while our own synthetic LAN lobby event remains crash-free (still redirected to map, since it still has no real track scene to load, cont.36). No known open gaps in this specific mechanism. Broader remaining items are unchanged from cont.48/49:
g_capturedEventDetailsTreeRef's cold-start case forTriggerTrueDirectCarSelectJump, and full re-verification of mod selection/persistence specifically through a true-jump-reached car_select.
2026-08-12 (cont. 53) — Broader stability pass: confirmed the mod-menu and upgrade-purchase screens (not just the loadout confirm itself) are unaffected by cont.52's change, on both real and synthetic events
- What: User asked to verify that the other screens reachable from loadout - the mod-selection menu and the upgrade-purchase flow - are also stable after cont.52's conditional-interception fix, not just the loadout confirm button itself. No code changes in this entry, pure live-verification.
- Real event, second one this session (МАККЛЕЙН → "ТЬМА СГУЩАЕТСЯ", a MASLKAR-class event, since ПОБУДКА was already 100% complete from cont.52's own test): event_detail → car_select (single-car class, confirmed straight through) → loadout → confirmed → genuinely entered and drove the race (opponent traffic, police cars visible, in-race pause menu worked, exited cleanly back to map via "ВЫЙТИ В МЕНЮ"). No interception fired, as expected.
- Garage → car detail → paint screen (
ГАРАЖbottom-nav tab → owned Ford Focus RS500 → paint/diamond icon): openedGaragePaintcleanly (confirmed viaDIAG LayoutScreenCtor: ... screenName="GaragePaint"), back button returned to the car detail screen without issue. Establishes this garage-browsing path (outside any race/event flow, never gated by any of this project's hooks) was never at risk, but good to confirm explicitly. - Synthetic event's actual upgrade-purchase screen (the specific thing not yet exercised by cont.52's test, which had only tapped straight through to the loadout confirm without opening anything): via
TriggerOpenCarSelectOnDemand→ car_select → confirmed →RestrictedGarageLoadout→ this time tapped an empty upgrade slot (ПУСТО (УЛУЧШ.)) instead of the confirm button - openedВЫБОР УЛУЧШЕНИЯ(upgrade-selection screen) cleanly, listing real purchasable options (ВОССТ. ШИНЫ1000$,ЗАЩ. ОТ СТОЛКН.1000$,УСИЛЕННОЕ ШАССИ1500$, etc.). SelectedВОССТ. ШИНЫ- purchase applied correctly (loadout total updated 0$→1000$,ModSlot: slot 0 recorded -> CarMod=0x1logged), and the car's dollar balance (a separate currency from the SP shown on the map) was correctly debited 26000$→25000$ and stayed debited across screen transitions. - Loadout confirm after a real purchase, both trigger paths: (1) manually tapping confirm with the tire-repair upgrade active still correctly hit
REAL CONFIRM: intercepted CONTINUE ... substituting BACKand cascaded cleanly back toMapOverworld; (2) on a second synthetic-event cycle, the loadout's own auto-continue (cont.42/43'ssub_16C660, which considers a session with an upgrade selected "fully configured") firedCONTINUEon its own without any tap - this too was caught correctly by the same interception and redirected back to the map (REAL EXIT CHAIN: reached MapOverworld after 111 BACK attempt(s)). Confirms the cont.52 gate holds regardless of whether the CONTINUE originates from a manual tap or the engine's own auto-fire. - Crash check across the whole session's logcat: grepped for
FATAL|AndroidRuntime|SIGSEGV|crash- found only the pre-existing, already-handledF fatal: Couldn't find MedalPosition component/Dereferencing a NULL component pointerlines (the game engine's own internal log level for conditions it recovers from, immediately followed by this project's own... FireOutput/Strlen ... returned without crashingconfirmations) - these are the same benign messages documented back in cont.36/42/43 for the synthetic event's intentionally-minimal data, not new crashes.pidofconfirmed the app process stayed alive through every step of this entry. - Snapshot: No files changed - this entry is verification-only, following up on cont.52's fix.
- Outcome/Next: Every screen reachable from the loadout flow that was actually exercised this session (real race entry/pause-menu/exit, garage paint browsing, synthetic-event upgrade purchase + persistence, both manual and auto-fired confirm interception) is confirmed stable with no regressions from cont.52. Not covered in this pass: the mod-selection (performance parts, as opposed to the one-off consumable upgrades tested here) screen specifically, and a real event's own upgrade-purchase flow (only the synthetic event's was exercised) - worth a quick follow-up check if either becomes relevant, but nothing currently suggests either would behave differently given the interception gate is keyed on event provenance, not on which sub-screen is open.
2026-08-13 (cont. 54) — Chased an unexplained self-navigating "BACK" cascade on a REAL event down to real engine behavior (not our code), via a new observational hook - then set the investigation aside per the user's own call
- What: User asked why buttons were "pressing themselves" again after a manual device reboot - this time on a genuinely real event (КРЮГЕР АВЕНЮ → B2 "ЖАРКИЙ ЗАЕЗД"), not our synthetic one. Clarified this was the SAME bug shape as cont.51 (auto-navigation walking backward through EventDetails/car_select/loadout) but this time reproducibly tied to ~34s of idle time sitting on the loadout screen.
- Ruled out our own code first, from logs alone: the exact real-event sequence (event_detail → car_select → loadout → 34s idle → three consecutive "BACK" fires walking back to MapOverworld) showed no
REAL CONFIRM: intercepted CONTINUE...log line anywhere, and no "CONTINUE" preceded the BACKs at all - ruling outg_realExitChainActive/Hook_FireOutputDiag's interception (cont.42/43/52), which only ever substitutes BACK for an actual CONTINUE it catches.kEnableAutoChainTestconfirmed stillfalsein source. This is real engine behavior, not our mod. - New diagnostic hook added (
Hook_GenericFireOutputWrapperDiag,GENERIC_FIRE_OUTPUT_WRAPPER_OFFSET 0x1A7920): found via IDA thatHook_FireOutputDiag's existing__builtin_return_address(0)(hooked atFIRE_OUTPUT_REAL_OFFSET 0x1581A0) always reports the SAME address regardless of true caller, becausesub_1A7920- a single generic "fire this named output" wrapper used by real taps AND internal engine logic alike - tail-calls down intoFIRE_OUTPUT_OFFSET(0x1BB59C) without pushing its own LR, so the real caller's distinguishing return address gets lost by the time it reaches 0x1581A0. Hookingsub_1A7920's own entry instead - one level higher - recovers the genuine caller. Installed as a pure observational hook (budget-limited, logs only when the fired output name is exactly "BACK"). - Reproduction attempt: rebuilt, installed, and set up to watch live while the user navigated to the exact real repro path and waited on the loadout screen - but the timeout did not reproduce this time (user checked ~10 times over several minutes). Given the new hook was correctly installed (confirmed in boot logs) but never fired, and the earlier repro was tied to a specific pre-reboot session, this looks like real, pre-existing NFSMW engine behavior (plausibly some kind of session/event-specific inactivity handling, not necessarily present on every event or every session) rather than anything introduced by this project's own hooks.
- Outcome/Next: User made the call to drop this specific thread rather than keep chasing a non-reproducing timeout, and pivoted to the (unrelated, pre-existing) open question of jumping straight from the map to car_select.
Hook_GenericFireOutputWrapperDiagremains installed (harmless, budget-limited observational hook, may be useful again if this resurfaces) - not wired into any interception logic.
2026-08-13 (cont. 55) — Warmed up TriggerTrueDirectCarSelectJump's tree capture with zero live visit needed - but found via a real, reproducible crash that this alone is NOT sufficient for a truly cold session; root-caused the crash precisely via IDA
- What: Follow-up UX design discussion (unrelated to cont.54's dropped thread): user asked how a lobby "Select Car" button should work, given
TriggerOpenCarSelectOnDemandalways shows event_details first. AgreedTriggerTrueDirectCarSelectJump(cont.48) is the right primitive for this (no stakes-screen makes sense in a LAN lobby context), but flagged its own known gap:g_capturedEventDetailsTreeRefonly gets populated by observing a REAL live "EVENT" transition (Hook_FlowNodeTick), so a lobby button pressed "cold" (before the player has ever opened any real event this session) would silently no-op. User rejected a fallback-to-event_details as bad UX (still shows the screen once) and asked for a true zero-visit solution. - Found the mechanism via IDA:
GetOutputNode(sub_159700, cont.47/48) internally resolves an unresolved-by-name output edge viasub_156F08- a lazily-loads-and-caches-by-name resolver with its own hash table. This meant the exact resolutionsub_1573ECperforms on a real map→event_detail transition could be invoked directly, without any live screen construction, by callingGetOutputNodeourselves with map's own already-loaded node data (*(mapFlowNodeExecutor+28), available immediately onceMapOverworldconstructs) and the interned "EVENT" key. - Implemented (
lan_event_injection.h):GetOutputNode(GET_OUTPUT_NODE_OFFSET 0x159700) added as a plain function pointer (no hook needed, just a direct call - resolved inInstallMapTrackHandleEventHookalongsideInternString/FireOutput).Hook_LayoutScreenCtor'sMapOverworldbranch now does a one-shot (g_eventDetailsTreeWarmedUp) directGetOutputNodecall the moment the map constructs, populatingg_capturedEventDetailsTreeRefwith zero visible flash and no player action - confirmed working immediately on the very first map load, both on a normal boot and after a fresh relaunch (WARM-UP: captured EventDetails tree ref=... via direct GetOutputNode call). - The warm-up itself works, but the direct jump still crashes cold: with a genuinely fresh session (app just booted, warm-up fired, but no real event_detail construction has ever happened), firing
TriggerTrueDirectCarSelectJumponce crashed with a real SIGSEGV (fault addr 0x38,Cause: null pointer dereference, abort messageDereferencing a NULL component pointer., on the GLThread) - fully reproducible twice in a row on a clean boot. (A messier first test, where the trigger was fired twice without an app restart in between, additionally left the shared FlowNode's own+28permanently stuck on the substituted tree after the first failed/no-op attempt -TriggerTrueDirectCarSelectJumpdoesn't restore+28after firing, relying on the normal real-transition machinery to move it on, which doesn't happen when the transition doesn't actually complete. Not itself the root cause, but a real secondary hazard worth remembering: a failed direct-jump attempt can corrupt map's own navigation state for the rest of the session.) - Root-caused the crash precisely via IDA, using a
libapp_basevalue read directly from that exact crashed session's own boot log (InstallCopSoundsTickSkipHook: called, libapp_base=0x...) for byte-exact address translation (the crash report's ownpc 0006563cbacktrace-frame offset turned out to be relative to something other than a plain libapp_base delta - not directly usable; deriving the base independently from the crash's rawlr/pcregisters against this project's own already-validated__builtin_return_address(0)reference point resolved cleanly to real, sensible function code, confirming the derivation):- Crash PC resolved to
sub_2A65E4(a1, a2), a constructor-shaped function - the very first real dereference on its own second argument (a2[14], i.e.*(a2+56)=*(a2+0x38)) is exactly what faults whena2is null, matchingfault addr 0x38(56 decimal) exactly. - Its only caller,
sub_23F990, passes*(_DWORD**)(v5+12)as that argument (v5beingsub_23F990's own second parameter) - gated behind a "Dereferencing a NULL component pointer." soft warning that logs and continues anyway rather than aborting (the same benign-looking message pattern seen everywhere else in this codebase, cont.36/42/43 - except this time the code that follows the warning isn't actually null-safe, and it's a real crash). sub_23F990itself is called fromsub_170EC8(part of car_select/RestrictedGarage's own construction, per the live backtrace/register evidence -r0/r4/r8all held the exactRestrictedGaragescreen instance address logged moments earlier byHook_LayoutScreenCtor), which first resolvessub_171278(a1)and reads*(a1+320)(looks like a screen-mode/state enum) to decide which path to take;sub_171278in turn (for at least one mode value,2) builds something viasub_77B70(0)→sub_2550C8(...).
- Crash PC resolved to
- Interpretation: this is very unlikely to be anything specific to
event_detailsitself setting upcar_select-specific context (the original cont.37/48 worry) - it looks more like a global cache/registry (reached viasub_77B70/sub_2550C8, several hops removed from anything event-detail-specific) that just happens to normally get populated as a side effect of the game's ordinary navigation flow before car_select is ever reached, and which the direct jump's map→car_select shortcut simply never touches. - Snapshot:
lan_event_injection.h- addedg_eventDetailsTreeWarmedUp,GetOutputNode/GetOutputNodeFn/GET_OUTPUT_NODE_OFFSET, theGetOutputNodeinitialization line inInstallMapTrackHandleEventHook, and the warm-up block inHook_LayoutScreenCtor'sMapOverworldbranch. No other files touched. Built, installed, and live-tested (warm-up confirmed working twice; the downstream crash confirmed reproducible twice on a clean boot). - Outcome (superseded by the rest of this entry below): the
sub_171278/sub_77B70/sub_2550C8trace continued and led to a precise, empirically-confirmed root cause - see below. Short version: it is NOT a missing singleton construction; it's one specific, still-zero field on an already-valid singleton.
Continued same day: refuted the first fix attempt, found the precise field, still open
- First fix attempt (singleton construction) - tried, then refuted by direct measurement: traced
sub_890EC(called throughout this whole chain, always with a dummy/ignored argument per its own decompile) and found it is a plain no-argGetInstance()- RTTI-confirmed via its own"s_Instance"/"GetInstance"assertion strings - returning a single global singleton (dword_AD2A08), soft-warning "Not initialised" if still null rather than crashing itself. Found its matchingInitialise()(sub_244CA8, RTTI-confirmed via"!s_Instance"/"Initialise") - constructs a self-containedmalloc(0xD0)object (internal strings"CurrentState - Car"/"CurrentState - Race"suggest a car/race state tracker) with no external dependencies, and assigns it todword_AD2A08. Added a direct call to this fromTriggerTrueDirectCarSelectJump, gated ondword_AD2A08still being null. - Live-tested and refuted: added an unconditional diagnostic log of
dword_AD2A08's current value right before the jump. Across every test - cold boot, 5s delay (matching the original crash's own timing), 30s+ delay - the singleton was already non-null every single time, even before any of this session's code ran. The fix's ownInitialise()call was therefore never actually invoked (always found already-initialized) - it was a no-op, not a fix. Removed it (see the "SingletonInitialise() experiment removed" comment now in the code) rather than leave dead, misleading logic in place. - Real environmental confound discovered mid-investigation, worth remembering for future testing on this device: after this session's very long runtime (many repeated app launches, IDA sessions, background monitors), the device hit severe memory pressure (
free -hshowed 163MB free of 5.5GB) and separately the screen locked during a long gap between commands - both independently produced a "the trigger fires, logs look normal, but nothing ever happens - no crash, no navigation" symptom that looked like a code regression but wasn't. Diagnosed and fixed each time:am kill'd third-party background packages to free memory,input keyevent KEYCODE_WAKEUP+ swipe to clear the lock screen, and raisedscreen_off_timeoutto 30 minutes for the rest of the session. Once memory was healthy and the screen confirmed unlocked, the original crash reproduced cleanly and consistently again - this was pure environmental noise, not a behavior change from any code edit. Worth checkingfree -hand lock state early next time something "just stops happening" mid-session for no apparent reason. - Found the actual field, confirmed by direct measurement: re-examined
sub_23F990's crash-adjacent argument more carefully -v5(its own second parameter) isdword_AD2A08itself (the singleton, resolved via the no-argGetInstance()one level up insub_171278), not some other object as first assumed. The crash is*(_DWORD**)(v5 + 12)- i.e. the singleton's own+12field, whichsub_23E6AC(the constructor) explicitly zeroes at construction time (*(_DWORD *)(a1 + 12) = 0;) and which something else, later, is expected to populate before car_select's construction path reads it. Added a direct diagnostic read of*(int32_t*)(dword_AD2A08 + 12)right before firing the jump: confirmed0at the exact moment of a clean, reproduced crash (singleton=0xbf1e3080 *(singleton+12)=0(0x0), immediately followed byRestrictedGarageconstructing and then the samefault addr 0x38SIGSEGV). This is now a precisely confirmed root cause, not a static-analysis guess - the field genuinely is unpopulated at the exact instant of the crash. - Not yet found: what real code path normally populates
dword_AD2A08+12, and whether it can be safely replicated or invoked directly (the same trick used forGetOutputNode). A first attempt to find a setter viadword_AD2A08's own vtable (off_AA5B94) turned up a large, not-yet-fully-parsed vtable (RTTI/multiple-inheritance-shaped layout, several0/2/ptrtriplets mixed with real function pointers) - not conclusive without more work; a global write-to-+12search wasn't completed either (IDA's static xrefs don't trackglobal+offsetfield writes once code holds the resolved instance pointer rather than referencingdword_AD2A08by symbol again). - Snapshot:
lan_event_injection.h-SINGLETON_INSTANCE_OFFSET/SINGLETON_INITIALISE_OFFSET/SingletonInitialiseFn/SingletonInitialisedeclared (kept -SingletonInitialiseitself is legitimate, RTTI-confirmed, harmless to keep resolved even though the call site was removed); theTriggerTrueDirectCarSelectJumpcall site now does a read-only diagnostic log ofdword_AD2A08and its+12field instead of the removedInitialise()call. Two temporary diagnostic hooks added and then removed after serving their purpose (Hook_GenericFireOutputWrapperDiag/GENERIC_FIRE_OUTPUT_WRAPPER_OFFSET 0x1A7920sub_1A7920-entry hook from cont.54's dropped idle-timeout thread, andHook_Sub170EC8Diag/SUB_170EC8_OFFSETadded and removed this entry) - theirInstall*calls are gone frommain.cpp, but the hook function definitions themselves are still present in the header (harmless, unused) in case either is useful again later; not cleaned up further to keep this diff reviewable. - Outcome/Next - STILL IN PROGRESS, not yet resolved:
TriggerTrueDirectCarSelectJumpremains crash-prone on a genuinely cold session (no realevent_detailsvisit this session) - must still only be called after at least one real (or cont.45/46-style brief)event_detailsvisit, exactly as before cont.55 started. The warm-up mechanism (tree-pointer capture, confirmed solid) is real, working, harmless infrastructure that narrows the gap but does not close it. Next step, if resumed: finddword_AD2A08+12's real setter (finish the vtable parse, or search disassembly forLDR/MOVW+#0xAD2A08-adjacent code combined with a#0xCfield store) and either call it directly (matching theGetOutputNodepattern) or determine what data it needs and whether that data is itself available without a live visit.
2026-08-15 (cont. 56) — Closed cont.55: exhaustive live watchpoint search for +12's setter (hit a real deadlock, fixed it, still didn't find it), then restored a safe, permanent gate instead of chasing further
- What: Resumed cont.55 across a session restart, at the user's request, by finding the writer of
dword_AD2A08+12dynamically instead of by further static tracing - checked the vtable atoff_AA5B94fully (only 5 real entries:Serialize(), an emptynullsub,Reset(bool), the destructor, anddelete- the other four apparent "slots" turned out to be unrelated neighbouring PLT/IAT thunks, not part of this vtable at all) and confirmed none of the five touches+12.sub_890EC/dword_AD2A08itself turned out to be referenced from 200+ call sites across totally unrelated engine subsystems (physics, audio, etc.) - not car-select-specific at all - so exhaustively checking every caller by hand wasn't practical. - Built a live software watchpoint instead: redirect
dword_AD2A08to a dedicated shadow copy of the singleton,mprotectit read-only, and catch the very next write via aSIGSEGVhandler that reads the exact field offset plus the writing instruction's ownpc/lrstraight from the signal'sucontext- no hardware watchpoint/ptrace needed (this device's gdb/Frida watchpoints are already known-flaky per project memory). Extended it to auto-rearm on every catch so a whole chain of early writers could be logged, not just the first. - First version deadlocked for real on-device (required a
force-stopto recover) once the chain ran deep - root-caused to callingmmap()from inside the signal handler on every rearm: the interrupted code was itself deep in amalloc-using insert-loop and almost certainly already held an internal allocator lock that the handler's ownmmap()then needed too, classic signal-handler self-deadlock. Fixed by pre-allocating a whole pool of pages with a singlemmap()call safely outside any signal context during install, so the handler itself only ever doesmemcpy(fixed 208 bytes) +mprotect(a single syscall, no allocator interaction) + a pointer write on each rearm - confirmed this version no longer hangs, even chaining thousands of catches. - Still didn't find
+12's writer: the chain reliably caught two small, early, one-off writes (+112,+164) within microseconds of the singleton existing, then entered a very long repeated-write loop at+156(matching what looks like a bulk vector/hash-table insert - the same PC/LR fired thousands of times in a row) that never finished even after a 5000-page pool was fully exhausted. Suppressing repeat-offset log spam and raising the pool to 20000 pages didn't change the outcome either -+12simply wasn't reached within any tested budget. Given this loop's true length is unknown and each catch has real per-iteration syscall overhead, chasing it further would mean an open-ended, increasingly slow diagnostic run for uncertain payoff. - Decision, given the depth already reached: stop hunting for the specific writer. Disabled the watchpoint's automatic arming (left commented-out and fully documented in the header, in case this is ever picked up again) rather than deleting the working, deadlock-fixed infrastructure outright.
- Final fix - restored and generalized the original safety net: added
g_realEventDetailsVisitHappened, settruethe momentHook_LayoutScreenCtorsees a realEventDetailsconstruction (covers both genuine player navigation and the cont.45/46 auto-skip flash - both construct a realEventDetailsobject, unlike the tree-only warm-up which never does).TriggerTrueDirectCarSelectJumpnow requires this flag in addition tog_capturedEventDetailsTreeRefbefore proceeding - i.e. the warm-up mechanism from earlier in cont.55 remains in place and still eliminates the visible flash once a real visit has happened, but no longer lets the jump fire when nothing on the car-select-family construction path has ever actually run yet. - Verified live, both branches, same session: (1) cold - broadcast fired immediately after boot, no real
EventDetailsvisit yet:TriggerTrueDirectCarSelectJump: no real EventDetails construction yet this session (tree warm-up alone isn't safe - see cont.55) - ignoring- clean refusal, zero crash,pidofconfirmed alive. (2) warm - firedTriggerOpenCarSelectOnDemandonce (realEventDetailsconstruction via the auto-skip path), navigated back to the map, then firedTriggerTrueDirectCarSelectJump:singleton=0xbf45da60 *(singleton+12)=-1084370432(0xbf5dd200)- non-null, confirming the real visit is what populates it -RestrictedGarageconstructed cleanly, no crash, screenshot confirmed a fully rendered, interactive car_select screen. - Snapshot:
lan_event_injection.honly - addedg_realEventDetailsVisitHappened(declared next tog_eventDetailsTreeWarmedUp, with a comment correcting the earlier over-optimistic framing), set inHook_LayoutScreenCtor'sEventDetailsbranch, checked inTriggerTrueDirectCarSelectJump. The watchpoint infrastructure (ArmSingletonWatchpointFromCurrent/SingletonField12SigsegvHandler/InstallSingletonField12Watchpoint/WATCHPOINT_POOL_PAGES) stays in the file, fully functional and deadlock-fixed, but its one call site inHook_LayoutScreenCtoris commented out - not wired into any build path by default. No other files touched. - Outcome/Next:
TriggerTrueDirectCarSelectJumpis now safe to call from a lobby "select car" button in every tested condition - it silently no-ops (logs and returns, no crash) if called before any realEventDetailsconstruction this session, and works correctly (confirmed via full visual + crash-log verification) once one has happened, matching its original cont.48 safety contract exactly, just with the added zero-flash benefit once warmed. The underlying question - what specifically populatesdword_AD2A08+12, and whether it could someday be replicated to lift this requirement entirely - remains genuinely open, but is no longer blocking anything: this is now a solid, safe stopping point for this whole cont.55/56 arc.
2026-08-15 (cont. 57) — FOUND dword_AD2A08+12's real writer (across a session restart, at the user's explicit request to keep digging) - fixed the crash for real, and found (but did not resolve) a second, separate cold-start gap
- What: User asked to resume the "why does one real
EventDetailsvisit fix it" question specifically, willing to stop if it turned out uninformative or risky. Re-armed the (already deadlock-fixed, cont.56) software watchpoint, but armed it right before a realFireEventOutputcall instead of atMapOverworldconstruction - cont.55/56's attempt armed too early and drowned in an unrelated, very long early-boot insert-loop before ever reaching+12. Arming late, after all that boot noise settles, let the very first catch land exactly on+12on the first try. - Root cause, precisely identified: the writing instruction is inside
sub_240294(a1, a2)-*(_DWORD*)(a1+12) = *a2;(plus+16/+20, with refcounting on the third field) - called from directly insideFireEventOutput(sub_17A99C, already a known, resolved function pointer in this project since cont.26-28).FireEventOutputinternally: resolves the raw event key viaResolveHandle(already known,RESOLVE_HANDLE_OFFSET) against a global "EVENT ID " cache context, builds a small refcounted wrapper around the resolved event data, callssub_240294(singleton, resolvedEventData)to stash it as the state singleton's "current event context" - then, only after that, calls the same low-levelFireOutput/sub_1BB59Cthis project already knew about.TriggerTrueDirectCarSelectJumphad always calledFireOutputdirectly (skippingFireEventOutputentirely, cont.48's original design), so it always skipped this resolve-and-populate step - explaining exactly why a prior realFireEventOutput-based visit (viaTriggerOpenCarSelectOnDemand, cont.44) was needed first: that's the only thing in this whole file that ever actually ransub_240294. - Real fix, not a workaround:
TriggerTrueDirectCarSelectJumpnow callsFireEventOutput(g_mapScreenInstance, 0xC0FFEE00u)(our own synthetic event's key, already a resolved pointer, the exact primitiveTriggerOpenCarSelectOnDemandalready uses) instead of building an "EVENT" name slot and callingFireOutputdirectly. The+28tree substitution beforehand is unchanged and still does its job - it governs which treeFireEventOutput's own internalFireOutputcall resolves against (car_select's captured tree, not map's own), so the true one-hop skip is preserved, now with the missing singleton side effect included for free. Using our own event's key here is correct, not just convenient - the "current race context" now correctly reflects our own synthetic LAN event's data, not stale data from someone else's event. - Verified live, repeatedly:
*(singleton+12)reads0immediately before the fix's ownFireEventOutputcall and non-null (a real heap pointer) on every subsequent read - confirmed across many fire attempts, cold and warm, on this device and after a full device reboot. Zero crashes in any of them (pidofalive, crash buffer empty every time) - a real, unambiguous improvement over the pre-cont.57 state. - Second, separate, NOT resolved issue found during this same testing: even with the crash fixed, firing
TriggerTrueDirectCarSelectJumpas the very first call on a genuinely cold session (right after boot, before any realEventDetailsvisit) reproducibly does not visibly navigate at all - no crash, no screen change, indefinitely. Isolated this to being specific to this function, not a general device/environment flakiness:TriggerOpenCarSelectOnDemand(sameFireEventOutputcall, no+28substitution) was fired as the literal first cold call on the exact same fresh boot, in the same test session, and worked perfectly every time (EventDetails→ auto-skip →RestrictedGarage, all constructing cleanly). So the one remaining structural difference - pre-substitutingflowNode+28tog_capturedEventDetailsTreeRef(the cont.55 warm-up'sGetOutputNode-resolved tree) before firing - is the current suspect: that tree reference is a valid, non-null pointer (the lookup itself succeeds), but is suspected to be an incompletely-initialized node compared to one obtained via a genuineEventDetailsconstruction - i.e. potentially the same class of hidden-side-effect problem as the singleton, just not yet traced this deep. Not investigated further given the scope of effort already spent on this arc; the existingg_realEventDetailsVisitHappenedgate (cont.56) already fully covers this case operationally, so it isn't blocking anything, just not yet understood. - Real environmental noise hit again during this session, worth reiterating: multiple test cycles produced the exact same "fires, logs look clean, nothing happens" symptom this entry's own root-cause work was trying to distinguish from - traced each time to low free memory (
free -hdown to ~120MB free) or, after an explicit device reboot, to the lock screen (the reboot leaves the device locked; an app launched while locked never actually renders/progresses, producing hook logs that stop dead with zero further activity and no error). Killing background packages and/or swiping to unlock resolved each instance. Anyone continuing this investigation should checkfree -hand the lock state before trusting a "nothing happens" result as code behavior. - Snapshot:
lan_event_injection.honly.TriggerTrueDirectCarSelectJumpnow callsFireEventOutputinstead ofFireOutput+manualInternString/ctx setup (the actual fix).TriggerOpenCarSelectOnDemand's temporary watchpoint-arming call (used only for this investigation) removed again.WATCHPOINT_POOL_PAGESleft at the smaller300(cont.56 had raised it to 5000/20000 chasing the unrelated early-boot loop; not needed at that size once armed late).g_realEventDetailsVisitHappened's gate inTriggerTrueDirectCarSelectJumpkept (comment updated to explain it now covers two distinct, only-one-of-which-is-understood problems, not one). No other files touched. - Outcome/Next: The actual crash this whole cont.55/56/57 arc chased is now genuinely fixed at its root, not just gated around - confirmed via the precise mechanism, not inference. The
g_realEventDetailsVisitHappenedrequirement remains in place, now correctly understood to guard against (at least) two distinct hidden-initialization gaps rather than being a blunt "just in case" measure. If ever resumed: the second issue (direct-jump doesn't navigate cold, even without crashing) could likely be chased with the same late-arm-the-watchpoint technique, this time watching whateverg_capturedEventDetailsTreeRefitself points to rather than the singleton.
2026-08-16 (cont. 58) — Explored two forward-looking asks (inject a car class into car_select; capture the player's car/upgrades/color for a future native client) - both hit genuine, deep walls; nothing shippable landed, but the walls themselves are now well-mapped
- What: User asked to design, then implement, two things: (1) make the injected synthetic event open car_select pre-filtered to a specific car class, and (2) capture the player's final car + upgrades + paint color at confirm time so a UI layer or future native multiplayer client can know what was picked. Explicitly said to abandon either thread if it turned out uninformative or risky, rather than force a shaky landing.
- Design phase (no code yet): surveyed existing infrastructure first. Upgrades tracking already exists end-to-end and needed no new work:
g_modSlotSelections[]/Hook_ModSlotSelected(cont.39-41) captures accepted mod-slot picks,PersistAcceptedUpgrades()dispatches them to Kotlin via the establishedgame_events.h/GameEvents.ktJNI bridge (GameEventListener.onUpgradesAccepted, withregister()/listener fan-out already wired). User picked "read the car's current equipped color, don't add a new in-flow color-picker step" when asked to disambiguate - the simpler of two options. - Car-class injection (part 1) - looked solved, then found to be a false lead on closer inspection:
RACEEVENT_CATEGORYTAG_OFFSET(an already-known RaceEvent field from cont.29/33) looked like the answer - its own existing comment describedsub_27D1ECas reading*(thisPtr+12)as "a small 0-7 category-tag index," which car_select's real class-filter dropdown (ВСЕ/ВНЕДОРОЖНИК/КАЖДЫЙ ДЕНЬ/МАСЛКАР/СПОРТКАР/GT/ЭКЗОТИКА) looked like a natural match for. Implemented writing a configurable index into the categoryTag buffer, live-tested index 0 (showed a locked Marussia/СПОРТКАР by default) vs 1 and 3 (both showed the owned Ford Focus RS500/КАЖДЫЙ ДЕНЬ) - looked promising at first glance.- Re-checked
sub_27D1ECdirectly in IDA before trusting the A/B result, and found the original cont.29 comment itself was imprecise: the function isv1 = *(a1+12); return v1<=7 ? table[v1] : 0;- it reads*(a1+12)as the categoryTag pointer's own raw value, not anything stored inside the buffer it points to, and uses that raw heap address directly as a table index. A realcalloc()pointer is always far larger than 7, so this function always takes theelse 0branch regardless of what's written inside the buffer - the index write had zero effect on this function. The observed 0-vs-1/3 car difference was almost certainly unrelated noise (heap-address/session-state dependent), not a real effect of this field. - Reverted the write (kept
categoryTagcalloc'd as before, cont.29/33's original null-guard behavior unchanged) rather than leave a misleading no-op in place.RACEEVENT_CATEGORYTAG_OFFSET's own file comment now flags this as a documented false lead, explicitly warning not to revive the same assumption without re-deriving it. - Real mechanism for "which class car_select filters to" is still unidentified - worth noting the same field is also read completely differently by
sub_162F14(as an actual pointer,*(RaceEvent+12)+28, for the category display label) - two functions reading the same field two incompatible ways strongly suggests they're not even looking at the samea1/object, i.e.sub_27D1EC's own real argument may not be RaceEvent at all. Whoever picks this up next should tracesub_27D1EC's actual callers/argument first, not assume continuity with the already-understood label-display path.
- Re-checked
- Car+color capture (part 2) - two live dead-ends, one documented promising-but-large path found:
- Tried reading
RestrictedGarageLoadout's own construction parameter (a2inHook_LayoutScreenCtor) live, on the theory car_select might hand off "which car" directly when constructing loadout. Found (no crash, useful negative result) that*(a2+4)matches the FlowNode pointer and*(a2+8)dereferences to the literal ASCII string"car_select_loadout"- i.e.a2is flow/layout-resource construction metadata (which screen/resource to build), not car selection data at all. - Tried a live software watchpoint (cont.57's deadlock-fixed technique) armed at the exact moment car_select's own "CONTINUE" fires (car choice confirmed) - but arming from inside
Hook_FireOutputDiag's own already-executing processing of that same call turned out to be reentrant and hung the app for real (confirmed twice, requiredforce-stop), almost certainly because something in that call chain holds an already-fetched reference to the real singleton pointer that would diverge oncedword_AD2A08got redirected to a shadow copy mid-call. Built a second, simpler in-place variant (InstallInPlaceSingletonWatchpoint/InPlaceSingletonSigsegvHandler) that protects the singleton's own real page instead of swapping the pointer - confirmed this doesn't hang - but the singleton's own heap page turned out to be shared with a very frequent, unrelated write source (same PC0xce2a176c, both attempts, offsets far outside the singleton's own 208-byte size, with implausible/garbageLRvalues suggesting a GPU/driver-level write rather than normal heap code) that always won the race to fault first. Both attempts landed on this exact same noisy neighbor. - Found the real mechanism via string/xref archaeology:
"/Cars/Current Car"(with"CurrentState - Car"from the state singleton's own constructor, cont.55, being the same concept under a different key) is resolved through a full declarative property-binding system (sub_152250, which itself calls out to ~6 further sub-functions per property bound) rather than a plain struct field - the same general class of subsystem already flagged as a dead-end for an unrelated goal in this project's own memory (project_visible_streets_investigation- "property-registry all ruled out"). Confirmed real (im::app::ui::CarSelectionWidget/CarSelectButton/CarPaintSelectionWidgetare genuine RTTI-named C++ classes in this binary, not guesses), but properly understanding and safely reading from it is a substantial standalone investigation, not an extension of anything already in hand.
- User's call, given the size of what's left: stop here rather than keep spending session time on it. The recommended next attempt, if ever picked up again, is not the property-registry path - it's mirroring
Hook_ModSlotSelected's already-proven pattern (cont.39-41: hook the specific, low-level "user made a choice" callback directly, rather than trying to read back engine state) applied toim::app::ui::CarSelectButton's own click handler. That class's vtable/constructor haven't been examined yet.
- Tried reading
- Snapshot:
lan_event_injection.honly. Net change versus before this entry is close to zero shipped functionality: theRestrictedGarageLoadouta2-dump diagnostic and theInstallInPlaceSingletonWatchpoint()call site (added, then both removed after serving their purpose) are gone from the hot path; the categoryTag index write is reverted;kSyntheticEventClassIndexand its call site are removed entirely. TheInstallInPlaceSingletonWatchpoint/InPlaceSingletonSigsegvHandlerinfrastructure itself (deadlock-free, confirmed working, just not productive on this specific page) stays in the file, unused, alongside the earlier shadow-copy watchpoint from cont.55-57 - both are available if a future investigation needs a live memory watchpoint again. No other files touched; no user-facing behavior changed by this entry. - Outcome/Next: Neither of this entry's two asks landed. Upgrades-capture was already done before this entry started. Car-class injection needs a fresh, correctly-scoped trace of
sub_27D1EC's real caller/argument (notRaceEvent, per the two-incompatible-readers finding above). Car+color capture needsim::app::ui::CarSelectButton's own click-handler vtable slot found and hooked, following theHook_ModSlotSelectedpattern instead of trying to read engine state. Both are real, bounded, but separate pieces of RE work - worth treating as their own dedicated sessions rather than folded into "let's think about X" scope again.
2026-08-16 (cont. 59) — CarSelectButton (cont.58's recommended next step) tried live, confirmed to be a dead end for "which car" - it's the post-race "car unlocked" popup's button, not car_select's
- What: User asked to follow up on cont.58's own recommendation - hook
im::app::ui::CarSelectButton's click handler, mirroringHook_ModSlotSelected's proven pattern, to capture which car the player picked. - Vtable turned out to be a dead end for finding a click handler directly: found the class's vtable (
_ZTVN2im3app2ui15CarSelectButtonE) via RTTI archaeology (mangled name string →__class_type_info→ vtable viadata_ref), but nearly every slot (95-102 xrefs each) turned out to be generic, sharedboost::function/signal-dispatch machinery ("call to empty boost::function") used across dozens of unrelated widget classes - not a distinct override findable by inspection. - Found the constructor instead (
sub_35E134, found viadata_refto the vtable's own address) and, live-tested with the user's go-ahead, a per-instance "car reference" field at+264set either via a named-property lookup keyed on the constructor's second argument, or (fallback path, when a"SetUnlockedCar"property resolves to"Yes") from the state singleton's own resolved instance. - Live test: hooked the constructor, logged every call across a full car_select session (default view, carousel nav both directions, opening the class-filter dropdown, selecting "КАЖДЫЙ ДЕНЬ") - zero constructions, ever. Confirmed this wasn't a hook-install problem (install log fired normally, offset/prologue matched a fresh IDA re-check) - the class genuinely never gets instantiated by this screen.
- Root-caused why, via IDA, rather than guessing further:
sub_35E134is only reachable through a factory wrapper (sub_394F78) that gets registered by name into a generic widget-factory table -sub_38DA44(a large factory-registration function) builds a name string byte-by-byte on the stack (0x38df48-0x38df84:0x656C6553,0x61437463,'r'→ little-endian decodes to"SelectCar") and passes it to a registration call (sub_397BD8) alongside thesub_394F78factory pointer. - Cross-checked "SelectCar" against
NFSMW12MobileTools/layouts.sb.json's string pool (already-unpacked game layout resources): exactly one"ButtonType"/"SelectCar"pair exists in the entire game's UI layouts, sitting immediately next to"SetUnlockedCar"/"Yes","CAR UNLOCKED!!!","car_unlocked_info","GARAGE"- i.e.CarSelectButtonis specifically the single GARAGE button on the post-race "car unlocked" congratulations popup, not any part of the normal car_select browsing/carousel screen. This exactly matches the constructor's own"SetUnlockedCar"=="Yes"fallback path found in cont.58's decompile - fully consistent, not a coincidence. - Snapshot:
lan_event_injection.handmain.cpponly. TheInstallCarSelectButtonCtorHook()/Hook_CarSelectButtonCtordiagnostic (implemented and live-tested this entry) was reverted after confirming the negative result, replaced with an explanatory comment in the same spot - consistent with this project's convention of not leaving confirmed-nonfunctional diagnostics installed.main.cpp's install-call site reverted to match. No user-facing behavior changed. - Outcome/Next: This is now the fifth independent dead end on "which car did the player pick" (a2 red herring, watchpoint noise, property-registry system, categoryTag misread, and now CarSelectButton) - all traced to clean, understood root causes rather than left ambiguous.
im::app::ui::CarSelectButtonand its whole factory chain (sub_394F78/sub_394F20/sub_38DA44/sub_397BD8) can be crossed off entirely for this goal. Two directions remain genuinely open, neither started: (1) find the actual widget class the car_select carousel uses (thesub_38DA44factory-registration function registers many other named widgets besidesSelectCar- a neighboring registration, found by walking nearby string-building sequences the same way"SelectCar"was decoded here, likely names the real carousel/list widget); (2) revisit the declarative property-binding system (sub_152250,"/Cars/Current Car") properly as its own dedicated investigation, now that the button-click-handler shortcut is confirmed not to exist for this screen. User has not yet chosen between these - ask before starting either.
2026-08-16 (cont. 60) — Both cont.59 follow-ups (factory-registry neighbors; property-binding system) run to ground - the widget-factory registry is a flat list of single-purpose buttons (no per-car widget), and every real consumer of "/Cars/Current Car" is QA/debug tooling, not production UI
- What: Two follow-up threads picked up in direct continuation of cont.59, both purely investigative (IDA-only, no device testing, no code changes).
- Factory-registry neighbors of "SelectCar": decoded the two registrations immediately adjacent to
CarSelectButton's ("SelectCar") insub_38DA44's registration sequence, the same byte-by-byte-stack-string-decode technique used to find "SelectCar" itself: the preceding registration is "DefaultSettings" →im::app::ui::DefaultSettingsButton(sub_3632E0), the following one is "Nav" →im::app::ui::NavButton(sub_36E9E0) - both confirmed via RTTI vtable comments in the same pattern asCarSelectButton. This confirmssub_38DA44's full 94-call-site registration table is a flat list of ~94 distinct, narrowly-scoped...Buttonsubclasses (one class per specific action/purpose), not a per-list-item widget system - there is no "car carousel entry" widget class to find here at all. Car_select's single-car-at-a-time display is confirmed to be pure data/property rebinding of one persistent view, not N button instances, which is why zeroCarSelectButtons (or anything else from this registry) ever construct while browsing. - Property-binding system (
sub_152250,"/Cars/Current Car"), run down properly this time:sub_152250itself is a generic "bind a string property path to a component slot, trying each supported value type in turn" utility (calls out tosub_14A5F8/sub_14C240/sub_14E180/sub_14D0DC/sub_14DE7C/sub_14E664/sub_14DE84- one per property type) - confirmed not car-specific. Found all 5 real call sites (xrefs_toonsub_152250itself, not just the string) and traced each to its owning class/context:sub_20E088(called fromsub_20DD38←sub_20B5C4←sub_20AF30, whose constructor's own RTTI vtable comment readsim::app::layers::debug::CarPerformanceLayer) - binds"/Cars/Current Car"into a slot on this debug overlay, refreshed on every property-change notification.sub_210BD0(called fromsub_210880←sub_20F5CC←sub_20E914, RTTI-confirmedim::app::layers::debug::CarPreviewLayer) - same pattern, second debug overlay. 3./4.sub_2ACFAC(two call sites) - turned out to be a generic, unrelated helper that binds a list of components to sequential property paths, with the base path passed in as a parameter by its own caller rather than hardcoded - not specific to"/Cars/Current Car"at all, just a second, coincidental consumer of the sharedsub_152250utility for some other property list.sub_F85B8(a ~4.5KB function in a completely different, low-address code region full of float/VFP register pressure and large+0xFAC-style struct offsets, with no RTTI/vtable setup of its own) - almost certainly gameplay/physics-side Car-object update code, i.e. the writer, not a reader, of"/Cars/Current Car"(the live Car object publishing itself into the property tree when it becomes "current"), not something with any obvious hookable UI moment.
- Cross-checked against
NFSMW12MobileTools/layouts.sb.json's string pool for corroboration, same technique as cont.59's "SelectCar" check - no separate finding needed here since the RTTI trail was already conclusive.
- Snapshot: IDA/analysis only. No files touched, no device testing this entry - purely static re-analysis to close out cont.59's two open threads.
- Outcome/Next: Both of cont.59's follow-ups are now fully closed, cleanly negative. The production car_select UI does not use a per-car button widget (ruled out via the factory registry), and does not read the currently-selected car through the named property-binding system either (every real consumer of
"/Cars/Current Car"is QA/debug tooling or the writer side, not production UI). This converges hard on the conclusion already on record from cont.55-58: the actual, real mechanism the production UI uses is almost certainly the already-known state singleton (dword_AD2A08,"CurrentState - Car",+12/+16/+20fields) - property-binding and per-widget approaches are now both exhausted as alternatives to it. cont.58's live watchpoint on that singleton failed due to unrelated page-sharing noise (not a wrong-target problem) - if this is picked up again, the recommended next step is a different way to read the singleton (e.g. a hook at a fixed, low-frequency call site that's known to read it -TriggerTrueDirectCarSelectJump's own diagnostic already reads*(singleton+12)successfully - rather than a live memory watchpoint, which is what actually failed last time, not the singleton-as-target idea itself).
2026-08-16 (cont. 61) — SOLVED: "which car did the player pick" - a plain synchronous read of the state singleton at car_select's own CONTINUE, no watchpoint needed
-
What: User's direct follow-up to cont.60's own recommendation - hook the singleton's read at the moment car_select's CONTINUE fires, using a plain synchronous snapshot instead of cont.58's failed live watchpoint (the watchpoint's problem was the technique - an unrelated, high-frequency neighbor write always won the race on that heap page - not the singleton-as-target idea).
-
Implementation: added
DumpSingletonFields(tag)(lan_event_injection.h) - reads all 208 bytes (malloc(0xD0), cont.55) of the singleton atSINGLETON_INSTANCE_OFFSETas 52 raw DWORDs and logs them. Called from two points: once as a baseline right when car_select itself constructs (screenName=="RestrictedGarage", inHook_LayoutScreenCtor), and again - purely observationally, no interception - right when its own confirm checkmark fires"CONTINUE"(new budget-limited branch inHook_FireOutputDiag, gated ong_lastLayoutScreenName=="RestrictedGarage", distinct from the already-handled"RestrictedGarageLoadout"interception below it). -
Live test, methodical A/B/A: (1) baseline+CONTINUE on the default car (Ford Focus RS500) - all 52 fields identical between the two dumps, zero change. (2) Switched to a different owned car (Dodge Challenger SRT8 392, via the МАСЛКАР class filter) and confirmed - 6 fields changed:
+24, +28, +56, +60, +64, +68. (3) Switched back to Ford Focus RS500 and confirmed again -+24/+56/+60/+64exactly reverted to their original Ford values (+28stayed constant across all three - not car-specific;+68drifted slightly, likely an unrelated frame/session counter) - a real, reproducible, revert-verifiable per-car signal, not noise. -
+24decoded conclusively: it's a pointer to a car-resource object whose first bytes are directly the car's own ASCII resource-ID string, no further indirection needed. Dumped and manually little-endian-decoded the first 64 bytes at that pointer for both cars: Ford Focus RS500 →"ford_focus_rs500_2010_desc", Dodge Challenger SRT8 392 →"dodge_challenger_srt8_392_2011_desc"- both exact, unambiguous, human-readable identifiers matching the on-screen car precisely (make_model_variant_year_desc pattern - almost certainly the same resource-id family already seen elsewhere in this project, e.g.NFSMW12MobileTools's unpacked resources). -
Snapshot:
lan_event_injection.honly -DumpSingletonFields(now also dereferencing+24/word-index-6 and dumping+logging its first 16 words, added after the first successful test confirmed+24as the signal), one baseline call site inHook_LayoutScreenCtor, one CONTINUE-time call site inHook_FireOutputDiag(g_singletonContinueDumpBudget, budget 20). Still a diagnostic (logs everything, doesn't extract/expose the string yet) - no JNI/game_events.h/GameEvents.kt wiring done this entry. -
Outcome/Next: This is the answer to the "which car" half of cont.58's original two-part design ask (car+upgrades+color capture for a UI layer / future native client) - genuinely solved, live-verified twice over (two different cars, confirmed revert). Upgrades-capture was already done (cont.39-41,
g_modSlotSelections[]/FireUpgradesAccepted). Paint color (the third piece, "read the car's already-equipped color" per the user's own earlier choice) is still unexplored - likely lives on the same car-resource object+24points to, or a sibling field on the singleton, worth checking the same object's other bytes/offsets for a color index/enum now that the object itself is located. Next concrete step, if continued: (1) clean up the diagnostic into a minimal "read car ID string" helper (no more need for the full 52-word dump now that+24is confirmed - keep only+24and maybe+28/+68until their meaning is understood too), (2) extendgame_events.h/GameEvents.ktwith a new event (e.g.onCarSelected(carId: String)) mirroringFireUpgradesAccepted's JNI pattern, fired from the sameHook_FireOutputDiagCONTINUE branch, (3) look for the paint color nearby. -
Wired into the JNI bridge same-session, per the user's follow-up choice: replaced the exploratory 52-word dump with a minimal
GetCurrentCarId()(lan_event_injection.h) that just returns*(const char**)(singleton+24). AddedFireCarSelected(carId)togame_events.h(NewStringUTF/CallStaticVoidMethod, same shape asFireUpgradesAccepted, new cachedg_onCarSelectedMethod) andonCarSelected(carId: String)/dispatchCarSelectedtoGameEvents.kt, mirroring the existingGameEventListenerpattern exactly.Hook_FireOutputDiag's car_select-CONTINUE branch now callsGetCurrentCarId()+FireCarSelected(carId)directly (no more budget-limited raw-dump logging). The unused ctor-time baseline call and its dump helper were removed fromHook_LayoutScreenCtortoo, now that the answer is known. -
Live end-to-end verified: rebuilt (native + Kotlin both recompiled cleanly), reinstalled, opened car_select, confirmed the default Ford Focus RS500 - log chain confirmed complete:
REAL CONFIRM: car_select CONTINUE - carId="ford_focus_rs500_2010_desc"→GameEvents.dispatchCarSelected: 0 listener(s), carId=ford_focus_rs500_2010_desc→GameEvents: onCarSelected dispatched to Kotlin successfully. Zero listeners is expected (nothing subscribes yet) - the JNI plumbing itself is confirmed working end-to-end, native hook to Kotlin dispatch. -
Final outcome: "which car did the player pick" is now fully solved AND exposed on the same
GameEventListener/GameEventssurface asonMapLoaded/onUpgradesAccepted- ready for a real Kotlin-side consumer (UI layer or future native-client bridge) toregister()and receive it. Only paint color remains open from the original three-part design ask.
2026-08-17/18 (cont. 63) — Found the real persistent per-car storage (color, confirmed) and definitively closed the "upgrades" question (no persistent storage exists - confirmed why, not just that)
- What: User asked to keep digging for paint color specifically (not just the transient per-session write already found at
singleton+176from the RESPRAY popup), then, after color was nailed down, asked to also verify whether upgrades land anywhere similarly persistent - "надо по максимуму узнать информацию о машине, апгрейдах и цвете, чтобы потом не возвращаться к этому", plus an explicit architectural note: the UI layer is just a controller, the native layer (the planned native RatNet multiplayer client) also needs to know this state directly, not only via one-shot events. - Color:
+56/+60/+64probed and ruled out,+64's own+16field caused a real, understood, self-diagnosed crash: all three are pointer-shaped fields on the same singleton that change per-car (found in cont.61's A/B/A).+56's begin pointer → another car-id string variant (no_descsuffix).+60's begin pointer → empty string (likely an unused "custom name" field).+64's own+16field is a custom SSO ("small string optimization") slot: for Ford Focus it held the literal inline ASCII bytes"default"(short string, fits inline); for Chevrolet Corvette it held a real heap pointer, dereferenced live to"CAR_MODEL_CORVETTE_ZR1_NFS_EDITION"- a model/SKU analytics tag, not a color. Blindly dereferencing+64/+16as a pointer for the Focus case crashed for real (SIGSEGV fault addr 0x61666564- that address is the literal ASCII bytes "defa", i.e. the crash was the inline-string bytes being misread as a pointer) - confirmed via the crash log/backtrace, then removed the risky blind deref fromProbeCarColorFieldsrather than add SSO-mode detection just to keep probing an already-identified non-color field. - Found the real, persistent per-car storage -
GetCarRegistry()/LookupCarRecord(): traced viasub_246950(called by the RESPRAY popup'sPAINT1..PAINT6swatch handlers,sub_188F7C, specifically to compare a tapped swatch against the car's already-saved color) =*(sub_25102C(sub_77B70(), &(singleton+24)) + 4). Decompiled the whole chain:sub_77B70(GET_CAR_REGISTRY_OFFSET = 0x77B70) is a second, independentGetInstance()-style singleton getter (own"s_Instance"/"Not initialised"assert pair, globaldword_AD299C) - almost certainly the owned-car/garage collection manager (matches the garage screen's own "ПОЛУЧЕНО 55/55").sub_25102C(LOOKUP_CAR_RECORD_OFFSET = 0x25102C) is a generic hashmap lookup: buckets atregistryPtr+72/+76, each node is 8 words{key, value[0..5], next}, keyed by the car-id string's own pointer value (relies on car-id strings being interned/deduplicated, so the same car always resolves to the same pointer) - returnsnode+1(the 6-word/24-byte value region) on a hit.- Wired both as real function pointers (
GetCarRegistry/LookupCarRecord, resolved inInstallMapTrackHandleEventHookalongside the other primitives) instead of reimplementing the hash walk - reusing real engine code, this project's established pattern.
- Color, live A/B/A/A verified across two cars, three colors, and a full app restart:
DumpCarRecord(tag)reads and logs the 6-word car record at car_select's ownCONTINUE.- Ford F-150 SVT Raptor, repainted blue (2nd swatch) →
record+4 = 1. Verified this survived a full app process restart (the transientsingleton+176field correctly reset to0on the same read,record+4did not - direct proof this field is the real persisted color, not session state). - Ford Focus RS500, repainted green (2nd swatch in its own palette) →
record+4 = 1(consistent - same swatch position). - Same Focus, repainted orange (3rd swatch) →
record+4 = 2(index tracked the swatch position change exactly, ruling out coincidence).
- Color is fully solved and confirmed reliable:
record+4(a plain 0-based index into the car's own swatch palette) is the real, persistent, restart-surviving color state, readable at any time viaGetCarRegistry()/LookupCarRecord(carId)- not tied to any specific UI screen or event.
- Ford F-150 SVT Raptor, repainted blue (2nd swatch) →
- Upgrades: found why there's no persistent storage, not just that there isn't one - three-round live investigation:
- Live-tested
record+8/record+12(whichsub_23AC88(record, upgradeTypeId)reads as a begin/end vector of installed-upgrade-type IDs - looked like exactly the right field) immediately after actually purchasing and equipping "ВОССТ. ШИНЫ" (tire repair) on the Focus - zero, three separate times, including once with no app restart at all between the purchase and the check (~20 samples captured via the CONTINUE hook's own budget, all identical, all zero). - Traced the purchase path itself (
sub_247640, uses the interned string"upgrade"for the cash deduction) tosub_24D5B4, which builds a composite key{carId, upgradeTypeId, ServerTime}(own assert name:"PendingUpgrade") and passes it tosub_257894. - Decompiled
sub_257894and found its real purpose from its own assert strings:"!IsApplied()"/"OrderCarUpgrade"/"Cannot make changes to change set once applied"- it appends the composite key to a local/transientstd::vector-shaped "change-set" object (a1+184/188/192, begin/end/capacity pattern), not into the persistent per-car hashmap record at all.
- Conclusion, matching the user's own clarification that upgrades apply per-race and are never saved: there is no persistent per-car upgrade storage to find, because none should exist by design - "OrderCarUpgrade" is a transient, presumably server-bound transaction object (the
ServerTimefield fits this), and in this offline/LAN test environment it never resolves into any lasting state. The already-existingHook_ModSlotSelected/g_modSlotSelections[]/FireUpgradesAccepted(cont.39-41) - which captures the UI's own transient per-race selection directly, event-driven - is confirmed to be the correct and complete mechanism; nothing deeper to find. Re-confirmed live this session too:ModSlot: slot 0 recorded -> CarMod=0x1for "ВОССТ. ШИНЫ", matching that hook's own pre-existing code comment.
- Live-tested
- Snapshot:
lan_event_injection.honly. AddedGET_CAR_REGISTRY_OFFSET/LOOKUP_CAR_RECORD_OFFSET+GetCarRegistry/LookupCarRecordfunction pointers (resolved alongside the file's other primitives),DumpCarRecord()+ProbeCarColorFields()+TryLogAsString()(all still TEMPORARY/diagnostic, budget-limited, not yet wired togame_events.h/GameEvents.kt- color is understood but not yet exposed over the JNI bridge the waycarIdalready is). No other files touched this entry. - Outcome/Next: All three pieces of the original cont.58 design ask (car, upgrades, color) are now genuinely resolved - car and upgrades are already wired to Kotlin (cont.61, cont.39-41 respectively); color's storage/read mechanism is fully understood and live-verified but not yet wired to the JNI bridge. The user's own next request (this same message): don't expose color as a bare index to the UI/native layer - resolve it to at least a name string, ideally an RGB value, before wiring it up. That mapping (index → name/RGB) hasn't been located yet - likely lives in a per-car color/swatch resource table (matching the swatch icons seen in the paint UI), possibly in
NFSMW12MobileTools's unpacked resources or a similar in-binary table - not yet searched.
2026-08-18 (cont. 63b) — Color name/RGB resolved DYNAMICALLY from the live engine (not a static table) - full carId+color pipeline now wired end-to-end to Kotlin
- What: Direct follow-up to cont.63's open item. First explored a static solution: unpacked
alfaromeo_4c_2012_desc.prefabs.sb/ford_focus_rs500_2010_desc.prefabs.sb(and all 71*_desc.prefabs.sbcar files) viaNFSMW12MobileTools.jar, found each car's own resource file has aPaintJobstruct list (Name+SwatchColor/SwatchColor2DATA_ID_REFs resolving to{Red,Green,Blue,Alpha}byte structs) whose order exactly matches the in-game swatch index (verified: Ford Focus index 1/2 = "Green"/"Orange", exactly matching cont.63's own live-painted colors) - built a complete 67-car{carId: [{name, rgba}]}table from this (28KB JSON). User's own call, mid-implementation: a static table goes stale if cars are ever added/modded into the game later - asked to find the dynamic, live in-engine equivalent instead, so color resolution keeps working for any future car without needing to re-extract/re-ship anything. - Found
CarDescription::GetPaintJobDescription(int paintJobIndex)(sub_B3564, own assert:"paintJobIndex >= 0 && paintJobIndex < (int)m_PaintJobDescriptions.size()") - a plain vector-index accessor:*(carDescPtr+104) + 112*paintJobIndex(astd::vector<PaintJobDescription>'s begin pointer, 112 bytes/element). Found the real call site/argument viasub_188024(car_select's own screenTick, which calls this same function every frame to render the live-previewed swatch):sub_B3564(*(singleton+56), paintIndex).sub_890EC()appearing in that function's own decompile is a red herring - decompiled it directly and confirmed it takes no real argument, returning the same familiardword_AD2A08singleton this file already reads everywhere. - First guess wrong, caught by a real live crash, second guess right: assumed
singleton+24(the same object the car's ID string lives on) was itself usable asCarDescription*- live-tested, got back an obviously-garbage pointer (0x66647342, which is literally the ASCII bytes of "Bsdf" misread as an address) and a realSIGSEGVwhen dereferencing it (fault addr matches exactly). Corrected tosingleton+56per thesub_188024trace - live-tested again, got a real, sane 112-byte record, and itsword[0]dereferenced to the string"Orange"with+96(packed RGBA) decoding toR=240,G=120,B=0,A=255- an exact, byte-for-byte match with cont.63's own live-painted "Orange" Focus and this entry's own static extraction of the same car/color, independently confirming both the offset and the field layout. PaintJobDescription's 112-byte layout, now fully mapped (matches the unpacked resource schema's field order exactly): six 16-byte string fields (begin/current/capacityEnd triple + 4 bytes padding each) -+0Name,+16DiffuseTextureFilePath,+32DiffuseMaskFilePath,+48BRDFFilePath,+64BRDFSpecularResponseFilePath,+80NumberPlateTextureFilePath - then+96SwatchColor (packed RGBA, one byte per channel),+100SwatchColor2,+104Type (int32),+108UseVinylMap/padding.- Live-verified three times, three different cars, zero static data involved: Ford Focus RS500 →
carId="ford_focus_rs500_2010_desc" color="Orange" RGBA=240,120,0,255. Jeep Grand Cherokee SRT8 (its untouched factory color, never manually repainted this session) →carId="jeep_grand_cherokee_srt8_2011_desc" color="Deep Cherry" RGBA=132,19,19,255. Both dispatched cleanly end-to-end to Kotlin with no crash. - Wired into the JNI bridge, replacing the diagnostic scaffolding entirely:
GetCurrentCarColor()(lan_event_injection.h) is the clean, final helper -struct CarColor {name, r,g,b,a}, built fromGetCarRegistry()/LookupCarRecord()(cont.63, for the saved color index) + the newGetPaintJobDescriptionchain.FireCarSelected(game_events.h) andonCarSelected/dispatchCarSelected(GameEvents.kt) all extended to(carId, colorName, colorR, colorG, colorB, colorA). All of cont.63's exploratory diagnostics (DumpCarRecord,ProbeCarColorFields,TryLogAsString,TryDumpPaintJobDescription, their budget globals) removed now that the answer is known and shipped - matches this project's established convention of not leaving dead diagnostic code installed once a question is answered. - Snapshot:
lan_event_injection.h,game_events.h,GameEvents.kt. The unpacked 67-car static color table (car_colors.json, scratchpad only) was not added to the app - superseded entirely by the dynamic resolver per the user's own explicit direction, kept only as this entry's own verification cross-check. - Outcome/Next: The full original cont.58 design ask (car + upgrades + color, for a UI layer and a future native RatNet client) is now completely resolved and shipped:
onCarSelected(carId, colorName, colorR/G/B/A)fires once per real car_select confirm,onUpgradesAcceptedfires once per real loadout exit (cont.39-41) - both live-verified end-to-end, both resolving all data dynamically from the game's own current engine state rather than any static/baked snapshot. Nothing outstanding from this arc; remaining open items are the older, unrelated ones already on record (car-class injection into car_select's filter, cont.58; the cold-session direct-jump navigation gap, cont.55/57).
2026-08-18 (cont. 64) — Full lobby-flow design + clickable UI prototype (LOBBY_UI_DESIGN.md, LOBBY_PROTOTYPE.html) — no native/engine work this entry
- What: User asked for a prototype of the lobby overlay UI and to fully think through the multiplayer lobby flow end-to-end — creation, joining, player list, hand-off into native car_select and its upgrade screen, and specifically how to handle players who load into the race at different speeds. Pure design/UI session — no IDA, no device testing, no native code touched.
- Existing infrastructure surveyed first, not re-derived: confirmed
MultiplayerCore.ktalready exposestriggerCarSelectTest()/triggerTrueDirectCarSelectJump()(native jump straight into car_select, PROGRESS.md cont.44/48/57) andGameEvents.ktalready fires realonCarSelected/onUpgradesAccepted(cont.61/63b) — these became the concrete "proven" anchors the whole flow design is built around, rather than inventing new native hooks speculatively. - Key design correction found while mapping the flow: the synthetic "LAN: " event used to reach car_select on demand has its loadout-confirm deliberately redirected back to the map (cont.36/52) because it has no real track/scene reference — letting it through crashes
RaceLoaderTask_ResetStartingLine. Documented the recommended fix (ride a real, existing race event as the "vessel" and let track substitution, ARCHITECTURE §3a, swap its geometry) instead of the much bigger "give the synthetic event a real scene reference" investigation. - Three design/prototype iterations, each driven by direct user feedback:
- First pass: portrait phone mockup, 9 screens, Compose-overlay pattern reused from
CarSelectionBadge. - User corrected orientation ("ориентация должна быть как у игры — альбомная") — rebuilt as a 720x340 landscape device frame with real two-column layouts (list + side-rail), not a squeezed portrait; added a dedicated player-profile screen (name + local avatar preset) after the user flagged nowhere existed to store player identity.
- User gave a large batch of follow-up feedback: profile should be a global chip/popover (not a forced first screen), first-run name should auto-generate (not force typing), IP:port should be shown everywhere for both LAN and WAN, rewards need to be explicit validated fields (not static pills), the landscape layout still read as an adapted-portrait list (needed real redesign: car icons per player, a track hero placeholder, readiness as a button not a toggle switch, and the ability to change car even after readying up), and — the most architecturally significant point — loading/waiting for stragglers should happen at the engine level (the real native pre-race starting-grid scene, looping, with only a thin overlay status strip on top) instead of a custom Compose loading screen. Rebuilt accordingly; caught and fixed a real bug during live DOM verification (
isValidAddraccepted out-of-range IPv4 octets like999.1.1.1— tightened to a proper per-octet 0–255 check) and an initial-screen index bug (state.screenstarted on the lobby browser instead of the map).
- First pass: portrait phone mockup, 9 screens, Compose-overlay pattern reused from
- Docs split per explicit instruction ("выкладки по дизайну оверлея веди в отдельном файле, сделай из него ссылку из основного"): moved ARCHITECTURE.md §4/§4a/§4b/§4c (lobby UI mechanism, flow, data schema, player identity) into a new dedicated
LOBBY_UI_DESIGN.md, replaced with a short pointer + the one native-relevant takeaway (engine-level waiting) in ARCHITECTURE.md; the original content is preserved in a collapsed<details>block for history rather than deleted. - Verification: screenshot tooling in the Browser pane was intermittently flaky this session (timeouts on
computer{screenshot}specifically, one scratchpad-directory reset mid-session); relied primarily on live DOM/JS assertions (querySelector/state checks after simulated clicks) to confirm each screen renders and each interaction (avatar picking, live name preview, ready-button toggle, car-change-after-ready, rewards validation, IP validation) actually works, with screenshots as a secondary check where the tool cooperated — both agree the prototype works as designed. - Snapshot:
LOBBY_PROTOTYPE.htmlandLOBBY_UI_DESIGN.mdadded at repo root (persisted, not just an ephemeral Artifact); ARCHITECTURE.md trimmed. No files underlauncher/ornative_lib/touched. - Outcome/Next: Design phase for the lobby UI is in good shape and reviewable end-to-end via the prototype. Nothing here is implemented as real Kotlin/Compose yet — next step, whenever picked up, is turning
LOBBY_UI_DESIGN.md's screens into actualmpcore/appCompose code, starting with the parts already marked [proven]/[wired] (car-select hand-off, car/color/upgrade capture) since those need no new native work. The single biggest remaining native gap is still Phase 5 (engine-level wait-for-stragglers hold/release hook) — see LOBBY_UI_DESIGN.md §7.
2026-08-18/19 (cont. 64) — car_select class-filter injection: the label field found and shipped; the actual list-filtering field found too, but its "rebuild" call crashes at the timing tried — reverted, documented, not yet solved end-to-end
- What: User asked to revisit car_select's class-filter injection (cont.58's original, never-resolved "inject a car class" half of the design ask) — explicitly important for choosing the car class shown in the synthetic lobby. Also gave standing guidance mid-investigation: unlike car color (cont.63b, resolved dynamically from live engine data because per-car resource files could change), the car-class enum itself is a small, fixed, game-design-level set that will never change — hardcoding it (in both the native layer and any future UI) is the right call, not worth a dynamic-resolution effort.
- Found the real label field: traced
sub_162F14(event_details' own category-label builder, already known from cont.29/58) fully — its switch statement reads*(*(singleton+12) + 132)(note:+132on the resolved event object*(singleton+12)itself, not+12's own categoryTag buffer — cont.58's original false lead conflated the two) and maps it to one of 7"CAR_CLASS_X"string keys. Full enum decoded and confirmed to line up exactly with car_select's live "КЛАСС" dropdown order (ВСЕ/МАСЛКАР/СПОРТКАР/GT/КАЖДЫЙ ДЕНЬ/ВНЕДОРОЖНИК/ЭКЗОТИКА):0=ANY, 1=MUSCLE, 2=SPORTS, 3=GT, 4=SEDAN, 5=SUV, 6=SUPER. Cross-checked againstRaceEventCtor(sub_2A4B58)'s own decompile: it explicitly zeroes this same+132field, confirming0/ANY as the real engine default and that the object (malloc(0xE4)= 228 bytes) is comfortably large enough for this offset. - Wired
RACEEVENT_CARCLASS_OFFSET(=132) intoInjectSyntheticEvent(kSyntheticEventCarClassconstant) — live-tested with1(MUSCLE): car_select's own class dropdown correctly showed "КЛАСС - МАСЛКАР", locked (padlock icon, confirming the field is genuinely read as an active filter mode, not just cosmetic) — the label half is fully solved and confirmed working. - Label alone doesn't filter the browsable list: live-tested repeatedly (arrow-key browsing after the label changed) — the carousel stayed stuck on the same non-muscle car (a Land Rover Range Rover Evoque, tagged ВНЕДОРОЖНИК/SUV) regardless. Traced why via
sub_16677C(the real dispatcher a live player's own tap on a class-filter dropdown row resolves to, routed by matching"CLASS_FILTER_MUSCLE"/etc. strings) →sub_165630: its own first line is*(sub_890EC()+68) = <UI-index>— the actual list-filtering state lives on a completely different field,singleton+68(a persistent, global "current UI class filter" slot), not anywhere on the RaceEvent object at all. Documented asSINGLETON_CARCLASS_FILTER_OFFSET, with its own (third!) index scheme decoded fromsub_165630's own switch (0=MUSCLE, 1=SPORTS, 2=GT, 3=SEDAN, 4=SUV, 5=SUPER, 6=ALL) — added a small conversion table (RaceClassToUiFilterIndex) sokSyntheticEventCarClassstays the single source of truth for both fields. - Setting
singleton+68early (inInjectSyntheticEvent, well before car_select ever constructs) was ALSO live-tested and found insufficient on its own — the list still wasn't filtered. Root cause:sub_165630doesn't just set the field, it also callssub_16692C(a1)(a1= the car_select screen instance itself) to actually rebuild the list from it — car_select's own construction path evidently builds its list once, synchronously, and doesn't re-read the field lazily. Found and decompiledsub_16692Cfully: clears the old list, callssub_23FF50(&result, sub_890EC(), 1)(an "enumerate owned cars filtered by the current class field" style call) then populates UI rows one by one viasub_166C8C(a1, ...)—a1's own field layout (+304/+312/+328/+332/+364/+368/+380/+384/+388/+396/+400/+404, all sub-widget/list-container-shaped) matches a large screen-controller object, consistent with it being the same kind of object asHook_LayoutScreenCtor's owna1forscreenName=="RestrictedGarage". - Tried calling
RebuildCarClassList(a1)(resolvedsub_16692Cfunction pointer) right afterRestrictedGarageconstructs, using the already-captured screen instance — live-tested, crashed for real:SIGSEGV,"Dereferencing a NULL component pointer", fault addr0x44, insidesub_16692Citself, witha1correctly matching the just-built screen instance (confirmed via the log line printed immediately before the crash). Some dependencysub_16692Cneeds isn't ready yet this early in construction — calling it at this exact hook point isn't safe as-is. Reverted the call (kept the two field-writes, which are harmless and still make the label work) rather than ship a crashing path, per this project's established convention. - Snapshot:
lan_event_injection.honly. AddedRACEEVENT_CARCLASS_OFFSET/SINGLETON_CARCLASS_FILTER_OFFSET/REBUILD_CAR_CLASS_LIST_OFFSET+kSyntheticEventCarClass/RaceClassToUiFilterIndex, both field-writes wired intoInjectSyntheticEvent(live, active, safe),RebuildCarClassListfunction pointer resolved but its one call site removed after the crash.kSyntheticEventCarClassleft at0(CAR_CLASS_ANY, matching the engine's own default) — the mechanism is proven and ready to flip on for a specific class once the rebuild-timing problem is solved. - Outcome/Next: Genuinely two-thirds solved, not fully shippable yet. The class label (and, per the padlock, the "this session is class-locked" mode signal) works end-to-end and is safe to enable today. The actual car list doesn't filter yet because the one known way to force a rebuild (
sub_16692C) crashes when called immediately post-construction. Next concrete step if resumed: find a later, safer point to callRebuildCarClassList(a1)— candidates matching this file's own established patterns for "needs to happen slightly after construction settles" are a short deferred timer (likeg_autoSkipEventDetailTargetPending's own delay mechanism) or piggybacking on the screen's first real per-frameTick(sub_188024, already known from cont.42/43) instead of its constructor. Decompiling what specifically sits at offset0x44relative to whatever's null at crash time (likely one ofsub_16692C's own referenced sub-fields ona1, e.g.+304/+312) would also narrow down exactly what isn't ready yet, rather than guessing at timing alone.
2026-08-20 (cont. 65) — Debug menu (Compose): on-map button, explicit mpcore flag, money-editor stub — see DEBUG_MENU.md
- What: User asked for a Compose-based debug menu, first feature a money-amount editor, activated by a button on the map, with an explicit enable mechanism living in
mpcoreas a simple flag. Pure Kotlin/Compose UI session — no IDA, no native code touched, no device testing yet (build-verified only). - Design decision, confirmed with the user before implementing: the money editor is a stub for now — edits local Compose state only, doesn't write into the game's real balance. No RE work has located the player's actual spendable-cash storage (getter/setter/address) —
ANALYSIS.md/PROGRESS.md's only cash-adjacent findings areCashReward(a race event's bronze/silver/gold reward definition, §6aa/6z) and theISaveablefamily (Profile,CurrentState, etc., 2026-08-06 save/profile entry) that plausibly hosts the real balance but was never traced that far. Wiring the editor to the real balance is out of scope for this entry, tracked as a separate future RE task inDEBUG_MENU.md§3. - Reused, not reinvented, the existing overlay mechanism: same
ComposeView-added-to-mFrameLayoutpattern asCarSelectionBadge(CarSelectionOverlay.kt, cont.63b), sameGameEventListener-as-Compose-state adapter shape, and the same already-provenGameEvents.onMapLoaded()signal (fires off the existingMapTrack::HandleEventnative hook — no new native hook needed) to decide when the button should appear. - New files:
mpcore/src/main/java/nfs/mod/mpcore/DebugFeatures.kt—object DebugFeatures { var debugMenuEnabled: Boolean = true }, the one explicit gate for all debug tooling, deliberately placed inmpcore(notapp) per direct instruction.app/src/main/java/com/ea/ironmonkey/DebugMenuOverlay.kt—DebugMoneyState(local Compose state,apply()setter),DebugMenuVisibility(GameEventListener, tracksmapLoaded),registerDebugMenuListener(), and theDebugMenuButton/DebugMoneyDialogComposables (FAB →AlertDialogwith a digit-filteredOutlinedTextField+ Apply/Cancel). - Wired into
GameActivityMain.kt: newbuildDebugMenuOverlay()(bottom-end corner, deliberately oppositeCarSelectionBadge's top-start corner so the two never overlap), added tomFrameLayoutinonCreateonlyif (DebugFeatures.debugMenuEnabled)— flipping the flag off means the view is never built or added, not just hidden. - Verification:
./gradlew :app:compileDebugKotlin—BUILD SUCCESSFUL, no new errors/warnings beyond pre-existing unrelated deprecation warnings elsewhere inGameActivityMain.kt. Not yet exercised on-device (noadb/device session this entry) — the on-map button's actual appearance/tap flow is unverified live, only compiled. - Docs: per direct instruction, this work gets its own living doc — new
DEBUG_MENU.md(design, activation mechanism, stub-scope decision, panel inventory table) — with a short pointer + one load-bearing decision (flag lives inmpcore) added toARCHITECTURE.md§4d, same split pattern asLOBBY_UI_DESIGN.md/§4. - Snapshot: on branch
lan-event-injection-poc(already-current feature branch, per this project's master-stays-release-ready policy) — note this branch also carries pre-existing uncommitted changes from the class-filter work (cont.64,lan_event_injection.h/main.cpp), untouched by this entry. New:DebugFeatures.kt,DebugMenuOverlay.kt,DEBUG_MENU.md. Modified:GameActivityMain.kt,ARCHITECTURE.md,PROGRESS.md. Nothing committed yet. - Outcome/Next: Debug menu framework is in place and compiles; next steps are (1) on-device verification of the actual button/dialog on a real map load, and (2) the separate RE task of locating the real player-cash storage so the money editor can eventually write real values, per
DEBUG_MENU.md§3. Future debug panels should be added to the same menu/dialog rather than as new ad-hoc overlays.
2026-08-20 (cont. 64 continued) — Two more timing strategies for the class-list rebuild tried and ruled out (Tick hook never fires; deferred timer crashes too) — the rebuild call itself, not its timing, is the real blocker
- What: Direct follow-up to cont.64's own "Next" section - user asked to try calling
RebuildCarClassListfrom car_select's own first Tick instead of its constructor. - Tick hook (
sub_188024) installed and live-tested - genuinely never fires, contradicting cont.42/43's own documented finding: hooked it with the lightest possible wrapper (call the original unconditionally first, identical timing to no hook at all, only check a one-shot flag after) specifically to avoid reproducing cont.43's unexplained touch-responsiveness regression from hookingsub_16C660(a function this same Tick calls). Confirmed the hook installs correctly (offset/prologue verified via IDA, install log fires). Live-tested on three separate occasions - car_select's own browsing screen, its loadout screen, and a REAL (non-synthetic) event's full flow through both screens - zero Tick calls logged, ever, despite cont.42/43's own record describing this exact function firing "~200x/sec from app boot onward". First test round's "success" (car_select screen showing correctly) turned out to be Android'sonRestartlifecycle resuming a backgrounded activity, not a fresh construction - re-tested properly with a fullam force-stop+ fresh launch + real map-pin tap, and the negative result held. - Reverted the Tick hook entirely (not just disabled) - not worth keeping installed-but-nonfunctional code around, and it's unclear whether it's genuinely dead in this build or something about the hook itself is still subtly wrong; either way, not a productive foundation to build on further right now.
- Deferred-timer variant (polled from
Hook_InternStringDiag, 500ms afterRestrictedGarageconstructs - the same wall-clock pattern every other deferred fire in this file already uses) - crashed for real, again:SIGSEGV,"Dereferencing a NULL component pointer", fault addr0x7this time (vs0x44for the original immediate-post-construct attempt) - a different null field inside the same function (sub_16692C), at a very different timing (500ms vs 0ms). Two independent crashes at two very different delays rules out simple "not ready yet" timing as the root cause. - Conclusion:
sub_16692Cmost likely depends on something our synthetic event flow never populates at all, not on anything timing-related - consistent with this whole project's recurring pattern of synthetic-vs-real state gaps (e.g.g_realEventDetailsVisitHappened, cont.55-57). Retrying with yet another delay or hook point isn't likely to help without first identifying what that missing dependency actually is - e.g. by decompiling what specifically sits at offsets0x44/0x7on whichever object faulted, or by testing this exact rebuild call on a REAL event's car_select (not yet tried) to see if it succeeds there, which would confirm the "synthetic-only gap" theory directly. - Snapshot:
lan_event_injection.h/main.cpp. All class-list-rebuild attempts (constructor-time, Tick-based, deferred-timer) fully reverted - onlyRACEEVENT_CARCLASS_OFFSET's field-write (the working label) andRebuildCarClassList's own function-pointer resolution (a proven-real primitive, kept resolved for whenever this is picked up again) remain.kSyntheticEventCarClassback at0(CAR_CLASS_ANY, safe default). Build and live device stability reconfirmed after every revert. - Outcome/Next: Still two-thirds solved - class label works and ships, class list filtering remains unsolved after three independent timing strategies. This is no longer a timing problem to iterate on; it's a "what does
sub_16692Cactually need, and does our synthetic event flow have it at all" question. Recommended next step if resumed: test the exact sameRebuildCarClassList(a1)call on a real event's car_select (confirmed reachable this session via a real map street's event list) to check whether it crashes there too - if it doesn't, the gap is synthetic-event-specific and the fix is providing whatever real events have; if it does, the function needs a proper argument/precondition trace via IDA before it's safe to call at all.
2026-08-22 (cont. 66) — CRITICAL: found and fixed two real crashes on a genuinely fresh save/first race, both pre-existing gaps this project's testing had never actually exercised
- What: User reported the game had become unplayable end-to-end - "impossible to complete any race", race results not saving on a fresh save, and a guaranteed crash right after the very first race on genuinely fresh game data. This session's entire prior testing (cont.61-64) had exclusively used an already-100%-complete save (loaded specifically to make car-variety testing easier) or mid-progression saves, which never exercised the prologue/tutorial race or an empty car-record registry - both gaps this entry found.
- Bug 1 -
GetCurrentCarColor()(cont.63b) can compute a wild pointer on a car never seen before: re-decompiledGetPaintJobDescription/sub_B3564and found its own out-of-range handling is broken - it logs an assertion but stillreturnsvectorBegin + 112*indexusing the original, still out-of-rangeindex, not a safe fallback. Separately,LookupCarRecord's own "not found in the hashmap" fallback record (sub_25102C's lazily-initialized default object) is allocated via plainmalloc(notcalloc) and never explicitly zeroed - so its own+4field (our color index) can be genuine uninitialized heap garbage. On a fresh save, before the car-record hashmap has any entries, this combination means the player's very first car_select confirm could compute and dereference a wild pointer. Fix:GetCurrentCarColor()now independently reads the same vector boundssub_B3564itself reads (carDescPtr+104/+108), validates them, and clampscolorIndexto[0, count)itself before ever callingGetPaintJobDescription- never trusts the native function's own broken fallback. - Bug 2 (the real, confirmed root cause of the reported crash) -
kEnableTrackSubstitutionHook(a much older PoC toggle, subtask 1 of this whole project) was lefttrueand has zero scoping:Hook_BuildTrackScenePathunconditionally overrides the track/environment for every race load in the game, not just our own synthetic LAN test event - confirmed viamain.cpp, no gate at all. Live-reproduced on a genuinely fresh save:SIGSEGV, null pointer deref, fault addr0x14, on a background loading thread, immediately after"BuildTrackScenePath hook fired: track -> region4_chicago_track4, env -> chicago4"fired during the game's own scripted prologue/tutorial race'sPreRaceLoadingScreen. The prologue's other scripted data (checkpoints, cutscene triggers, start/finish) still expects its own original track, so the substitution mismatches and null-derefs downstream - the exact same crash class this hook's own comment already documents for the abandoned region3/colorado override, just newly hitting a different race (the prologue) that no prior test session ever reached, since every earlier test used a save that had already passed it. This project's own[[project_track_substitution_scope]]note ("only regular races need to work") was an intent, never actually enforced in code. Fix: defaultkEnableTrackSubstitutionHooktofalse- this is a standalone PoC/test tool, not something that should run as part of normal play; flip it on deliberately only when specifically testing track substitution itself. - Verification: live-tested on this session's own test device after a full
pm clear(genuinely fresh save, matching the user's own repro exactly) - reached and completed the prologue race (gold/1st place,+10000$/+20000 SP), multiple screen transitions afterward, zero crashes, before the user's own parallel manual test (with real steering, which this session's own automated adb testing can't provide) confirmed the actual reported crash live and its exact log signature, which is what led to finding Bug 2 above. - Snapshot:
lan_event_injection.h(GetCurrentCarColor's new bounds-check),main.cpp(kEnableTrackSubstitutionHookdefault flipped tofalse). Both fixes are defensive/config-level, not new features - no other behavior changed. - Outcome/Next: Both real regressions found and fixed this entry. Standing lesson for this whole project, not just this bug: testing exclusively on an already-progressed/100%-complete save systematically misses anything gated on "first ever" state (empty registries, the prologue, first-run flags) - worth deliberately re-testing major changes against a
pm clear-fresh save periodically, not just the convenient late-game save.kEnableTrackSubstitutionHook's own track-substitution PoC itself (region4_chicago_track4 override) remains available, just off by default now - re-enable deliberately and re-scope it (e.g. gate ong_currentEventIsSynthetic) if that line of work is ever resumed.
2026-08-25 (cont. 67) — Third critical regression found: real race medals silently never recorded, root-caused to a stale "narrow test fix" left permanently installed
- What: After cont.66's crash fixes, user confirmed the prologue race completes without crashing - but reported a new symptom: finishing a real race (career/tutorial, e.g. "Перед вами FAIRHAVEN") on gold correctly grants cash/SP, but the medal itself is never recorded and the street's completion-% badge stays at 0%. User was confident this was a regression from this project's own patches specifically, not a pre-existing/server-side limitation (correctly - see below).
- Read-side investigation: decompiled
MapTrack::AddEvent(sub_369AB0, called fromMapTrack::RefreshEvents/sub_369040whenever a street's pin refreshes). It increments a per-pin "total medal slots" counter (this+272 += 3) unconditionally, but only increments "medals earned" (this+276) after resolving a per-event persistent progress record through a nested lookup chain (sub_77B70→sub_251188→sub_77B70→sub_2515D0/sub_250F34) and reading a reflective"medal"property off it (sub_4F99F0/sub_4F9A80, the same generic property-getter pattern already reverse-engineered forCashReward's Bronze/Silver/Gold in earlier sessions) - this directly drivesRefreshEvents's(this+276)/(this+272)*100completion badge. Also found a same-shaped per-driver "medal" write path (sub_235798/sub_236ED0, building/reading a results-table row withname/vehicle/localPlayer/time/medalcolumns) used for the PostRaceResults display itself - a different, in-memory-only subsystem, not the persistent progress record. - Ruled out: live logcat showed a recurring
[AUTOLOG QUERY] Server: Blaze Call: /nfs-2013-android/util/pingfiring every ~10s throughout the whole session with no response ever logged - EA's original online backend for this game is dead, so this looked like a plausible "medal commit needs a server ack that never arrives" explanation. User correctly pushed back: this exact symptom never happened before this project's patches, and the dead Blaze backend predates and is unrelated to any of this session's work - redirected the investigation back to this project's own hooks. - Method: rather than continue tracing the write-side nested-registry chain blind (
sub_366160/sub_36AEC0, both large, both call the samesub_77B70/sub_251188/sub_2515D0family), re-audited every hookmain.cppinstalls unconditionally (i.e. not behind any of the existingkEnable*toggle flags):CopSoundsTickSkip,GetComponentNameSkip,StrlenNullGuard,InternStringDiag,FatalLogCallerTrace,ResolveDisplayText,ResolveDisplayTextWrapperDiag,LayoutScreenCtor,ModSlotSelected,FireOutputDiag,FlowNodeTick. Added a temporarykEnableDiagnosticAndTestHookstoggle to install none of them (keeping onlyMapTrackHandleEvent/LAN injection and the Soak Test disable hook) - live-tested, medal recorded correctly with all of them off. Bisected by re-enabling all of them exceptGetComponentNameSkipHook- live-tested again, medal still recorded correctly - isolating that one hook as the sole cause. - Root cause:
GetComponentNameSkipHook(Hook_GetComponentName, hookingsub_240548) was added in cont.29/30 as a workaround for one narrow SIGSEGV: our own synthetic test actor (fromInjectSyntheticEvent, never going through normal prefab registration) wasn't found in a hash-table lookup insideGetComponentName, falling into an RTTI/error-string fallback path that crashed. The fix as written always returns a fixed empty-string sentinel (off_AC80E0) for every call, unconditionally - not just the specific null-input case the comment described as "a real, already-safe code path in the shipped binary." Decompilingsub_240548's actual callers found this was never "purely cosmetic" for real gameplay:sub_240294doesn't just log the returned name, it writes it into a named-cache-context object's own+8field (*(a1+8) = sub_240548(...), the cache context itself resolved viasub_7566C- the same cache-context accessor the medal-lookup chain inAddEventalso uses), andsub_240294is called fromsub_17A99C- the same real map-event-processing function this project's ownFireEventOutputtargets. With every real gameplay entity's component name always resolving to the same empty string instead of its real name, whatever downstream lookup/keying depends on that name plausibly collides or misses - breaking the medal-progress record specifically, while leaving the independent cash/SP grant path (which doesn't go through this same naming step) unaffected. This exactly matches the observed symptom. - Fix:
InstallGetComponentNameSkipHook()is no longer called fromJNI_OnLoad- left declared/available (commented out) for if the original narrow crash needs revisiting later, but must not ship installed, since it silently corrupts real save progression for every player on every real race, not just the one synthetic test scenario it was originally written to unblock. - Verification: live-tested three times total this session (all-diagnostic-hooks-off, all-but-this-one-on, and the final clean build with just this one hook removed) - medal correctly recorded in all three, confirming both the root cause and that no other currently-installed hook is implicated.
- Snapshot:
main.cpp(InstallGetComponentNameSkipHook()call site removed fromJNI_OnLoad, function itself left in place - now incrash_workarounds.hafter cont.68's file split - unused). - Outcome/Next: Standing lesson for this whole project: a hook written to fix one specific crash in one narrow test scenario (here, triggering the synthetic LAN event's own car-select flow) can silently corrupt unrelated, real gameplay systems if its "safe" substitute path is taken unconditionally instead of only for the exact input/condition that used to crash - always prefer gating narrow crash workarounds on the specific condition (a null input, a known synthetic-only object identity) over blanket-replacing a function's behavior. If the original cont.29/30 crash needs revisiting (e.g. to resume car-select-list-filtering work or the synthetic-event demo flow), a properly scoped fix should check the actual crash condition (or gate on
g_currentEventIsSynthetic) rather than reinstalling this hook as-is.
2026-08-25 (cont. 68) — Refactor: split lan_event_injection.h (3149 lines) into topical files, dedupe hook-install boilerplate, delete confirmed-dead code
- What: User flagged that
lan_event_injection.hhad grown to ~3000 lines mixing unrelated concerns, with comments in places exceeding the code they documented - explicit direction to bring the file's architecture in line with what it's actually doing, and cut comment volume down. Pure refactor, no behavior change intended (or found) - verified live afterward. - Deleted ~490 lines of confirmed-dead code (grep-verified zero call sites anywhere in the project, not just
main.cpp): two experimental SIGSEGV-based software watchpoints on the state singleton (InstallSingletonField12Watchpoint/InstallInPlaceSingletonWatchpoint, cont.55/58 - both had their only call sites already removed in earlier sessions) and three one-shot diagnostic hooks (InstallGenericFireOutputWrapperDiagHook/InstallSub170EC8DiagHook/InstallConfirmCarSelectionHook, cont.43/54/55 - never called fromJNI_OnLoad). Left a short pointer comment to the relevant PROGRESS.md sections in case either investigation resumes, instead of keeping the dead code itself around "just in case." - New
util/hook_install.h: one sharedInstallArmTrampolineHook(base, offset, hookFn, debugName)helper implementing this project's one hooking pattern (2-word-copy trampoline +LDR PC,[PC,#-4]redirect) - replaces ~15 hand-copied ~28-line blocks (mmap/mprotect/patch/clear-cache, each previously retyped by hand per hook) with a single ~3-line call per installer. EveryInstall*Hook()in the project now goes through this one function; lower duplication, lower chance of a hand-copy typo in a security-sensitive code-patching path. - New
car_selection.h:GetCurrentCarId/CarColor/GetCurrentCarColorand their offsets (GET_CAR_REGISTRY_OFFSET/LOOKUP_CAR_RECORD_OFFSET/GET_PAINT_JOB_DESC_OFFSET/REBUILD_CAR_CLASS_LIST_OFFSET/SINGLETON_INSTANCE_OFFSET) - reading the player's live-selected car and its persisted paint color, entirely separate from the synthetic-event-injection machinery. (Car-CLASS handling -RACEEVENT_CARCLASS_OFFSET/SINGLETON_CARCLASS_FILTER_OFFSET/RaceClassToUiFilterIndex- stays in the core file: unlike color, it's about labeling/filtering the synthetic event's own class, deeply embedded inInjectSyntheticEvent'sFakeActorconstruction, not a standalone car-read concern.) - New
crash_workarounds.h: the narrow, single-purpose crash mitigations from cont.29-34 (CopSoundsTickskip,GetComponentNameskip [kept disabled per cont.67's finding, with that lesson documented directly on the hook],Strlennull-guard,ResolveDisplayText's "XXXXX[...]" marker strip,ResolveDisplayTextWrapperDiag). - New
mod_slot_tracking.h: loadout mod-slot-pick tracking (ModSlotSelection,RecordModSlotSelection,PersistAcceptedUpgrades,Hook_ModSlotSelected) - self-contained, only touchesgame_events.h'sFireUpgradesAccepted. - Comments trimmed, not just relocated: the densest remaining block (the synthetic-event/real-exit-chain state-machine documentation at the top of the core file, ~300 lines) was condensed from multi-paragraph per-session "cont.NN" narration into single-paragraph why-notes - kept every load-bearing fact (offsets, invariants, what breaks if a gate is removed) but cut the blow-by-blow investigation diary, which already lives in this file's own history.
- What stayed in the core
lan_event_injection.h, deliberately not split further: the synthetic-LAN-event-injection state machine itself (InjectSyntheticEvent,Hook_MapTrackHandleEvent,Hook_LayoutScreenCtor,Hook_FireOutputDiag,Hook_InternStringDiag,Hook_FlowNodeTick,Hook_MapScreenTick/Soak-Test-disable, the on-demand car-select triggers) - these all read and write the same dozen-odd global state flags (g_currentEventIsSynthetic,g_realExitChainActive,g_chainIndex, etc.) in a single cohesive state machine; splitting them across files by "which hook function this is" rather than "what subsystem this belongs to" would have fragmented one coherent concern instead of clarifying it. - Verification: rebuilt after each extraction step (5 separate incremental builds, all
BUILD SUCCESSFUL), then installed the final build on the test device and confirmed via logcat that all 12 expected hooks still install cleanly (GetComponentNameSkipHookcorrectly still absent, per cont.67) and that LAN synthetic-event injection + map navigation behave identically to before the refactor (sameMapTrackenumeration, same synthetic-event injection log line, sameWARM-UPcapture) - no crash, no missing hook, no behavior change. - Snapshot:
lan_event_injection.h3149 → 1691 lines. New:car_selection.h(125),crash_workarounds.h(200),mod_slot_tracking.h(86),util/hook_install.h(51) - 2153 total lines across all 5 files, vs. 3149 in one file before. Committed together with cont.64/66/67's fixes (class injection, the two fresh-save crash fixes, and the medal-tracking regression fix) as a single commit onlan-event-injection-poc, since all of it was still uncommitted from the same work session. - Outcome/Next: File organization now roughly matches actual concerns (car reads / crash workarounds / mod tracking / shared hook infra / the core injection state machine), and the hook-install pattern has one canonical implementation instead of 15 hand-copies. If this file grows again, prefer adding a new topical file over growing the core one further - but resist over-splitting the exit-chain/synthetic-event state machine itself, which is genuinely one coupled subsystem, not several.
2026-08-26 (cont. 69) — Subtask 2 fully scoped via RE: starting grid, opponent substitution, and cop-spawn all mapped to real addresses; no code yet
- What: Autonomous overnight session (explicit instruction: work through the token budget, report at the end) answering all four open questions from
ARCHITECTURE.md§3b (scoped the prior session): is the starting grid per-track, is "player always last" an index or insertion-order effect, where does an opponent's car/color get written, and where do cops/traffic spawn. Pure research — no hooks installed, nothing built or tested on-device. - Method: started from already-unpacked
event_*_race.prefabs.sb.jsonfiles (62 events, from an earlier session) plus two freshly-unpacked full track scenes (region4_chicago_track1,region1_foothills_track1, ~75kDATA_Elementseach), then moved into IDA once the data side gave concrete leads (RTTI class names, property-name strings) to chase. - Grid layout — not per-track:
StreetRaceStartingGrid's 5 spacing/speed parameters (MinDistanceBetweenRacers=10.0,MaxDistanceBetweenRacers=15.0,MaxTrackWidthFraction=0.8,PlayerStartingSpeed=100km/h,OpponentsStartingSpeed=130km/h) are hardcoded C++ constructor defaults, confirmed via decompile and confirmed via a full struct-name enumeration across all 62 events that no event ever overrides them via data. Each track supplies only a single"start"locator actor (confirmed via two full scene unpacks — no numbered/per-slot spawn actors exist anywhere). The0xcfeb5c/0xcfeb8c/0xcfebb8vtable addresses recorded inANALYSIS.md§3.1 are stale (from an old.i64) — real addresses are0xaa7d28/0xaa7da8(RaceStartingGrid/StreetRaceStartingGrid). - "Player always last" — insertion-order, not an index: the placement algorithm (
sub_2B88BC) loops over the opponents vector first (accumulating randomized inter-car spacing), then places the player once at the tail in a separate call. This means multiplayer doesn't need to fight this rule — real players inserted into the opponents vector get normal grid slots via the same code path as AI, and only each device's own local player keeps the dedicated last slot (which is fine/expected). - Opponent car+color write path found:
Opponent's live layout (+20/+24/+28=CarDescriptionNamestring,+36=ColourIndexint) and its builder (OpponentCollection::PopulateFromProperties,sub_2B649C) are both fully decompiled. Plan: hook after this builds the normal AI list, overwrite selected entries' two fields with real players' own already-captured car/color data. - Bonus, relevant to subtask 4:
PlaceCar(sub_2914DC) calls the realTrackNavigator::Resolve(sub_3261B0) — the spline-distance+lateral-offset → world-position transformARCHITECTURE.md§5 predicted subtask 4 would need. Only the forward direction is confirmed; the inverse (world→spline, needed to read a moving car's current position) not yet found. - Cops: found
SpawnCopCar(sub_F85B8) and its scheduler gate (sub_F7E9C— cooldown timer + active-count check before spawning). Plan: skip the scheduler's body entirely for multiplayer sessions, same pattern as the existingCopSoundsTickhook. A second caller (sub_F8078) not yet examined. - Civilian traffic: confirmed data-level (
RaceEvent.TrafficCarCount,TrafficFlowcomponent) but the runtime spawner wasn't traced — likelyTrafficCarCount=0alone is sufficient (same pattern as every other per-race data override in this project), needs a live A/B test to confirm. - Snapshot:
ANALYSIS.mdnew §6hh (full technical writeup, all addresses/offsets/decompiled functions),ARCHITECTURE.md§3b rewritten from a scope-only placeholder to reflect these findings with per-item status tags. Nomain.cpp/lan_event_injection.hchanges — this entry is planning/RE only. - Outcome/Next: subtask 2 is now RE-complete enough to start implementing hooks. Recommended order: (1)
OpponentCollectionsubstitution hook (2.1) since it's the most self-contained and directly reuses this project's existing string-write/field-override patterns; (2) verify (1) also produces correct grid placement for free, confirming the Q2 insertion-order theory live; (3) cop-spawn scheduler skip (simple, same shape as an already-proven hook); (4) civilian traffic A/B test. Subtask 4 (coordinate sync) remains deliberately deferred per the user's own reasoning (avoid discovering facts that contradict subtask 2's now-large accumulated fact base) but already has a head start via theTrackNavigator::Resolvefinding.
2026-08-26 (cont. 70) — Subtask 2.1 implemented and CONFIRMED LIVE (opponent car substitution reaches the rendered grid); cop-spawn scheduler hook implemented; civilian traffic found harder than scoped
- What: Direct continuation of cont.69's RE pass - user explicitly asked to start implementing subtask 2 point by point, working autonomously through the night, moving to the next open item rather than waiting whenever something needed a decision only the user could make (e.g. spending in-game currency to unlock a car class - skipped that path and found an already-owned-class event instead).
- Diagnostic hooks first (
opponent_substitution.h): hookedOpponentCollection::PopulateFromProperties(sub_2B649C) andStreetRaceStartingGrid's placement method (sub_2B88BC) to log every pointer both touch, specifically to answer cont.69's one open verification question - are these the same vector? Live-tested via a real (non-synthetic) event replay ("Перед вами FAIRHAVEN", driven through the whole menu→car-select→grid flow viaadbUI taps): confirmed they are NOT the same objects -StreetRaceGrid::Place's vector held addresses that never appeared inOpponentCollection's own vector across two separate captures. There's a real, unidentified intermediate "spawn the actual racer" step between them. - Despite that, the substitution mechanism itself works: modified the hook to unconditionally force opponent-slot-0's
CarDescriptionName/ColourIndexto a fixed test car ("ford_focus_rs500_2010_desc") atPopulatetime (map-load), rebuilt, and replayed the same real event end-to-end. Screenshot proof: before the hook, the lead opponent car was a white sedan-shape car; after, it's unmistakably a silver/gray Ford Focus RS500. Logcat confirms the in-memory write held across every collection populated that pass. This settles subtask 2.1's core open question: whatever the intermediate spawn step is, it reads car/color off these sameOpponentobjects (or a copy taken strictly afterPopulateruns) - the hook point identified in cont.69 is sufficient, no need to trace the intermediate step further. - Cop-spawn scheduler hook implemented (
cop_traffic_disable.h): hookssub_F7E9C(the cooldown+count-check gate beforeSpawnCopCar) to skip its body entirely when enabled. Installs cleanly, confirmed stable through map/menu navigation, no crash - but the scheduler never actually fired while idle on the map (0 log lines), meaning it's very likely gated on active driving or a pursuit context. Visual "cops don't spawn" confirmation needs a longer, active-gameplay or pursuit-triggering test than this session's tap-only navigation could practically set up - left as a follow-up. - Civilian traffic correction: cont.69's own writeup undersold this -
RaceEvent.TrafficCarCountis not a plain fixed-offset field, it resolves through the same reflective per-name property lookup already seen for"medal"(cont.67) andColourIndex. A post-deserialization field overwrite (the plan from cont.69) doesn't apply the way it did forTrackName/CarDescriptionName. Needs either the real runtime traffic-spawner function (not found) or a narrower hook on the reflective lookup helper itself, gated on the"TrafficCarCount"key. Deferred, not attempted this session. - Both new hooks left gated off by default (
g_enableBlanketOpponentSubstitutionTest/g_enableCopSpawnSkipTest, bothfalse) before ending the session - the opponent substitution is a blanket test (no lobby data model exists yet to target a specific race/player), and leaving either on unconditionally would affect the user's own normal singleplayer testing. Final build installed on the test device has both off - safe for normal play. - Snapshot: new
opponent_substitution.h, newcop_traffic_disable.h, both wired intomain.cpp'sJNI_OnLoad(unconditionally installed, behavior internally gated by the runtime flags above - diagnostic logging stays on, low-risk/budget-capped).ANALYSIS.mdnew §6ii/§6jj,ARCHITECTURE.md§3b updated with current per-item status. - Outcome/Next: Subtask 2.1's core mechanism is proven end-to-end - the remaining work is entirely a lobby/session data-source problem (which race, which real players' cars/colors), not further RE. Subtask 2.4's cop half is code-complete pending a proper visual test; its traffic half needs another RE pass on the reflective property system. Recommended next steps, roughly in order: (1) a proper active-gameplay test of the cop-scheduler skip (drive around / trigger a pursuit, not just idle on the map), (2) trace the reflective
"TrafficCarCount"consumer or scope a keyed hook on the property-lookup helper, (3) once the lobby UI/data model exists, replace the opponent-substitution blanket test with the real, targeted version.
2026-08-26 (cont. 71) — Civilian traffic hook implemented and CONFIRMED LIVE, correcting cont.70's own mistake about TrafficCarCount
- What: Direct continuation of cont.70, same autonomous session. Re-examined
RaceEvent's deserializer (sub_2A4D70) decompile more carefully after cont.70 wrongly concludedTrafficCarCountneeded a harder, keyed reflective-lookup hook. - Correction: the reflective read (
sub_4F99F0/sub_4F9A80) happens exactly once, during deserialization - identical in shape to every other field this function reads. The resolved value is then stored as a plain int at a fixed offset,RaceEvent+116, on the live object - the original cont.69 plan (hook after deserialization, overwrite the field) was right all along. - Implemented and live-tested: second hook in
cop_traffic_disable.h, onsub_2A4D70itself, zeroing+116after the real deserializer runs. Confirmed via logcat: fired once per event at map-load time, correctly zeroed real non-trivial values (1,2,10for one event) to0, no crash, process stayed stable through the same real-event test flow as cont.70. - Not independently confirmed: actual absence of ambient traffic while driving - only checked via the data write (logcat) and a stationary starting-grid screenshot, which doesn't show moving traffic either way regardless of the hook. A real visual check needs active driving, which this session's
adb-tap-only navigation can't practically simulate. - Snapshot:
cop_traffic_disable.hnow has two hooks (cop-scheduler skip, traffic-count zero), both gated off by default before ending the session.ANALYSIS.md§6jj corrected in place (not a new section - fixed the wrong conclusion directly, noted as a correction).ARCHITECTURE.md§3b's traffic status updated to match. - Outcome: All four of subtask 2's original open items now have working, live-tested (to varying confidence levels) implementations: 2.1 opponent substitution (fully confirmed, screenshot proof), 2.2/2.3 grid/spawn-order (solved via RE, no code needed beyond 2.1's own hook), 2.4 cops (installs cleanly, spawn-time behavior not yet visually confirmed) and traffic (data write confirmed, visual road-emptiness not yet confirmed). Everything is gated off by default - the device was left in a safe, normal-play state. Next real step for subtask 2 is no longer RE - it's building the lobby/session data layer that can actually drive the now-proven opponent-substitution hook with real player data, plus two remaining visual confirmations (cops during a pursuit, traffic while driving) whenever manual/longer testing is convenient.
2026-08-26 (cont. 72) — Full mock multiplayer grid emulated: 5 distinct cars/colors + randomized (non-last) player position, both confirmed live together
- What: User asked to try emulating a fuller session - different cars/colors per opponent (not the single fixed test car from cont.70) and a randomized player grid position (not always last).
- Varied roster: extended the blanket opponent-substitution test from one car to a 5-entry roster of distinct real cars/colors, cycled by slot index. Live-confirmed: screenshot shows 3 visibly different cars on the same grid (BMW M3, Dodge Challenger, Ford Focus RS500), logcat confirms all 5 slots got distinct assignments correctly and consistently.
- Random player position - the harder half: "player always last" (cont.69/§6hh) is baked into the grid-placement function's own call ORDER (place every opponent first, then the player once at the tail), not a data field - so this needed a genuine reimplementation of that placement loop, not another post-hoc field tweak like every other hook this session. Wrote a new branch in the
StreetRaceGrid::Placehook that calls the same two real engine primitives (sub_291BA4for opponents,sub_2914DC/PlaceCar directly for the player) in a randomly reordered sequence -rand()picks which of thecount+1slots the player lands in, falling through to the completely untouched original function whenever the test flag is off. - Both confirmed live, together, in one test run: logcat showed
RANDOM GRID TEST: 5 opponents, player placed at slot 0/5on the exact same real-event replay that produced the 3-distinct-cars screenshot - no crash, stable through the full menu→car-select→grid flow. - Snapshot: both extensions in
opponent_substitution.h, gated off by default before ending - device left in a safe, normal-play state (both flagsfalse). - Outcome/Next: subtask 2 now has a fairly complete local demonstration of what a real multiplayer grid would look like - varied cars/colors, unpredictable player position, all working simultaneously and confirmed stable. The single remaining gap, unchanged from cont.70/71, is the lobby/session data layer to feed this real player data instead of a hardcoded test roster and
rand().
2026-08-26 (cont. 73) — Full-save re-verification, a real spawn-collision bug found and fixed live, cop-scheduler test inconclusive
- What: Continuation of cont.72, now against a user-provided "full" 100%-complete save (backed up the prior save to two locations first: scratchpad + device
/sdcard/) to re-verify all four subtask 2 hooks on fresh, previously-untested tracks/cars instead of the same repeatedly-driven event. - Re-confirmed on a new track ("Битва на шоссе" / Highway Battle, Reynolds Lane, player car Bugatti Veyron 16.4 Super Sport instead of the earlier test car): opponent roster substitution (all 5 distinct cars cycling correctly),
TrafficCarCountzeroing (51 firings, real non-zero values correctly zeroed), and random grid position (different slot than previous tests, confirming the randomization varies per race) all still worked exactly as cont.70-72 found, on genuinely different data. - Real bug found by the user, live: while watching a race start, the user caught the player's car spawning inside another car and being launched by the resulting physics interpenetration. This was cont.72's random-grid-position hook, not the substitution or traffic hooks.
- Root cause, found by fully decompiling
sub_291BA4/sub_2914DC/sub_2B88BC(previously only partially read in cont.69):sub_3261B0(TrackNavigator::Resolve) returnsv13/v14, the world-space lateral lane bounds at a given track distance, symmetric around the centerline. The player is always placed at literal lateral offset 0 (dead center). An opponent's lane-cycle formula((a6+1)%3)*0.5*a4 + (1-a4)*0.5lands exactly on the same center (0) whenever its indexa6 ≡ 0 (mod 3). Vanilla is safe because the player is placed once, at the very end, past the entire accumulated distance of every opponent - lane overlap never matters because longitudinal separation is always huge. Cont.72's reimplementation broke that invariant by inserting the player mid-sequence, using only the normalMinDistanceBetweenRacers..MaxDistanceBetweenRacersgap - a spacing sized for two cars in different lanes, not the same one. When the player's random slot landed adjacent to an opponent witha6 ≡ 0 (mod 3)(observed: player at slot 1/5, right after opponent 0), both cars ended up in the same lane with insufficient longitudinal clearance → spawn overlap → launch. - Fix: widened the distance gap specifically on both sides of the player's slot (
(minDist+maxDist)to(minDist+maxDist)*2instead of the normalminDist..maxDist) inHook_StreetRaceGridPlace(opponent_substitution.h) - guarantees longitudinal separation alone prevents overlap regardless of which lane either car ends up in, without touching the opponent lane-cycle math at all. - Fix verified live: rebuilt, reinstalled, replayed the same event repeatedly via "Начать заново" until the RNG reproduced the same collision-prone case (player slot 1/5, immediately after opponent index 0) - car spawned cleanly, no interpenetration, raced normally (999 km/h, 1st place, car visibly undamaged). Confirmed across multiple restarts landing on different slots (5/5, 5/5, 1/5), all clean.
- Cop-spawn scheduler test - still inconclusive: navigated to a Blacklist daily challenge ("Нижняя ступенька", MOST WANTED #10, Reynolds Lane) expecting a pursuit context. Turned out to be a straight 1v1 no-rules race ("гонка без правил один на один") with no wanted/heat meter UI visible - not actually a police-pursuit event in this game mode. Scheduler hook still never logged a single firing across three separate sessions now (original save, full save idle/driving, full save Blacklist race). Cop spawning almost certainly requires a genuine wanted-level/pursuit state that hasn't been triggered yet by any tested event type.
- Session wrap-up: all four test flags (
g_enableBlanketOpponentSubstitutionTest,g_enableRandomPlayerGridPositionTest,g_enableCopSpawnSkipTest,g_enableTrafficCarCountZeroTest) reverted tofalse, rebuilt, reinstalled - device left in a safe, normal-play state. - Snapshot:
opponent_substitution.h'sHook_StreetRaceGridPlacegets the widened-gap fix (with an explanatory comment on the root cause, not just the fix). No other file changes this entry. - Outcome/Next: subtask 2's random-grid-position hook is now collision-safe as well as functionally confirmed - this was a real, user-caught bug in previously-"confirmed live" code, a good reminder that "no crash" and "visually looks right in a screenshot" aren't the same as "physically correct," especially for anything touching placement/transforms. Cop-spawn scheduler confirmation remains the one loose end from subtask 2.4 - next attempt should specifically look for an event type with a visible wanted/heat meter (a genuine police pursuit, not just a "Most Wanted"-branded leaderboard race) rather than assuming any MW-themed event implies cops.
2026-08-26 (cont. 74) — Cop-spawn scheduler's real two-path shape found and fixed (live roadblock A/B proof); ambient-traffic spawner found, partially chokeable
- What: User reported live, mid-race, that they'd seen a police car during the very race that also produced cont.73's grid-collision bug - directly contradicting the standing belief that cops never spawn in a plain street race. Explicit instruction: keep digging, work as long as possible, open other investigation branches too. Pure autonomous continuation, no further user input during this entry.
- Cop-spawn scheduler - real shape found: fully decompiled
sub_F8078(previously unexamined) and its own caller, and found the piece cont.70's original hook missed -sub_F5EA4is a per-tick dispatcher, itself only called fromsub_F5BB4(CopManager's broader per-tickUpdate, which also runs unrelated bust-timer/etc. bookkeeping - too broad to skip wholesale).sub_F5EA4branches on a live byte flag (*(a1+4036)): true →sub_F7E9C(the only leaf cont.70 had hooked), false →sub_F8078(a second, previously-unhooked leaf - a distance-sorted-candidate-list scheduler that can check/spawn multiple cops per tick). Both leaves call the sameSpawnCopCar/sub_F85B8(confirmed viaxrefs_to- exactly 2 callers, both now covered) - hooking onlysub_F7E9Cleftsub_F8078's branch completely open, which is almost certainly why cops were showing up live with the old hook enabled and 0 log lines from it (all spawn activity was going through the un-hooked branch the whole time). - Fix: moved the hook from
sub_F7E9Cto the shared dispatchersub_F5EA4- one hook now covers both leaves, without touchingsub_F5BB4's other per-tick cop maintenance. - Live A/B proof, unambiguous: replayed the exact same event ("Битва на шоссе", Reynolds Lane) three times. Flag off: drove through and hit a full police roadblock mid-race - multiple marked "POLICE" cars, light bars, cops standing in the road, a stop sign - screenshotted, completely real, on a plain street race with no "Most Wanted" branding. Flag on (new dispatcher hook): replayed the same event, dispatcher hook fired 7 times in a tight burst right at the same point in the route (logcat), and the road was completely clear at the equivalent location - no roadblock, no cops, clean pass-through. This is the strongest live confirmation of any hook this session - a real visual before/after on the exact same content.
- Ambient traffic - real spawner found, only partially chokeable: cont.71's
TrafficCarCount=0field-write hook was independently re-tested live (drove the actual race, not just checked logcat) and civilian traffic (a red pickup, a blue sedan) was still clearly present - the field write was real but doesn't gate ambient traffic at all. Traced the actual spawner:sub_33D734isTrafficCarSpawner's populate function (own assert string confirms the class name), called twice per race fromsub_33C020(once per traffic direction/lane-group), with its max-count parameter read from a completely different object (*(a1[3]+16)/*(a1[3]+20)) thanRaceEvent.TrafficCarCount. Hookedsub_33D734directly, forcing that max-count parameter to 0 - but the underlying loop has ado { ... } whileshape that unconditionally builds at least one traffic-car candidate per track-authored waypoint/road-node, regardless of the max-count parameter - so this hook reduces traffic to a structural minimum (confirmed live: maxCount 1→0 and 2→0 logged, but the same red pickup was still present, i.e., real, meaningful reduction from cont.71's untouched baseline, not full elimination).sub_33C020(which builds this candidate list) is itself called exactly once at race setup (sub_2A8CE8, confirmed viaxrefs_to- not a per-tick call), meaning the candidate list this builds is very likely the race's entire traffic roster, not a queue refilled over time - the remaining ~1-2 residual cars per race are the honest floor of this specific hook, not a sign it's not working. Full elimination needs either the track-waypoint-count source (harder, track-authored) or the actual world-instantiation consumer of this candidate list (not yet found) - left as an open, accurately-scoped item, not silently claimed as solved. - Session pattern: this whole entry followed the same "trust but verify" shape twice in a row - a previous session's "confirmed live" claim (traffic - only checked via logcat before) and a previous session's "installs cleanly" claim (cops - never live-tested during actual driving/pursuit) both turned out to be incomplete once actually driven and watched, not just logged. Both fixes came from decompiling one level deeper than the first pass did (
sub_F8078/sub_F5EA4for cops,sub_33D734/sub_33C020for traffic), not from new tooling or a different approach - the lesson is to keep pulling the caller thread until every path converges on the actual engine primitive (SpawnCopCar, the traffic candidate list), not stop at the first plausible-looking gate function. - Snapshot:
cop_traffic_disable.h-Hook_CopSpawnScheduler/InstallCopSpawnSchedulerSkipHookrenamed toHook_CopSpawnDispatcher/targetsub_F5EA4(wassub_F7E9C); newHook_TrafficCarSpawnerPopulate/InstallTrafficCarSpawnerZeroHookhookingsub_33D734, gated by newg_enableTrafficSpawnerZeroTest.opponent_substitution.h'sHook_StreetRaceGridPlaceunchanged this entry (already fixed in cont.73). All test flags reverted tofalseand a final clean build reinstalled before ending the session. - Outcome/Next: subtask 2.4's cop half is now genuinely confirmed live with strong before/after proof - effectively done. Its traffic half is honestly partial: meaningfully improved, not solved - next session should look for the candidate list's world-instantiation consumer (search around
TrafficCarSpawner's owning object - the one built bysub_2A8CE8/sub_33C020- for a per-tick method reading thea1[28]/a1[30]handle slotssub_33C020writes) rather than re-deriving what's already been found here. With subtask 2 now covering grid, opponent substitution, cop-spawning (fully) and traffic (mostly), the accumulated fact base is about as solid as it's going to get without a real lobby data layer - subtask 4 (coordinate/position sync) is the next large deliberately-deferred item per the user's own cont.68-era reasoning, and remains the natural next branch once this session's remaining budget allows.
2026-08-26 (cont. 75) — Civilian traffic fully eliminated, CONFIRMED LIVE over a 2+ minute drive
- What: Direct continuation of cont.74, explicit user instruction: keep digging into traffic specifically, goal is literally zero cars on the road.
sub_33D734's do-while structural-minimum fix from cont.74 was the starting point - not good enough on its own, so this entry traces one level further to where a traffic candidate actually becomes a positioned, visible actor. - Found
sub_C26A0(CarReset): own assert strings confirm "foundTrackInfo"/"Reset". Resolves a spline distance + lateral offset to a world position and writes it via the samesub_10B09C/sub_D5138transform-write pair already known fromPlaceCar(subtask 2.2) andSpawnCopCar(subtask 2.4) - the actual moment any car's position becomes real, third confirmed user of that pair. Exactly two callers, found viaxrefs_to:sub_2A0470andsub_C201C. - First attempt (disproven live):
sub_2A0470is registered as the handler for a"ResetLine"event viasub_31AAEC(the same event-subscription idiomSpawnCopCaruses for its own"EndOfTrack"event) - looked like the natural "recycle/reposition a traffic car" hook point. Hooked it, live-tested by actually driving (not just checking logcat) - the hook's own unconditional diagnostic log never fired once across a full ~1 minute race replay, yet the same red pickup truck from cont.74 was clearly visible and moving in a screenshot taken mid-race. Conclusion:sub_2A0470isn't on the path that keeps an ordinary traffic car on the road during a normal-length race (possibly only relevant for much longer sessions - it did fire once, later, in the successful longer test below). Left in place as a secondary hook, not the fix. - Real fix:
sub_C201C, the traffic car's own per-tick controller (second parameter carries a delta-time value,*a2 * -0.001pattern). Callssub_C26A0twice: once when an idle/wander timer at a fixed offset counts down to zero (computing a fresh position), once to retry a previously-deferred reset stored in fieldssub_C26A0itself writes on its own "can't resolve position yet, queue it" fallback path. Distinct field layout fromPlaceCar/Opponent/cops - genuinely a dedicated traffic-car component, not a generic car utility that opponents or the player also route through. Hooked to skip its body wholesale (g_enableTrafficControllerSkipTest). - Live-confirmed, unambiguous: replayed the same "Битва на шоссе"/Reynolds Lane event that reliably showed a red pickup (tunnel, ~14s in) and a blue sedan (later straight) in every prior test this session. With the
sub_C201Cskip hook on: completely empty road for the full drive - checked at the tunnel (previously always had the pickup - gone), and again after driving over 2 minutes total (previously always had at least the blue sedan - still gone). No crash; the hook fired continuously as expected for a per-tick function (~50+ times, hit its own log budget cap almost immediately). Thesub_2A0470hook also fired once, late in this longer drive, consistent with it being a genuine but rarely-needed secondary recycle path - both hooks installed together caused no conflict. - Snapshot:
cop_traffic_disable.h- newHook_TrafficCarControllerTick/InstallTrafficCarControllerSkipHookonsub_C201C(g_enableTrafficControllerSkipTest), newHook_TrafficResetLineHandler/InstallTrafficResetLineSkipHookonsub_2A0470(g_enableTrafficResetLineSkipTest, kept as a harmless secondary hook despite not being the primary fix).main.cppinstalls both unconditionally (behavior gated by the flags). All traffic/cop test flags reverted tofalseand a final clean build reinstalled before ending the session. - Outcome: subtask 2.4 is now fully solved and live-confirmed on both halves - cops (cont.74) and traffic (this entry). Both use the same underlying pattern this project keeps rediscovering: find the actual per-tick controller/dispatcher and skip it wholesale, rather than trying to starve a data-driven candidate list that has its own unrelated structural minimum. Subtask 2 as a whole is now about as complete as it can get without a real lobby/session data layer - the natural next major branch is subtask 4 (coordinate/position sync), deliberately deferred since the original cont.68-era planning discussion.
2026-08-27 (cont. 76-78) — Live demo with real car IDs; a real grid-collision regression found and understood; deep dive into a pre-existing TriggerOpenCarSelectOnDemand crash (one real bug fixed, root cause still open)
- cont.76 — user-directed live demo: ran subtask 2's full stack together with a user-specified roster (
marussia_b2_2011_desc,lamborghini_gallardo_lp570_4_superleggera_2011_desc,srt_viper_2013_desc,lamborghini_aventador_lp700_4_2011_desc) and the player inbmw_m3_e46_gtr_2001_desc, sourced from unpacked game data files rather than binary strings (user's own correction - car IDs live in game data, not.sostrings). Confirmed live on a fresh, previously-untested track: correct 4-car roster cycling into the fixed 5-opponent-slot structure, randomized grid position, player driving the requested car. User caught a real regression live: cars still spawned overlapping at the grid on this run, and traffic/cops were still visible - contradicting cont.73's "fixed" claim and cont.74/75's "confirmed zero" claims. Root cause for the opponent-count question (user flagged as "critical for multiplayer"): a regular race's AI slot count is genuinely data-driven, read from theRaceEvent's own"Opponents"property (sub_2B649Ccallssub_4F99F0(..., "Opponents")then sizes the loop from the resolved range) - not hardcoded engine-side as earlier sessions assumed from observing "always 5" across every sampled event. Every regular event happening to author exactly 5 was coincidence of content, not an engine constant - changing the count (not just car identity) would need to either truncateOpponentCollection's own built vector or find where the grid's separately-built vector derives its count, neither done yet. - cont.77 — the collision/traffic regression, root-caused and fixed: the
sub_C201Ctraffic-controller skip hook (cont.75's "confirmed zero traffic" fix) turned out to be track-dependent, not universal - it blocks repositioning but not a traffic car's first position, which on the untested track apparently defaulted to somewhere at/near the grid instead of harmlessly off to the side as on Reynolds Lane. Real fix: hooksub_C26A0(CarReset) directly with a once-per-object gate (Hook_CarReset,g_enableTrafficOnceOnlyResetTest) - let each distinct traffic-car pointer through exactly one real,TrackNavigator-resolved placement (same system used for opponents/cops, never intentionally at the grid), then no-op every subsequent call for that same object. Net effect: each traffic car appears once, parked, never recycled - live-tested clean afterward.opponent_substitution.h's player/opponent grid-gap widening (cont.73) was re-confirmed intact and not the cause of the user-observed collision. - cont.78 — user asked to switch to a fully synthetic test (fire the project's own long-standing
"LAN: Test Lobby"synthetic event via the existing debug broadcast receivers,adb shell am broadcast -a nfs.mod.mpcore.TEST_OPEN_CARSELECT, instead of navigating the real map) to get a track-agnostic, repeatable harness. This immediately hit a pre-existing, 100%-reproducible native crash (SIGSEGV,fault addr 0x8, null pointer dereference) every single timeTriggerOpenCarSelectOnDemand()'sFireEventOutput()call fired - not something this session's work introduced. Investigated in depth:- Ruled out, with live A/B tests: save-state size (crashed identically on both the 100%-save and the small original save, swapped via the established backup/swap workflow), and every one of this session's own test hooks (crashed identically with all of them - grid, traffic, cops, substitution - forced off).
- One real bug found and fixed:
carSelectTestReceiver/trueDirectCarSelectTestReceiverinGameActivityMain.ktcalled straight into JNI fromBroadcastReceiver.onReceive()(the Android main thread) - the only call sites in that file that skipped thegameGLSurfaceView.queueEvent { }wrapping every other native-touching call site there correctly uses to marshal onto the GL/render thread, whereMapScreen/FlowNodestate actually lives. Fixed by wrapping both inqueueEvent { }. Confirmed via tombstone that the crash's thread changed from the main thread to the correctGLThreadafter the fix - a genuine, real thread-safety bug, worth keeping fixed regardless of the deeper issue below. - Deeper root cause, only partially resolved: even on the correct thread, the crash persists, entirely inside
FireEventOutput(sub_17A99C) itself, before it ever reaches the real event dispatch (sub_1BB59C/sub_1581A0- confirmed by the complete absence of the pre-existingHook_FireOutputDiag'soutput="EVENT"log line across every crash).FireEventOutputbuilds a debug label ("Database of <Type> prefabs has no entry for component on actor <id>" / "...has no entry with ID <id>" - two related but distinct prefab-lookup-and-error-message functions,sub_240548andsub_173350) keyed on the numeric event ID before ever touching the real FlowNode - and this project's synthetic key family (0xC0FFEE00,0xC0FFEE01, ...) is negative as a signed int32 (top bit set), which a live-captured Android systrace line ("EVENT ID <garbage>") and a captured malformed eastl string range ({begin=<valid>, end=0}) both point at as mishandled somewhere in this number→string/lookup chain. A defensive repair in a new diagnostic hook (Hook_PrefabLookupDiagonsub_240548, collapsing a corruptend<beginrange to an empty string) did not stop the crash - the tombstone's PC offset and register pattern were byte-identical before and after, meaning the real fault is elsewhere in this same call chain (most likelysub_173350, the twin lookup/error-builder one level up thatsub_240294also calls, not yet independently hooked/instrumented). Traced as far as:sub_173350's own not-found path unconditionally zeros its 3-word output just before returning - if that's what's reachingsub_240294/sub_240548as theira2, a downstream user of that zeroed triple is the more likely actual crash site than anything insidesub_240548itself; not yet confirmed with a hook onsub_173350's own entry/exit. - Practical read: this crash is a standing bug in
FireEventOutput's own diagnostic-label path when fired with a numeric key that was never registered as a real prefab ID - completely independent of anything else in this project, latent since the0xC0FFEE00synthetic-key family was first chosen (cont.19-ish era), never triggered before because nothing had calledFireEventOutputthis way (out-of-band, via broadcast) until this session's synthetic-test push.
- Snapshot:
lan_event_injection.hgainsHook_PrefabLookupDiag/InstallPrefabLookupDiagHookonsub_240548(diagnostic + partial defensive repair, installed unconditionally, low log budget - safe to leave in, does not by itself resolve the crash).GameActivityMain.kt's two debugBroadcastReceivers now correctly wrapped inqueueEvent { }(real fix, keep).cop_traffic_disable.hgainsHook_CarReset/InstallCarResetOnceOnlyHookonsub_C26A0(g_enableTrafficOnceOnlyResetTest) as the corrected traffic fix, superseding cont.75'ssub_C201C-only approach (kept installed alongside it, harmless).opponent_substitution.h's roster updated to the user's four real car IDs. Flag state was mid-investigation when this entry was written and was not swept back to the standard all-falsesafe baseline before the session paused - next session must explicitly checkg_enableCopSpawnSkipTest/g_enableTrafficOnceOnlyResetTest/g_enableBlanketOpponentSubstitutionTest/g_enableRandomPlayerGridPositionTestincop_traffic_disable.h/opponent_substitution.hand revert any lefttruebefore normal play, rather than assuming the usual end-of-session cleanup already happened. - Outcome/Next: the synthetic-LAN on-demand-car-select harness (
TEST_OPEN_CARSELECT/TEST_TRUE_DIRECT_CARSELECT) is not currently usable untilsub_173350's own entry is instrumented the same waysub_240548's was, to find the actual zeroed/corrupt value reaching the real crash site - the next session should start there rather than re-deriving the thread-safety or save-state findings above, both already conclusively ruled out/fixed. The real-map-navigation path (car select → loadout → race) remains fully working and is the fallback for any live demo that doesn't specifically need the synthetic-event harness.
Parallel work: native ARM32→ARM64 translation (separate effort, separate repo)
The user reports that, in parallel with this session, they made progress in a different chat on the native ARM32-on-ARM64 CPU-level translation approach this project's ARM64_TRANSLATION_LAYER.md describes (Unicorn-Engine-based in-process JIT translation of libapp.so, API-boundary shims for libc/JNI/GLES/FMOD, mpcore itself becoming native ARM64 code driving the embedded ARM32 core - see that file for the full design). Per the memory record, that effort ("arm64-poc") had already reached real GLES rendering and confirmed-audible FMOD audio as of its own most recent milestones - this session doesn't have direct visibility into what specifically changed in the other chat. The user intends to start publishing that project's sources to a private repository going forward. Per the existing Repo merge pending note, launcher (this repo) and arm64-poc are understood to be one project on two branches with a merge planned once a private remote exists - this may now be imminent. Nothing in this repository was changed for this; recorded here as a pointer for continuity until the two branches/histories are actually reconciled.