Files
nfsmw-online/docs/PROGRESS.md
T
megboyzzandClaude e7c76fc2dd docs: bring 1.8 MB of project documentation under version control
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>
2026-09-22 23:29:12 +03:00

461 KiB
Raw Blame History

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.json into ANALYSIS.md.
    • Opened native_lib/libapp.so.i64 via 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/mpcore draft (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.md with 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.
  • 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.so itself is untouched):
    • sub_5640ECBitmapGraphics_ctor_jni (0x5640ec)
    • sub_5625A0BitmapGraphics_drawString_jni (0x5625a0)
    • sub_56274CBitmapGraphics_drawString_thunk (0x56274c)
    • sub_562A58BitmapGraphics_createPaintFromFamilyName_jni (0x562a58)
    • sub_562B94BitmapGraphics_createPaintFromFile_jni (0x562b94)
    • sub_563D30BitmapGraphics_blitBitmapToAtlasTexture (0x563d30)
    • sub_51145CResolveFontPaint_ttfOtfOrFamily (0x51145c) Also added two explanatory comments (at 0x563d30 and 0x5625a0) describing the native↔Java text/atlas pipeline. Saved via idb_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 — see launcher/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 in ARCHITECTURE.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 the rename tool's success response (7/7 ok); idb_save confirmed write to native_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 at 0xd86210 (19 slots) by walking its RTTI chain (name string → type_info struct → find_bytes for who embeds that type_info address). Renamed 8 functions in native_lib/libapp.so.i64:
    • sub_2D8E04RaceLoaderTask_dtor_complete (0x2d8e04)
    • sub_2D8F18RaceLoaderTask_dtor_deleting (0x2d8f18)
    • sub_2DBBA4RaceLoaderTask_ExecuteLoadSequence (0x2dbba4) — main load orchestrator
    • sub_2DB160RaceLoaderTask_ResolveDriverPositionComponent (0x2db160)
    • sub_2D9D10RaceLoaderTask_SetupPlayerCar (0x2d9d10)
    • sub_2DAE0CRaceLoaderTask_SetupOpponentCar (0x2dae0c)
    • sub_2DA2A0RaceLoaderTask_ResetStartingLine (0x2da2a0)
    • sub_2DA880RaceLoaderTask_HandleSpikeStrip (0x2da880) Added a full vtable-layout comment at 0xd86210 and a detailed comment at 0x2dbba4. Saved via idb_save. Full table with evidence for every slot (including unconfirmed ones, left un-renamed) is in ANALYSIS.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: rename tool reported 8/8 ok; role assignments backed by decompiled evidence (destructor pattern, literal progress-fraction floats, string refs "playerCar"/"opponentCar"/"ResetLine"/"Spike Strip", and dynamic_cast RTTI 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 in native_lib/libapp.so.i64 (2 left un-renamed, evidence too thin):
    • sub_2DB384RaceLoaderTask_BuildTrackScenePath (0x2db384) — the subtask-1 hook point, builds the track prefab path from a single string field
    • sub_2DBF58GenericLoadScene_trackEnvWrapper (0x2dbf58)
    • sub_2DA710RaceLoaderTask_SetupAIDifficultyProfiles (0x2da710)
    • sub_2DAA7CRaceLoaderTask_TagPlayerEntity (0x2daa7c)
    • sub_2D994CRaceLoaderTask_RegisterTrafficFlow (0x2d994c)
    • sub_2DB534RaceLoaderTask_LoadRaceFSM (0x2db534) — loads the race rules/state-machine prefab, resolves the im::app::race::Race component, confirms im::app::car::Health exists in this binary
    • sub_2D969CRaceLoaderTask_DispatchInitialFSMEvents (0x2d969c) Added detailed comments on all 4 most important ones. Saved via idb_save. Full breakdown with per-stage evidence in ANALYSIS.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.sb suffix, directly matching the game_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 matching game_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 0x24a1f4 hot lead from the previous session. Confirmed a real, literally-named class TrackTestLayer exists and is fully functional in this binary. Renamed 5 functions in native_lib/libapp.so.i64:
    • sub_24A1F4TrackTestLayer_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 to TrackNavigator, logs telemetry to CSV
    • sub_24AEC4TrackTestLayer_factory_wrapper (0x24aec4)
    • sub_243160TrackTestLayer_QABatchRunner_tick (0x243160) — auto-cycles through up to 15 tracks from a runtime table
    • sub_2217ECDebugTestHarness_DispatchByName (0x2217ec) — dispatches a mode-name string ("Track/...", "Performance/...", "CarPreview/...", etc., default "MainMenu") to construct the matching test layer
    • sub_CF904ResourceDirListeners_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 via idb_save. Full writeup in ANALYSIS.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, bypassing RaceLoaderTask entirely.
  • 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 confirm BitmapGraphics/RaceLoaderTask earlier). Car IDs and category-prefix strings are literal, unambiguous string references in the decompiled code. Not yet traced: where the modeName string passed to DebugTestHarness_DispatchByName actually originates at runtime — flagged as the next concrete step in ANALYSIS.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 to DebugTestHarness_DispatchByName. Found its one caller, the app bootstrap function sub_DE180, renamed to AppBootstrap_flowInitOrStartupRaceCheck (0xde180), which also independently checks a config key "flow" against literal "STARTUP_RACE". Unpacked and searched all 8 files in game_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) via NFSMW12MobileTools, plus the reverse-engineered launcher Java sources — neither the +292 field's value nor the "flow"/"STARTUP_RACE" key/value pair appear anywhere in shipped assets. Added comments explaining the trace and conclusion. Saved via idb_save. Full writeup in ANALYSIS.md §6e.
    • Bonus: debug_options.sb turned out to be a full, directly-editable in-game debug-menu tree (unpack/edit-JSON/repack via NFSMW12MobileTools, 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.
  • Why: Direct follow-up to the previous session's TrackTestLayer finding — 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_unpack scratch 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 grep across 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 in ANALYSIS.md §6e, alongside the recommended workaround (call TrackTestLayer_ctor directly 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: added try_load_track_test_layer(), spawned from JNI_OnLoad via a detached std::thread (15s delay), calling TrackTestLayer_ctor (0x24a1f4) directly with a manually-constructed path-string object for published/prefabs/tracks/region1_foothills_track3.scene.sb. Commented out the pre-existing raise(SIGSTOP) in JNI_OnLoad (it suspends the whole process, including the new thread — noted inline why, easily reverted). Also fixed an unrelated, pre-existing build break in launcher/devmenu/domain/build.gradle.kts (Kotlin/Java JVM-target mismatch, JDK 21 vs explicit sourceCompatibility 17) by pinning compileKotlin's jvmTarget to 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 on PATH and 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-installed com.ea.games.nfs13_mod — applicationId not changed, per the still-open question from the previous session).
    • Crash 1: sub_CF5F8 called with an assumed 2-arg "C-string assign" signature; its real signature is a 3-arg [begin,end) range constructor. Fixed by passing trackPath + strlen(trackPath) as the third argument. See ANALYSIS.md §6f for full detail, confirmed via decompiling sub_CF5F8 after 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_695A10sub_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). See ANALYSIS.md §6f.
  • Why: Direct, explicit user request to try this ("Попробуй вызвать TrackTestLayer через хук в mpcore") — validating whether the reverse-engineered TrackTestLayer constructor (§6d) can actually be driven from mpcore as designed for subtask 1.
  • Snapshot: Not applicable to native_lib/libapp.so (untouched, no binary patch — this is a runtime call from mpcore, not an on-disk modification). mpcore's own source changes are tracked in the launcher git repo as usual.
  • Verification: Verified via live on-device testing, not static analysis alone — adb logcat confirmed each stage's log line firing, and tombstone/DEBUG crash logs plus IDA lookup_funcs on the crash PCs confirmed the exact crashing function both times. Current state: the code in main.cpp as 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-reference game_cache/published/data/races/*.sb) before re-testing, per ANALYSIS.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.sb for a real TrackName (region4_chicago_track4). Unpacked that track's .scene.sb via NFSMW12MobileTools and confirmed "start"/"finish" actors exist and are listed directly in the scene's top-level "actors" DataIdsMap (not nested). Updated launcher/mpcore/src/main/cpp/main.cpp's trackPath to 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_33C284 is asynchronous and TrackTestLayer's normal usage polls across frames before the lookup, which a single direct constructor call can't replicate, or (b) TrackTestLayer depends 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. See ANALYSIS.md §6f for full detail and the resulting recommendation.
  • 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, mpcore source changes tracked in the launcher git repo.
  • Verification: Live on-device (WayDroid), same method as the previous two attempts (adb logcat + tombstone + IDA lookup_funcs on 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 on TrackTestLayer — either invest in tracing scene-load-completion state properly, or pivot subtask 1 to hooking RaceLoaderTask_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 the TrackTestLayer thread-spawn experiment (commented out, not deleted). Added a new ARM-mode inline hook (install_arm_inline_hook, hook_BuildTrackScenePath) targeting RaceLoaderTask_BuildTrackScenePath (0x2db384), installed unconditionally in JNI_OnLoad. The hook overwrites the RaceDefinition's track-name string field (this[8] + 72, a 3-word {begin,end,capacity} object, same layout as sub_CF5F8's strings) with a hardcoded override (region3_colorado_track2) before calling through to the original function via a trampoline. This does not reuse armhook.cpp's existing InstallHook — disassembly showed the target function is compiled in ARM mode (E92D41F0/E24DD040, classic 32-bit ARM encodings), while armhook.cpp's hook helpers are hardcoded for Thumb targets and would have corrupted this function. Wrote a separate, ARM-mode-correct 8-byte LDR PC,[PC,#-4] detour + trampoline instead (full technical detail in ANALYSIS.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 route shows only the local /24 subnet, confirmed by 100% packet loss pinging 8.8.8.8), and the boot log shows NIM_ERROR: No network connection during EA's online-services init — very likely blocking progress to the menu. Fixing this needs root inside the WayDroid container, which requires the user's sudo password (not available in this session, and not something to prompt for).
  • 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 straight TrackTestLayer crashes.
  • Snapshot: N/A — no native_lib/libapp.so patch (all changes are runtime, via mpcore); mpcore source changes tracked in the launcher git 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 + repeated ps/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.obb present), not under our build's com.ea.games.nfs13_na. Changed launcher/app/build.gradle.kts: applicationIdcom.ea.games.nfs13_mod (kept namespace as com.ea.games.nfs13_na since source still imports com.ea.games.nfs13_na.BuildConfig). First install attempt failed with INSTALL_FAILED_VERSION_DOWNGRADE (our versionCode=1 vs. the real game's 1003128, a confirmed OS-level fact about Android's installer). Fixed by setting versionCode = 1003128, versionName = "1.3.128" to match — adb install -r then 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 obb across the entire launcher source tree (Java/Kotlin/XML) → zero matches; find_regex for addAssetPath|mountObb|StorageManager|\.obb in libapp.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 in build.gradle.kts to state only the confirmed fact (avoids the installer downgrade block) and flag the OBB theory as unverified. Full detail in ANALYSIS.md §6h.
    • Rebuilt/reinstalled/relaunched under the corrected package+versionCode: hook still installs cleanly (mpcore_log confirmed), but the screen is still black after one onDrawFrame call (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.
  • 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.so patch; build.gradle.kts/main.cpp changes tracked in the launcher git repo.
  • Verification: INSTALL_FAILED_VERSION_DOWNGRADE → success after versionCode match is directly observed adb install behavior, not inferred. The OBB-lookup claim's correction is a genuine grep/find_regex negative result across the full source tree and the native binary, not a guess. Still open: how game_cache/published/... is actually located at runtime (candidates: AAssetManager reading the APK's own bundled assets/, or loose external-storage files via fopen from an untraced root path) — not resolved this session. Resolved empirically later the same day: see the 2026-08-01 entry below — the user's corrected launcher/native_lib combination mounts the OBB successfully (Mounting SKU: ... to /published in 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 the launcher/ project (now at /home/megboyzz/AndroidStudioProjects/NFSMostWanted128) partway through the previous session, but native_lib/libapp.so.i64 was never rebuilt. It kept opening without error and even reported survey_binary hash metadata matching the new on-disk file, which looked like proof it was current. Direct verification proved otherwise: read raw bytes straight from native_lib/libapp.so at offset 0x2db384 via 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/BL in the real file vs. the PUSH {R4-R8,LR}/SUB SP,#0x40 RaceLoaderTask_BuildTrackScenePath prologue the stale .i64 showed). 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.i64native_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.so fresh via idb_open (pointing at the .so directly, 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 0x2db384 in the fresh session now matches the raw file bytes exactly.
    • Ran survey_binary on 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 read Java_com_ea_ironmonkey_GameActivityMain_*, matching the renamed activity class; new engine-assertion strings and a libc++ __ndk1 namespace 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 from ANALYSIS.md §6a–§6h is now invalid. One refinement: TrackTestLayer's full namespace is im::app::layers::debug::TrackTestLayer, not just a bare class name as previously known.
  • 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 in ANALYSIS.md §6i.
  • Snapshot: The stale .i64 was 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. The mpcore ARM hook from §6g/§6h now targets a meaningless offset in this binary; the user has already moved main.cpp on from it to a fresh dl_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 RaceLoaderTask vtable at 0xaa78e8 (18 slots, was 0xd86210/19 slots in the old build), via RTTI name N2im3app4race14RaceLoaderTaskE at 0xa2f570type_info at 0xaa7938.
    • 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_ExecuteLoadSequence and found stage 1 (0x2a8424) references the exact strings "published/prefabs/tracks/" + ".scene.sb" — the BuildTrackScenePath equivalent (was 0x2db384). Renamed to RaceLoaderTask_BuildTrackScenePath.
    • Confirmed ARM-mode compilation at the new address too (0xE92D4FF0 = PUSH {R4-R11,LR}, cond E) — same as the old binary, so the ARM-mode inline-hook design from §6g is directly reusable, just retargeted.
    • Added append_comments at the vtable and key slots documenting evidence, and idb_saved.
    • Full detail, table of all 18 slots, and the specific strings/constants used as evidence: ANALYSIS.md §6j.
  • Why: Direct continuation of rebuilding the RE baseline after the stale-.i64 incident (previous entry) — RaceLoaderTask/BuildTrackScenePath is 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 — .i64 annotation only, libapp.so itself 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.cpp targeting 0x2a8424 (the design is proven from §6g, just needs the new address and a re-check of the track-name field's offset within the RaceDefinition-like struct, which may differ from the old binary's this[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 mpcore at process start (no libapp.so file on disk touched, memory-only, per this file's header rule):
    • Added Hook_BuildTrackScenePath + InstallBuildTrackScenePathHook to launcher/mpcore/src/main/cpp/main.cpp, targeting RaceLoaderTask_BuildTrackScenePath at libapp_base + 0x2a8424 (offset confirmed fresh this session in ANALYSIS.md §6j). 8-byte ARM-mode patch (LDR PC,[PC,#-4] + hook address) at function entry, mmap'd RWX trampoline relocates the 2 displaced PUSH/ADD instructions and jumps back to target+8 — same design proven in the (now-stale) old-binary test from §6g, just retargeted.
    • Hook overrides the track-name field (raceDefPtr+72/+76, where raceDefPtr = 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 — BuildTrackScenePath only reads these fields, never frees them).
    • Built via ./gradlew :app:assembleDebug (JDK 17), installed on the Pixel 6a (GrapheneOS, real ARM hardware — see reference-pixel6a-grapheneos-testing memory) via adb install -r, launched via the package's default-launcher intent (dismissing GrapheneOS's deprecated-32-bit-ABI dialog via dumpsys window/screenshot/tap, not by waiting), navigated the real menu UI (map → event → car select → confirm) to trigger an actual race load.
  • 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.so patch. main.cpp changes tracked in the launcher git 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 logged libapp_base and 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 (tombstone pc - libapp_base = 0x53a604) identified it as a generic transform/bounds getter (a1[14], i.e. byte offset 0x38, dereferenced on a NULL a1) 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): other RaceDefinition fields (checkpoints, opponents, starting grid — populated separately, per RaceLoaderTask_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.
  • 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 in ANALYSIS.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):
    1. 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, attempted platform- and gdbserver-mode attach to the live game process via adb 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), and adb shell/su round-trips became wildly inconsistent while locked (34ms to 300s+, no clear pattern) — most likely Android Doze/screen-off throttling. Confirmed via direct time adb shell echo timing 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; see ANALYSIS.md §6l for exact resumption notes (keep the device unlocked/awake next time).
    2. Log() diagnostics: added temporary logging to Hook_BuildTrackScenePath (launcher/mpcore/src/main/cpp/main.cpp) dumping the original (pre-override) track name, the raceDef+40/44 field, and — critically — *(raceDef+12)+48/52, the nested field that supplies the race-FSM prefab name (sub_2A9338/RaceLoaderTask_LoadRaceFSM builds "/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.
  • 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.so patch.
  • Verification / outcome: Live log on the real event (values captured before our override overwrites them):
    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)
    
    Confirms point_to_point_fsm_newintro is 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 substituted region3_colorado_track2 scene. A quick strings diff of both tracks' .scene.sb files 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 just strings).
  • Next: unpack both .scene.sb files via NFSMW12MobileTools and 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 in ANALYSIS.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.sb files via NFSMW12MobileTools.jar unpack (jar + lib/ + HCStructFileArray.json copied into a scratch working directory) and walked their actor DataIdsMap structures directly:
    • region1_foothills_track4.scene.sb (the real track behind "Петерсон стрит", per §6l's live-logged original track name): full unpack succeeded. Checkpoint container is named checkpoints_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 named timetrial_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 generic point_to_point_fsm_newintro race-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.sb via 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 substitutes region3_colorado_track2, which has no actor named checkpoints_timetrial_event_2 at all (its container is timetrial_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, in ANALYSIS.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_52A620 with 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 no colorado/ folder (only chicago/desert/foothills/garage/newyork), and prefabs/environments/ has only one un-numbered colorado.prefabs.sb where every other region ships numbered <region>1..6.prefabs.sb. Unpacked region3_colorado_track2.scene.sb and confirmed it references a loose .m3g model path under models/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 in launcher/mpcore/src/main/cpp/main.cpp to region4_chicago_track4 (a confirmed-shipped, confirmed-playable track per §6). First attempt used kOverrideEnvName = "chicago" — wrong, hit Could 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-named RaceLoaderTask_ResetStartingLine — a genuine missing-null-check engine bug (every other access in that function null-checks; this one doesn't). Added temporary diagnostics dumping raceDef+164/180/196/212 (m_StartLine/m_FinishLine/m_EndOfTrack/checkpoint-container name fields) and found m_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/chicago4 substituted in.
  • 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.cpp now has only Hook_BuildTrackScenePath installed (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 via adb shell pidof staying 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/+212 to 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 in ANALYSIS.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..212 diagnostic Log() dump added earlier this session (its job was done — findings are recorded in ANALYSIS.md §6n). main.cpp now has only the clean Hook_BuildTrackScenePath override (track/env name repoint) and no defensive null-guards, no diagnostic logging. Then, per user request, temporarily commented out the InstallBuildTrackScenePathHook() 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.cpp end 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 single constexpr bool kEnableTrackSubstitutionHook in main.cpp, checked in JNI_OnLoad before installing the hook — flip the bool, rebuild, reinstall, no other edits needed. Done on a new branch (launcher repo, branch track-hook-toggle-flag, off master), committed as d8a1f34 — kept scoped to only mpcore/src/main/cpp/main.cpp (the launcher working tree had several pre-existing uncommitted/staged files from earlier unrelated work - .idea/*, AndroidManifest.xml, settings.gradle.kts, a new savepath.xml - left untouched, not part of this change). Also added ARCHITECTURE.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 from ANALYSIS.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: launcher branch track-hook-toggle-flag, commit d8a1f34. Not merged to master yet - 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 master or 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.sb files and flow/menus/map_overworld.sb via NFSMW12MobileTools to 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 through sub_8604A0sub_8598A4(path, mode) -> FILE*j_fopen → libc fopen.
  • 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_OnLoad in memory, without ever touching game_cache or 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's Location field 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 a j_fopen thunk with only 4 callers - a tight, single chokepoint for engine file opens. Hooking it and returning an fmemopen()-backed FILE* for specific virtual paths (falling through to the real fopen otherwise) would let any SB file's content be overridden purely in mpcore memory, with zero footprint on native_lib/game_cache/the OBB.
  • 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. an event_*.prefabs.sb) before generalizing. Full detail in ANALYSIS.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 on track-hook-toggle-flag - master is a stale, unrelated baseline, confirmed this session). Added Hook_sub_8598A4/InstallSub8598A4Hook to main.cpp (trampoline hook, same PUSH/SUB pattern as BuildTrackScenePath), logging every (path, mode) and substituting an fmemopen() buffer for any path containing "event_02_timetrial". Prepared the substitute payload via NFSMW12MobileTools: unpacked event_02_timetrial.prefabs.sb, edited CashReward.Gold/Silver/Bronze 10500/8500/700099999/88888/77777, repacked, embedded as a byte array (mpcore/src/main/cpp/test_event_02_data.h). Gated behind a new kEnableFileOpenHook toggle flag (same pattern as the existing kEnableTrackSubstitutionHook). 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 on event_02_timetrial specifically.
  • Snapshot: launcher branch file-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.cnf at startup). Wrong target function - sub_8598A4 was never called again after that one OpenSSL open; the "На время" event card still showed the original 10 500$/8 500$/7 000$, unchanged. Checked the other 2 named callers of the underlying j_fopen thunk: 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-file fopen calls at all - not yet located.
  • Next: trace the sub_3FB6E4 ("Mounting SKU") mount-table path or the 11 other open() (not fopen) call sites in the binary to find the real per-asset data-resolution function, then repoint the existing hook scaffold (trampoline + path-match + fmemopen substitution logic all reusable as-is) at it. Full detail in ANALYSIS.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_6753FCsub_4F0138 → a virtual call on a VFS singleton (sub_40E8E8(), vtable off_AB2084, confirmed via "VFS::AddVariant(" string) → resolved vtable slot+8 by reading raw bytes → sub_410808, confirmed as VFS::OpenInputStream via 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 (not fopen-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 found im::app::flow::nfs::MapScreen (map screen controller) and im::app::ui::MapTrack (likely the per-event marker object, appears as a boost::shared_ptr argument in one of MapScreen's methods). Also checked career.prefabs.sb as 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 as VFS::OpenInputStream is strongly evidenced (own literal strings + exact error-string match to a previously-observed live crash), not a guess. MapScreen/MapTrack are RTTI-confirmed class names, not yet confirmed as the actual injection point.
  • Next: find where MapScreen builds/holds its MapTrack collection and MapTrack's field layout, then apply the same "hook after real population, inject a synthetic entry" technique already proven for RaceDefinition fields. Full detail in ANALYSIS.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 function sub_1781BC (~8.7KB, largest function found this project) - confirmed it looks up a single, fixed-name "event_list" widget (via a generic FindOrCreateLayoutEntity<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 -> MapTrack dynamic_cast loops and a "TrackName" property-lookup inside the same function, but the exact call that matches RaceEvent.TrackName against a clicked pin's TrackId and 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.
  • 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 in ANALYSIS.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 ARM32 lldb-server (18.0.1) to /data/local/tmp/, matched with the same-NDK host lldb client (LD_LIBRARY_PATH pointing at the NDK's bundled python3/lib). Used direct gdbserver --attach <pid> *:5039 mode (not platform, avoiding the known zombie-child issue from 2026-08-04) via adb forward tcp:5039 tcp:5039, connected from the host with gdb-remote 127.0.0.1:5039.
  • Why: Direct continuation of §6q - static disassembly of the 8.7KB sub_1781BC stalled 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-server hits 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 leaves PC unresolvable for every thread (frame #0: 0xffffffffffffffff), matching the exact symptom documented on 2026-08-04. With a breakpoint set before continue, the client hangs indefinitely (breakpoint verification needs a working PC/register context that's already corrupted). Confirms this is a persistent bug in this specific lldb-server build (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-server route 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). Installed frida/frida-tools 17.17.0 into a fresh Python venv on the host, downloaded and pushed the matching frida-server (android-arm64, since the Fairphone 5's native ABI is arm64-v8a even though the target game process itself is armeabi-v7a) to /data/local/tmp/, ran it as root. Confirmed basic connectivity (frida-ps -U listed all device processes correctly). Attempted to attach to the running game process (both via -f spawn, which hit an unrelated Android ActivityManager start-timeout kill, and via -p <pid> attach to an already-running instance, launched normally via monkey).
  • 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.js reliably reports Failed to attach: agent connection closed unexpectedly, and logcat confirms why: the target process is killed by the kernel with signal 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-bit frida-server cross-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_1781BC as 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-server to a harmless system process (systemui, pid unrelated to the game) - hit the identical GetMaxU64 invalid byte_size! assertion and unresolvable-PC symptom seen against the game. This proved the bug is generic to the ARM32 lldb-server build itself, not anti-debug in the game. Then tested the NDK's AArch64 lldb-server against that same system process - worked perfectly (correct PC, disassembly, no assertion). Finally tested whether the AArch64 lldb-server can 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 breakpoint sub_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_1781BC stalled, 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-server distinction 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-server binary (not ARM32), pointed at the target process regardless of the target's own bitness - full recipe in ANALYSIS.md §6t. Next concrete step: breakpoint sub_17A4CC itself (the generic named-widget-lookup helper) and inspect r2/lr on 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 in ANALYSIS.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 traced sub_7D2E8's only 2 callers in the whole binary (sub_208C88 and a generic prefab-loader sub_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 lldb output) 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 explicit lldb error (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 (RaceEvent loaded 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_server32 remote 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 in ANALYSIS.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-multiarch without root (apt-get download + dpkg-deb -x, no sudo needed). It failed to talk to the existing AArch64 lldb-server at 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 legacy android-ndk-r16b (852MB - partial/range-based extraction against dl.google.com failed first, forcing the full download) to get a real, native ARM32 gdbserver (Google dropped gdbserver from the NDK after r17). Attached it natively (no cross-arch tricks needed) to the game process and connected with gdb-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-server combination.
  • Snapshot: N/A - debugging/tooling investigation only, no source or binary changes.
  • Verification: interrupt correctly stopped the live process and printed a genuine, correct backtrace (syscall() <- libart.so) - strictly better evidence of real control than either lldb path achieved. A breakpoint was accepted on j_malloc_0, but continue crashed gdbserver itself (game process unaffected, survived and kept running normally) - gdb reported Remote 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 continue past an installed breakpoint - a different failure (gdbserver process death, not a silent no-op) but the same practical result as the lldb attempts. Likely cause: ~7-year version skew between the 2017 gdbserver (last NDK release to include it) and the 2024 gdb-multiarch 15.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 in ANALYSIS.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, gdbserver crashing on continue) - 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) using func_profile+disasm windows (the plain callees tool returned an empty list for this function for unclear reasons — func_profile with include_lists: true worked 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: iterates map_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) into MapScreen+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 MapTrack pin carries a pre-set data pointer at +0xB8; card slots are a fixed pre-existing pool toggled by a locked flag) rather than a dynamic add-child call.
  • Snapshot: N/A — read-only static analysis, libapp.so untouched.
  • Verification: Static disassembly, cross-checked against xrefs_to on the MapTrack RTTI typeinfo (0xac6434) confirming only 3 functions in the whole binary ever dynamic_cast to MapTrack (sub_1781BC itself, sub_17C120 — unlock/tap-routing, strings "unlocking"/"NEXT_EVENT"/"RACE"/"BLACKMARKET" — and sub_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, unexamined dynamic_cast<MapTrack> site in sub_1781BC (~0x17a02c), find MapTrack's own constructor, and/or fully decompile sub_17C120. Full detail in ANALYSIS.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_cast to MapTrack (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 writes MapTrack+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-typed MapTrack* without needing dynamic_cast. Next: locate MapTrack's own constructor via its vtable symbol near _ZTIN2im3app2ui8MapTrackE (0xac6434) in .data.rel.ro, same technique already proven for RaceLoaderTask (§6a), then follow xrefs to that. Full detail in ANALYSIS.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) via entity_query on names near its RTTI typeinfo, then its real constructor (sub_368860, confirms +0xB8 starts 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 own TrackId, looks up matching RaceEvents in a registry, and calls sub_369AB0 = MapTrack::AddEvent(RaceEventHandle*) for each match — which push_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. RefreshEvents itself is triggered from MapTrack::HandleEvent (sub_368DFC) on the engine's FlowSetLayoutScreenEvent message — 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 via xrefs_to at each step (AddEvent has 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 the RaceEventHandle argument (looks like an ECS-style handle/ID, not a raw pointer — resolved through the same "component" indirection seen elsewhere in the binary, not a direct RaceEvent*). Full detail in ANALYSIS.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 from AddEvent) and sub_242778 (called from RefreshEvents, upstream). Found two independent, chained hash maps: TrackId string --[FNV-1a keyed]--> vector<uint32 hash> (sub_242778), then each hash --[int keyed]--> refcounted RaceEvent-prefab-instance ptr (sub_173350, same generic prefab-instance-cache system as sub_7CE58 from §6s). Confirmed AddEvent only calls the second (hash→instance) map directly — it never touches the TrackId registry itself.
  • Why: Needed the exact argument format MapTrack::AddEvent expects 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 fake RaceEvent-shaped object using the known SB field layout from §6p; (2) a direct call to MapTrack::AddEvent(existingPin, &chosenId). Remaining unknowns: exact in-memory shape of a loaded RaceEvent prefab instance, and safe construction details for splicing a new hash-map node at runtime. Full detail in ANALYSIS.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 cache AddEvent's handle resolves against, per §6w) and confirmed it stores sub_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/+20 fields. This means a resolved AddEvent handle always points at a real, fully-formed RaceEvent component 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 against sub_173350's read-side node layout (§6w) and sub_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 real RaceEvent-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 in ANALYSIS.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 with off_AA78B0). Fully decompiled sub_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, computed Zone@+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/CheckpointCollection offsets (164/180/196/212) cross-checked exactly against the raceDef+164/180/196/212 offsets 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 CashReward Gold/Silver/Bronze is now disproven — they're spawn-zone tuning floats, all defaulting to 1000.0. CashReward and 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_406644 consult when the UI reads class_restriction/event_type/Completion/CashReward by name, since those aren't raw fields of this struct. Full detail in ANALYSIS.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 same FindOrCreateLayoutEntity<T> pattern as sub_17A4CC (same "Unable to locate layout entity: " error string), specialized for im::scene2d_new::Text widgets. This means §6u Region A of sub_1781BC looks 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 and RaceType@+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_17A4CC exactly.
  • 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 in ANALYSIS.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 a Reward/RewardsCollection family. Constructor (sub_23E2C4) sets vtable off_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. Same Component base (sub_670454) as RaceEvent, via an intermediate Reward base 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 and Reward'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.
  • What: Found sub_164540 = Actor::GetComponent<CashReward>() (confirmed via "GetComponent" profiling string + generic component-list-iteration/dynamic_cast pattern) — its argument is an Actor*, the same object type sub_7D2E8 extracts RaceEvent from. This confirms RaceEvent and CashReward are independent sibling components on the same Actor, linked only via that Actor's component list — not via any direct field on either object. Also found CashReward'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 table RaceEvent'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.sb produces its own freshly-deserialized CashReward instance on its own Actor).
  • Snapshot: N/A — read-only static analysis.
  • Verification: Full Hex-Rays decompilation of both sub_164540 and sub_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_7D2E8 and sub_164540 resolve correctly against it exactly as for a real race. Full detail in ANALYSIS.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: hooking MapTrack::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-device TrackId values before hardcoding a target street, and the full fabrication sequence (RaceEvent ctor sub_2A4B58 + string-field append sub_7B524 + interned EventName via sub_406644, CashReward ctor sub_23E2C4, a minimal fake Actor exposing only actor[5]/[6]'s component array, cache-key insertion via the real engine primitive sub_7D638 rather than hand-rolled hash-map logic, then MapTrack::AddEvent sub_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 off master, 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 — full Actor layout 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-orig 7.11, also from NDK r16b, run with a local LD_LIBRARY_PATH symlink working around its legacy libncurses.so.5 dependency) instead of a modern gdb-multiarch. Set a software breakpoint on malloc — the exact same sanity check that never fired on the Fairphone 5 — and it fired correctly on the first continue, with a real PC and backtrace frame, then gdbserver detached 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.so untouched.
  • Verification: Direct, reproducible breakpoint hit with correct PC/backtrace; process survived cleanly after detach.
  • Notable side-findings: (1) gdbserver --attach pauses 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 standalone libapp.so, so /proc/PID/maps never shows a libapp.so-named entry — runtime load base has to be computed via the APK's zip data-offset for libapp.so matched against a /proc/PID/maps file-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/AddEvent call 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 in ANALYSIS.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 (existing dl_iterate_phdr-based code in main.cpp) actual runtime result against an independently, manually computed load address — read libapp_base's live value straight out of the already-running libmpcore.so (bundled in the installed APK, not stripped) via its own computed load address. Both methods gave the identical 0xb8798000. (2) Set a conditional breakpoint on MapTrack::HandleEvent (only stopping when the dispatched event type is really 1025/FlowSetLayoutScreenEvent), plus plain breakpoints on RefreshEvents and AddEvent, 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, with AddEvent firing 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/AddEvent chain 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 this pointers matching across the whole call chain (HandleEvent's this == RefreshEvents's this == AddEvent's this, 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 (AddEvent is 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 in ANALYSIS.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 from track-hook-toggle-flag rather than bare master (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 file launcher/mpcore/src/main/cpp/lan_event_injection.h hooks MapTrack::HandleEvent, and on FlowSetLayoutScreenEvent fabricates a RaceEvent+CashReward+fake Actor, inserts a cache entry, and calls the real MapTrack::AddEvent. Built and deployed to the Galaxy A9 multiple times, iterating on real crashes: (1) Actor+4 must be non-null ("actor has been deleted" check), (2) Actor+8 (a refcount) must start at 1, not 0, since AddEvent does 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 a MapTrack'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.cpp gained one include + one toggle flag + one JNI_OnLoad call. 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 ~12 MapTrack pins 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==1025 condition 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 in ANALYSIS.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 through sub_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 at 0x233b4c (only reachable via a stored function pointer, never called by name), which calls into 0x40602C. 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 in ANALYSIS.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 inside sub_406CAC, a generic "build a string from a C-string pointer" helper, faulting on its very first strlen-style scan when given a non-string pointer. Traced the caller: it's sub_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 via sub_242904, apparently a list of resource-path strings, not the same hash-table sub_7D638/sub_173350 operate 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 against sub_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_17C120 or force its flt_AD417C > 4.0 gate 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 in ANALYSIS.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/InstallSoakTestDisableHook to lan_event_injection.h - hooks sub_17C120 (MAPSCREEN_TICK_OFFSET, same trampoline pattern as the other two hooks) and forces the Soak Test's trigger accumulator (flt_AD417C, static offset 0xAD417C) to 0.0f immediately before every call to the original function, so its >4.0 threshold never trips and the feature never fires. Wired in via a new independent toggle flag kEnableSoakTestDisableHook in main.cpp, deliberately separate from kEnableLanEventInjectionHook so 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, pidof still 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.h gained the new hook; main.cpp gained one toggle flag + one JNI_OnLoad call. Still on branch lan-event-injection-poc, not yet committed.
  • Verification: 10-minute crash-free Monitor run (mpcore_log + *:F fatal logcat), pidof continuity 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/InstallMapTrackCtorTraceHook in lan_event_injection.h, gated by a new kEnableMapTrackCtorTraceHook flag) - rebuilt, installed, and read the result straight from logcat, which worked immediately and cleanly. Result: exactly 12 MapTrack constructor calls fire per app launch, all from the identical call site (lr=0xba03cb5c this run, static offset 0x395b5c) - matching exactly the ~12 pins already known to reach the HandleEvent hook, with zero extra. Traced that call site via IDA: it's sub_395B38, a tiny make_shared<MapTrack>()-style factory (malloc 0x118 + constructor call + shared_ptr control-block wrap), itself only referenced as a data function-pointer (never called directly) from sub_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.h gained the temporary Hook_MapTrackCtor/InstallMapTrackCtorTraceHook; main.cpp gained one toggle flag + one JNI_OnLoad call. Still on branch lan-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_395B38 is registered generically by type name in a factory registry rather than being a fixed per-instance loop, the actual count and identity of constructed MapTrack widgets is driven entirely by the map screen's layout/prefab data - however many nodes that data types as "MapTrack" (12, matching every pin the HandleEvent hook 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 MostWantedLeaderboard guess 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 screencap on the real device, tapping into two different pins' actual event-card screens.
  • Outcome/Next: Combined with cont.7's live count (exactly 12 MapTrack instances constructed, matching all reachable-via-HandleEvent pins with none missing), there is now no evidence the "3 special pins never fire HandleEvent" 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 the MapTrack-constructor logging hook (cont.7) and everything else touched this pass were already reverted/left clean on lan-event-injection-poc before 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_1781BC and the surrounding MapTrack/RefreshEvents static 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. Found sub_369040 (RefreshEvents) reads a {char* begin, char* end} vector at MapTrack+0xE0/+0xE4 of plain null-terminated event-group-name C-strings, populated at layout/prefab-bind time and available the moment HandleEvent(evtype=1025) first fires - independent of whether any RaceEvent has actually resolved yet (unlike the existing TrackName-from-first-event logging, which needs a resolved event). Implemented MAPTRACK_GROUPNAMEVEC_BEGIN/END_OFFSET, changed InjectSyntheticEvent to take an explicit eventName parameter (previously a fixed constant), and gated injection on strcmp(groupName, kTargetGroupName) == 0. Live-tested: this correctly injects into exactly one of the 12 reachable MapTrack widgets, by name, with zero effect on the other 11 - the targeting mechanism itself is proven sound. However, the 12 observed group names are all region{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 own groupName ("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.cpp on branch lan-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 (kTargetGroupName mode) and of the "inject into all 12, unique names" diagnostic mode; visual confirmation via adb screencap that 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 besides MapTrack::HandleEvent: sub_368FDC, a thin direct RefreshEvents(this) wrapper with zero static call-site xrefs anywhere in the binary - only reachable through a boost::_bi::bind_t<void, MapScreen, shared_ptr<MapTrack> const&> callback object (confirmed via its RTTI typeinfo). Added a temporary compiled hook on sub_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 hooked AddEvent itself (sub_369AB0, Hook_AddEvent/InstallAddEventTraceHook, filtering out our own synthetic 0xC0FFEE0*-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-initiated AddEvent calls fired this session, all from the exact same caller address, all targeting the same 12 region_*_track* widgets (5-6 events each) - zero calls for any visible street. This conclusively proves the visible/playable streets' events are not added via AddEvent at 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 earlier kTargetGroupName-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-target kTargetGroupName match) 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.cpp entirely via git checkout (its whole diff was the two diagnostic-hook toggle flags + JNI_OnLoad calls, nothing else). In lan_event_injection.h, removed Hook_AddEvent/InstallAddEventTraceHook (the AddEvent-level trace) and Hook_MapTrackDirectRefresh/InstallMapTrackDirectRefreshTraceHook (the sub_368FDC trace) 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 diagnostic snprintf). Kept everything that constitutes the actual deliverable from cont.9: MAPTRACK_GROUPNAMEVEC_BEGIN/END_OFFSET, kTargetGroupName, the groupName read/log inside Hook_MapTrackHandleEvent, and InjectSyntheticEvent's explicit eventName parameter (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.h on branch lan-event-injection-poc, main.cpp back to its last-committed state (ca55b2e). Net diff is now 72 insertions/31 deletions in one file, confirmed to ./gradlew :app:assembleDebug cleanly (BUILD SUCCESSFUL). Not yet committed.
  • Verification: git diff --stat reviewed 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::bind lead 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::bind callback-wiring angle. Along the way, re-verified (out of caution after noticing AddEvent's own internal dedup-check loop also reads MapTrack+0xE0/+0xE4, comparing elements as raw pointers rather than strings) that the MAPTRACK_GROUPNAMEVEC_BEGIN/END_OFFSET reading in RefreshEvents is still correctly interpreted (confirmed via a fresh full decompile of sub_369040: the while(*++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 for SaveGame/ISaveable/Profile class references and found a boost::_bi::bind_t<..., im::app::metagame::SaveGame, eastl::basic_string<...> const&> callback (a SaveGame method taking a slot-name string) and a large family of classes implementing im::app::metagame::ISaveable (AchievementManager, Autolog, CachedSpeedwall, CurrentState, MenuState, Options, Profile, ...). Enumerating the RTTI type-info table further (.data.rel.ro around 0xaa5700-0xaa6200) turned up im::app::metagame::Career, im::app::metagame::Progression, and critically im::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) that RefreshEvents/AddEvent use for the 12 region_*_track* slots. Checked Career's own RTTI base class: it's a plain single-inheritance im::components::Component (the same base RaceEvent/CashReward derive from) - i.e. Career is 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 the PrefabDatabase<Career>/Component base 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 generic AddEvent path this whole investigation (cont.3-cont.10) has been built around - which would fully explain why the AddEvent-level trace (cont.9, 62 genuine calls, zero for visible streets) never caught them. Not yet traced: Career's own vtable/methods, how a loaded Career prefab's data actually reaches a MapTrack widget's event vector (whether via a still-undiscovered call to the same AddEvent/MapTrack+0xF0 fields using a different key-resolution path, or via an entirely separate mechanism Career owns itself), and whether PrefabDatabase<Career>::Load(name, path) (the bound string-taking method glimpsed via the boost::bind signature) is the actual entry point invoked when the save loads. Next step: find Career's full vtable (same get_bytes technique used earlier for MapTrack's), and look for its constructor/factory xrefs the same way MapTrack's sub_395B38 factory was found via sub_38DA44's type-name registry - Career is 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 vtable off_AA5A58, mallocs 76 bytes, base im::components::Component ctor via sub_670454, zero-initializes two separate 3-word fields at +12/+16/+20 and +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 registry MapTrack was found registered in (cont.7's sub_38DA44/sub_395B38 pattern - same _cxa_guard-protected static-init + string-build + registration-call shape). Read Career's actual vtable content (10 entries, far smaller than MapTrack's ~60-entry one, consistent with a lightweight Component) 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 the Career+12 vector 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 inside sub_2A4D70, already-known from earlier sessions as RaceEvent's own field deserializer) and found it's resolved through sub_50D5B4 the same way RaceType/FinishLineNoSpawnZone are (a string-to-enum lookup with a coded default fallback) - i.e. Location is a category tag stored directly on each RaceEvent (like a region/city classification), not a live reference to a specific MapTrack widget.
  • 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_bytes on Career's installed vtable pointer; the "Cars"/"Name" property-read pattern and push-into-+12-vector behavior read directly from sub_23A5C4's Hex-Rays decompile, not inferred.
  • Outcome/Next: This tempers cont.11's optimism - Career looks like it's most plausibly the player's garage/owned-cars component (matching its PrefabDatabase<Career> + "Cars" property + a Career+12 resolved-car-list vector), not a per-street/track unlocking mechanism. The second zeroed vector at Career+28/+32/+36 is 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 a MapTrack widget for a currently-visible/playable street gets its event vector populated outside the AddEvent/RefreshEvents/sub_368FDC paths already ruled out in cont.9. Given the breadth already covered this session (gdb/frida live debugging, AddEvent-level tracing, boost::bind callback wiring, SaveGame/ISaveable/Career/Progression RTTI enumeration) without closing the loop, the next productive step is likely either (a) checking the unexplained Career+28/32/36 vector's populating code path, or (b) a live compiled-hook trace (proven reliable this session, see cont.7) on Career's constructor/the "Cars" method to empirically observe when/how many Career objects exist and correlate their this pointers 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. Found Career's GetComponent<Career>() (sub_25C9EC, confirmed via the same dynamic_cast(component_ptr, Component_typeinfo, Career_typeinfo, 0) shape already seen for RaceEvent/CashReward), traced its one caller sub_25C55C (confirmed as the generic PrefabDatabase<Career>::LoadOrGet(name) - hash-lookup-or-insert, same shape as the RaceEvent prefab cache), traced its one caller sub_25C404 (a thin .prefabs.sb-suffix-ensuring path-builder wrapper), and traced its callers to sub_25A4D4 - a callback-registration function that binds sub_25C404 under a property name into some type's schema via sub_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 for im::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 named Career prefab file on demand. This is a real, concrete architectural finding: the player's currently-active career is a single named Career prefab, loaded on demand via Progression'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 same sub_1738E8 push-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 a Career-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-Progression chain confirmed via direct decompile of every function in the xref chain, not inferred from naming alone; the sub_23B854 dead-end confirmed by literal decompile-output comparison (identical to sub_23A5C4).
  • Outcome/Next: Career+28/32/36's populator is still unfound by pure xref-chasing from Career's own vtable/nearby code. Given the architecture is now much clearer (Progression owns a lazily-loaded, single active Career prefab; Career itself is a small Component with 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-located Career-specific function reads into +28/32/36 (the "Cars"-shaped-duplicate dead end suggests brute-force xref chasing from sub_1738E8 won't find it directly - would need to search Career's surrounding code (0x239000-0x23C000ish) more broadly for any function referencing a1+28/+0x1C as 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) on Career's constructor and the "Cars"-loader, logging the resolved prefab name and the +28/32/36 vector'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 Career code cluster (0x239000-0x23C000, every quoted-string reference in that range, not just targeted guesses) instead of continuing to chase the +0x1C offset specifically (which had led to a dead-end generic component-resolution utility, sub_239F40, unrelated to Career's own field layout). Result: every single property name in this entire code region is car-related - "Cars", "Unlocked", "Purchased", "ColourIndex", "InstalledParts" (read by sub_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 the MapTrack/RaceEvent domain 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 the MapTrack/street-unlock system this whole investigation (cont.3-cont.14) has been trying to find. The Career+28/32/36 second 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 a MapTrack widget for a currently-visible/playable street gets its event vector populated outside the already-ruled-out AddEvent/RefreshEvents/sub_368FDC/Career paths - remains open after a very thorough multi-session investigation (live debugging infra fights, AddEvent-level tracing, boost::bind callback-wiring, and now the full SaveGame/ISaveable/Progression/Career RTTI family). Given the breadth already covered, the most promising unexplored angles for a future session are: (a) Progression itself (the singleton that owns the "career" property) may have its own separate street/region-unlock state independent of Career - its RTTI/vtable was never directly inspected, only inferred adjacent to Career's; or (b) pivot from static to the live compiled-hook technique (proven reliable all session, see cont.7) directly on MapTrack's constructor or AddEvent, filtering specifically for calls whose caller address (lr) is not the one known RefreshEvents call site (0x369210 internally) - since anything populating a visible street's event vector must call AddEvent from somewhere, and cont.9's trace only proved the 12 known calls share one lr, not that no other lr value 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 vptr off_AA5FA0) and its constructor (sub_258E0C, the same function already partially seen in cont.13 - confirmed it's a lazy-init ManagedSingleton<Progression>::Instance(), mallocs 80 bytes, single base class ManagedSingleton<Progression> only - i.e. Progression does not inherit ISaveable directly, unlike the AchievementManager/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 a boost::shared_ptr-shaped field at instance offset 76 via refcount decrement, nothing else) - i.e. Progression has essentially no custom virtual behavior beyond the base singleton/destructor. A broad string-literal scan of Progression'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, unlike Career'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/76 is a 3-word {begin,end,cap} vector, just like Career's" was likely wrong - the destructor's refcount-release pattern on offset 76 alone looks much more like a 2-word boost::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 via sub_171610(instance), which may be a separate property-schema/type-descriptor object rather than Progression'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 Career scan.
  • Outcome/Next: Progression is now also a dead end for this specific question via the techniques that worked for Career - 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_368FDC broadcast paths in cont.9, Career in cont.11-14, Progression in 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 (the sub_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 on AddEvent with a longer observation window and no lr-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 several cont. 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, no lr-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 Career and Progression as 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.cpp temporarily gained the hook again during this pass; both fully reverted back to the clean, committed lan-event-injection-poc state (git status empty) once the trace was complete - no net changes left in the working tree.
  • Verification: Live logcat capture across the whole interactive session - 186 genuine AddEvent calls 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-shifted lr values), all 186 sharing the exact same caller address and the exact same 12 region_*_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 AddEvent is 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 flagged sub_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_171610 in 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. exactly ManagedSingleton<Progression>::GetInstance(), reading the same global (dword_AD40A8) that Progression's own constructor (sub_258E0C, cont.13/15) sets. This resolves cont.15's flagged uncertainty: sub_258D2C's v3 = sub_171610(v2) genuinely operates on Progression's real 80-byte instance, not a separate schema-descriptor object as speculated - the earlier offset-based reasoning about Progression+68/72/76 was 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 from sub_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 that Career'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_AD40A8 identity match against Progression's own constructor confirmed by direct comparison against cont.13's decompile of sub_258E0C, not inferred from naming.
  • Outcome/Next: No new lead toward the visible-street mystery. sub_171610/sub_3E8DA0/sub_40E8E8 are exactly what they look like - a standard lazy-singleton + central property-registry pattern used pervasively across the im::app::metagame namespace 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 implicitly RaceEvent/CashReward/MapTrack themselves - 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 a MapTrack. That remains genuinely unlocated. This closes out the sub_171610 side-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 for MapTrack. Found the "TrackId" string exists exactly once in .rodata (0xa19218) with zero code cross-references to it - consistent with what RefreshEvents (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 with sub_3684CC's known implementation (an FNV-hash lookup into a per-instance dictionary stored on the calling object itself, not a query against sub_40E8E8's central registry), this confirms TrackId/Location are generic deserialized data fields in a per-object property dictionary populated whenever any prefab loads - not named handlers bound via sub_3E8DA0's RegisterHandler pattern 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 caller sub_7814C back 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 own lan_event_injection.h uses as GetCacheContext(mapTrackThis) + 320, now confirmed by name, not just by observed behavior), and then calls sub_1F19A0 (the large function from cont.15/16), which itself registers "published/prefabs/tracks/" (loader callback sub_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"/(inside sub_1F19A0) "published/prefabs/tracks/" strings and their exact byte offsets, not inferred.
  • Outcome/Next: Directly answers the user's question: TrackId/Location have 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 the sub_171610 side-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/ReadyState data 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 against alfaromeo_4c_2012_desc.prefabs.sb's real CarMod entries (TYRES_REINFLATING, CHASSIS_REINFORCED, BODY_IMPACT, CHASSIS_LIGHTWEIGHT, POWERTRAIN_POWERPACK, NITROUS_BURN, NITROUS_EARN) - not the separate im::app::car::CarPart/UpgradeParts catalog flagged in the prior session, which has no live instance data in any checked file and appears unused/vestigial in this build (no skipCost/orderTimeMinutes-style timer-gating fields exist on CarMod at all, consistent with the user's recollection that skipCost is always 0). This also supersedes an earlier guess that ModType/Category was 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/CarDescription struct schemas read directly from NFSMW12MobileTools-unpacked JSON.
  • Outcome/Next: LobbyPlayer.mods: [int|null, int|null] (two CarMod.ModType slots) added to the schema per user request, for cross-player visibility (not balance computation - client resolves name/icon from the car's own already-loaded Mods catalog). 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_eventgarage_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 (constructor sub_35E5D4, vtable off_AAAD54) registered by name in the same generic type-registry (sub_38DA44) previously used to find MapTrack's factory, via factory wrapper sub_395718. Traced its origin to game_cache/published/layouts/layouts.sb (a single monolithic UI-layout database, loaded wholesale at startup by sub_75E40/AppBootstrap), where it's the widget_class of a layout node named car_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 real FlowManager class): garage_pre_event.sb (screenId="RestrictedGarage", layout car_select_new, entered directly from BACK→/menus/map_overworld, i.e. this is what a map race-pin tap opens) transitions on CONTINUE to garage_pre_event_part2.sb (screenId="RestrictedGarageLoadout", layout car_select_loadout - confirmed to be the exact 2-slot upgrade screen investigated in cont.19), which itself transitions on CONTINUE to /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: CarSelectionWidget RTTI strings and registry entry read directly via decompile/xref, not inferred; layout/flow node names and transition graph read directly from NFSMW12MobileTools-unpacked JSON of the actual shipped .sb files, not guessed.
  • Outcome/Next: This conclusively answers what needs to be invoked (the existing garage_pre_event/garage_pre_event_part2 Flow screens - no need to build a new UI or call CarSelectionWidget's constructor directly) and why it already looks right (it's the same screen normal races use, already class-filterable via car_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: find FlowManager's public transition/goto API (likely dispatched via named Outputs events like CONTINUE, 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, offset 0x395718, same trampoline pattern as the other hooks in main.cpp) meant to capture lr via __builtin_return_address(0) when CarSelectionWidget's factory runs, revealing the generic widget-dispatcher's address. Built, installed on the Pixel 6a, and confirmed via a continuous background adb logcat capture (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...", matching libapp_base(0xB9D84000) + 0x395718 exactly).
  • Live-navigated a fresh app launch through the real in-game flow (map → МАККЛЕЙН → ПОБУДКА → confirm race → confirm car) all the way to the car_select_loadout screen (2 empty upgrade slots, visually confirmed via screenshot) - the exact screen this hook should fire for if CarSelectionWidget is 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 via git checkout -- once the negative result was clear (git status --short empty on the lan-event-injection-poc branch) - 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/CarSelectionWidget as the widget backing the car_select_loadout screen is not confirmed live and is probably wrong - either the "CarSelectionWidget"/"car_select_loadout" property pairing found in layouts.sb belongs 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 identifying FlowManager'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: the garage_pre_event/garage_pre_event_part2//race/race Flow 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 trace FlowManager::Update (sub_777B8, confirmed by name) and its neighboring calls (sub_156530/sub_1571E0/sub_157058/sub_1577F4) directly, or to hook at the Outputs/CONTINUE dispatch level referenced by sub_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=1 system setting was causing a persistent "Защита от случайного касания" (accidental-touch-protection) guard overlay to intercept nearly every adb input tap, making UI automation extremely unreliable. Fixed via adb shell settings put system screen_off_pocket 0. Also useful: adb shell wm dismiss-keyguard reliably bypasses the lock screen without needing swipe-gesture coordinates when deviceLocked=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): caches JavaVM*/a global ref to nfs.mod.mpcore.GameEvents during JNI_OnLoad (classloader-safe - FindClass only ever called from the properly-scoped JNI_OnLoad thread, never from an arbitrary later-attached native thread), then exposes FireMapLoaded()/FireRaceStarted()/FireRaceEnded() which CallStaticVoidMethod into Kotlin, handling attach/detach for whichever native thread calls them.
    • launcher/mpcore/src/main/java/nfs/mod/mpcore/GameEvents.kt (new): GameEventListener interface (onMapLoaded/onRaceStarted/onRaceEnded, all default no-op) + GameEvents object as the register/dispatch hub, called from native via the @JvmStatic dispatch*() methods.
    • launcher/mpcore/src/main/java/nfs/mod/mpcore/GameInput.kt (new): a SyntheticInputDispatcher interface + GameInput.dispatcher holder - needed because mpcore is a separate Gradle module app depends on (not vice versa), so it can't reference GameActivityMain by type without a circular module dependency; GameActivityMain implements the interface and hands itself in instead.
    • GameActivityMain.kt: added dispatchSyntheticTap(x, y) - synthesizes ACTION_DOWN/ACTION_UP MotionEvents directly into gameGLSurfaceView.dispatchTouchEvent().
    • launcher/mpcore/src/main/cpp/lan_event_injection.h: Hook_MapTrackHandleEvent now calls FireMapLoaded() once (static bool guard) on the first EVENT_TYPE_FLOW_SET_LAYOUT_SCREEN callback - 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 a GameEventListener whose onMapLoaded() schedules 4 hardcoded taps (map pin, event card, confirm race, confirm car) 2s apart starting 3s after the map loads, via Handler.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 -> dispatchMapLoaded fired from the game's own GLThread -> all 4 taps dispatched -> dispatchTouchEvent returned consumed=true for every one) but nothing happened on screen. Root cause, found by reading GameGLSurfaceView.java: onTouchEvent() branches on event.getSource() (SOURCE_TOUCHSCREEN/etc.) before forwarding to the native touch handler (nativeTouchScreenEvent/nativeTouchPadEvent) - a plain MotionEvent.obtain(...) defaults to SOURCE_UNKNOWN (0), matching neither branch, so the View layer "consumes" the event (returns true, satisfying dispatchTouchEvent'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 the ACTION_UP event).
  • 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 status on launcher shows the new/modified files, not yet committed.
  • Verification: Fully live-verified, twice - first run reproduced the SOURCE_UNKNOWN bug 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 the car_select_loadout screen (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 MapTrack widget 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 dynamic trackId (subtask/point 1) is wired up; (3) onMapLoaded's trigger (first MapTrack::HandleEvent call) is a coarse proxy, not a real screen-shown hook. onRaceStarted/onRaceEnded are scaffolded in GameEvents but have no real native trigger wired up yet - next candidates if pursued: the race-FSM load functions from RaceLoaderTask_LoadRaceFSM/RaceLoaderTask_DispatchInitialFSMEvents (already named, cont. from much earlier sessions) for onRaceStarted, and whatever shows the post-race results screen for onRaceEnded.

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-placeholder MapTracks, 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, ctor sub_1781BC, vtable _ZTVN2im3app4flow3nfs9MapScreenE/off_A9F420) registers a named layout entity "scroll" via sub_1332B8 (a generic FindOrCreateLayoutEntity(registry, name) - same shared Transform-based object family as MapTrack/CarSelectionWidget, base ctor sub_4D39D0), storing the raw entity pointer at MapScreen+0x1D0. Since this entity shares the same Transform layout (position floats at instance+36/+40, scale at +44/+48) already reverse-engineered from MapTrack'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 (InstallMapScreenCtorTraceHook in main.cpp) to capture the live MapScreen* into a new global (g_mapScreenInstance), plus a read of the "scroll" entity's position/scale in Hook_MapTrackHandleEvent (lan_event_injection.h). This reproducibly crashed the game (SIGSEGV, fault addr 0x0, 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-working onMapLoaded automated tap chain from cont.22 - stayed stable). Root cause not yet found: sub_1781BC decompiles 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_mapScreenInstance and the scroll-entity read in Hook_MapTrackHandleEvent are 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-enabled MapTrack rect 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 the sub_1781BC hook 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 live MapScreen* (e.g. hook a smaller method that takes MapScreen* this as an existing parameter, rather than its huge constructor - sub_1817A8/sub_18184C/sub_1818F0/sub_181A38 from the boost::bind setup code earlier in this investigation all reference MapScreen-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_18184C first - both decompile to pure boost::_bi::bind_t type-erasure "manager" functions (opcode-dispatched clone/destroy/typeid-compare/get-typeinfo on the wrapper's own small-buffer storage), never touching a live MapScreen instance. Confirmed dead end, not pursued further.
  • Instead, recognized that sub_17C120 - the function sub_1781BC's crash was trying to reach data from - is already hooked and proven stable all session: it's MapScreen's per-frame Tick, the exact function InstallSoakTestDisableHook (much earlier session) already wraps safely. Decompiling it in full for this task revealed camera-clamping code operating directly on MapScreen+316/+320 (float scroll x/y, no separate "scroll" entity needed after all) and two rect-bearing pointers at MapScreen+456 ("content", the full scrollable map bounds) and MapScreen+464 ("viewport", the visible window) - both using the same 4-float rect shape (indices 17-20) already found on MapTrack.
  • Extended the existing, already-safe Hook_MapScreenTick (in lan_event_injection.h) to log these three pieces of data once, ~1s after a MapScreen starts 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 by content's ~4280x3159 span and viewport's ~1993x928 span both being roughly screen-pixel-scale, not wildly different orders of magnitude) - against kTargetGroupName'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_18184C functions first), which led to recognizing the safer, already-proven Tick hook as the better vehicle once those two were ruled out.
  • Snapshot: Hook_MapScreenTick in lan_event_injection.h now includes the one-shot scroll/content/viewport logging (harmless, gated behind a tickCount > 60 + one-shot bool, same file already committed to logging patterns elsewhere in this hook). kEnableMapScreenCtorTraceHook remains false (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 because kTargetGroupName'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 inside MapScreen::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 to sub_1581A0(flowNode, outputNamePtr, ctx) with MapScreen+8 (a Flow-node object, not MapScreen itself) as the real first argument.
  • Decompiled sub_1581A0 in full: guards against a transition already being queued (flowNode+256/+260/+52/+44), then calls sub_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 into flowNode+56.. +64, sets flowNode+52=1) for FlowManager::Update to 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 own FlowNode::FireOutput(name) (or equivalent) primitive, the exact same mechanism that executes Outputs["CONTINUE"] -> "garage_pre_event_part2"-style transitions already seen in the .sb flow-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 at flowNode+28; (2) confirm what a valid ctx/a3 argument 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-safe g_mapScreenInstance) actually has a usable output name defined that leads toward garage_pre_event/the target screen, since MapScreen'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 of sub_159684 and 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 inside sub_1581A0) - a plain balanced-BST (std::map-shaped) key lookup comparing a name-hash against tree node keys, confirming Outputs is exactly the map<nameHash, target> structure already seen in every .sb flow file - nothing unexpected, closes out the low-level mechanics of sub_1581A0/FireOutput.
  • Found and unpacked game_cache/published/flow/menus/map_overworld.sb (the MapOverworld screen - im::app::flow::nfs::MapScreen's own Flow definition) and extracted its full Outputs map (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), and GAMESCOM_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: confirmed screenId="RestrictedGarage", layout="car_select_new" - identical shape to garage_pre_event.sb (cont.20), i.e. this genuinely is a real, playable car-select screen, not a stub. Its own CONTINUE output leads to garage_select_rollout.
  • Unpacked garage/garage_select_rollout.sb: confirmed screenId="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 own CONTINUE leads 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. If GAMESCOM_EVENT still fires correctly in this build, this reaches the target screen in one single FireOutput call 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_1581A0 was found.
  • Snapshot: Static/data analysis only, no code changes.
  • Verification: All screenIds/layouts/outputs read directly from NFSMW12MobileTools-unpacked JSON of the real shipped .sb files (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_EVENT is 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 normal EVENT/NEXT_EVENT -> event_detail path is the fallback (would need to also learn event_detail.sb's own outputs to keep chaining toward garage_select_car, not yet unpacked); (2) what sub_1581A0's third argument (ctx/a3, a {namePtr, refcountedObj}-shaped pair) needs to contain - nothing found yet indicating it can safely be null; (3) confirming MapScreen+8 is really the right node to call FireOutput on for these top-level map outputs specifically (traced via the sub_1BB59C thunk used for "NEXT_EVENT" in MapScreen::Tick, so at least that one case is solid precedent). Recommend a careful, isolated live test next (mirroring the Tick-hook safety approach from cont.24, not a risky new hook) once ctx semantics 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_EVENT is dead in the current build (not reachable in normal play) - noted as a future cleanup candidate (strip from the .sb files), and deprioritized as the trigger path; focus shifted to fully understanding sub_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:
    R0 = 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)
    
    For "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 ctx argument 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 FireOutput primitive is now fully specified and ready to call from a hook: FireOutput(node, &internedName, ctx), where InternString is 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-null ctx - {0,0} is confirmed safe only for payload-free outputs like "CONTINUE"/"BACK". Since GAMESCOM_EVENT is confirmed dead, the practical next question is what "EVENT"/"NEXT_EVENT" on MapOverworld actually need in ctx to 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_EVENT is dead in the live build (future cleanup candidate: strip from .sb files) 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:
    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_1BB59C
    
    I.e. sub_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 of handle/key our own InjectSyntheticEvent already produces (e.g. the key=0xc0ffee00 used for the current synthetic "LAN: Test Lobby" event).
    • Also decompiled sub_161A40 (the wrapper constructor) far enough to flag a risk: it dereferences the resolved Actor* (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's actor parameter (already a real argument InjectSyntheticEvent accepts, see lan_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.
  • Why: Direct continuation of the user's request to find a real (non-{0,0}) ctx example, specifically for "EVENT".
  • Snapshot: Static analysis only, no code changes.
  • Verification: Read directly from Hex-Rays decompile of sub_17A99C/sub_161A40; the reuse of sub_173350/+320/sub_406644 isn't inferred - these are the exact same offsets/functions already declared and used successfully in lan_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 on event_detail for it - no touch simulation needed anywhere in the chain. Before attempting this live: (1) check what actor value InjectSyntheticEvent/Hook_MapTrackHandleEvent currently passes for the synthetic event, since sub_161A40 wants 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 calling sub_17A99C directly 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/FireEventOutput for real. Wired FIRE_EVENT_OUTPUT_OFFSET 0x17A99C into lan_event_injection.h, extended the existing Soak-Test Hook_MapScreenTick (already ticking every frame, already resolving the live MapScreen* into g_mapScreenInstance) with a one-shot timer: after 120 ticks, call FireEventOutput(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_161A40 bumps *(actorPtr+8)) - raised FakeActor::refcount 1→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_27D1EC unconditionally reads *(a1+12) with no null check. RaceEventCtor (sub_2A4B58) deliberately zeroes RaceEvent+12 at construction - it's normally filled later by the prefab-deserialization pipeline, which a synthetic/injected RaceEvent never goes through.
    • Fix: in InjectSyntheticEvent, calloc(1, 16) a dummy buffer and point RaceEvent+12 (RACEEVENT_CATEGORYTAG_OFFSET) at it. Verified by the fault address moving from 0xc to a new value on retest, confirming the first crash site was actually cleared rather than coincidentally still failing nearby.
  • Why: Direct continuation of cont.28's plan - first real end-to-end exercise of the production trigger path.
  • Snapshot: lan_event_injection.h - added FIRE_EVENT_OUTPUT_OFFSET/FireEventOutputFn, RACEEVENT_CATEGORYTAG_OFFSET + the calloc'd dummy in InjectSyntheticEvent, and the one-shot test call in Hook_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 RaceEvent would 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, inside im::app::sounds::CopSounds::Tick = sub_304AA0, confirmed via vtable range 0xAA9260-0xAA92A8 containing the return address 0xAA9290): 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: hooked sub_304AA0 (COPSOUNDS_TICK_OFFSET) to a complete no-op (Hook_CopSoundsTick, does not call the original). Installed unconditionally from JNI_OnLoad via InstallCopSoundsTickSkipHook().
      • 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, inside sub_240548, reached from sub_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: hooked sub_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 !*a2 fast path) - bypasses the crashing fallback without calling the original. Installed unconditionally via InstallGetComponentNameSkipHook().
    • After both fixes: FireEventOutput completed 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 addr 0x0, offset 0x2C6374). Timing strongly suggests this is FlowManager::Update processing our now-queued transition on a later frame, not FireEventOutput itself. Decompiled sub_2C62A0: familiar GetComponent<T> (sub_8FF8C, already known-safe) pattern repeated several times against our synthetic Actor/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+.
  • 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/InstallGetComponentNameSkipHook added; main.cpp - both installed unconditionally from JNI_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-server fixed it), a screen stuck in Dozing/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 via KEYCODE_BACK + am force-stop).
  • Outcome/Next (paused here on user's explicit "Останавливаемся"):
    1. Resume by pinning down crash #4's exact faulting instruction via disasm at 0x2C6374 (same technique as crashes #1/#3) - the leading suspect is one of the three chained dereferences off a1+48, most likely because a1 (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.
    2. 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+Actor survive 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.
    3. Once a transition fully completes, verify the game visibly reaches garage_select_car/car_select_loadout via screenshot (per standing "Скринами проверяй" instruction), matching the already-confirmed real Flow chain from cont.26.
    4. Standing constraint remains in force: touch/tap simulation stays RE-verification-only; FireEventOutput/FireOutput is the correct, now largely-proven production path.

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 on sub_2C62A0, logging a1/a2 and each step of the a1+0x30 -> +0x2C -> +0x98 chain before calling the original) - live test showed the crash still occurred (same fault addr 0x0, 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 - proving sub_2C62A0 was 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 002c6374 as 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 to libapp_base. The correct file offset is (raw PC register) - (our own resolved libapp_base); using this run's own logged values (libapp_base=0xb9d9d000, tombstone pc ba3cc374) gives 0x62F374 - a completely different, unrelated address, off by a page-aligned 0x369000 from the wrong value.
    • Decompiling 0x62F374 (function sub_62F340) showed a hand-optimized SWAR strlen() implementation; the crashing line is *(_DWORD*)v1 reading the first word of the input pointer with no null check - a plain strlen(NULL), matching fault addr 0x0 exactly. 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's sub_240548 fix, but a different call site not covered by that guard.
    • Fix: replaced the (now-disproven) sub_2C62A0 hook with Hook_Strlen/InstallStrlenNullGuardHook at STRLEN_OFFSET 0x62F340 - a minimal if (!s) return 0; guard before falling through to the real implementation for every other (real) string. Leaf function, no PUSH, so the trampoline just relocates MOV 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.), logged A.N. produce offset for race selection screen, played the ui/ui/event_menu_in transition sound - and did not crash. dumpsys activity activities confirmed GameActivityMain still 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 (the XXXXX[...]XXXXX wrapping 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.
  • 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 - removed Hook_Sub2C62A0/InstallSub2C62A0DiagHook, added Hook_Strlen/InstallStrlenNullGuardHook (STRLEN_OFFSET 0x62F340); main.cpp - InstallStrlenNullGuardHook() replacing the removed diag-hook call in JNI_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:
    1. 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 past event_detail needs the same kind of output-firing call (not touch) - likely another sub_17A99C-style or direct FireOutput call for whatever output name the confirm-race button fires, still to be identified.
    2. 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.
    3. Standing constraint remains in force: touch/tap simulation stays RE-verification-only; FireEventOutput/FireOutput is the correct, now proven-in-production path.
    4. 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-reported backtrace: #00 pc ... line at face value - it can be relative to a different memory mapping and silently point at the wrong function.

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]XXXXX and 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 like Locale::, 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 unrelated OptionsSettingsScreen-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 after FireEventOutput fires, and separately hooked sub_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) - since sub_87738 itself is generic/shared, only its caller identifies which higher-level function is doing what.
    • The InternString trace showed our raw "LAN: Test Lobby" gets interned, immediately followed by a sub_87738 call from a caller whose address (after correcting for libapp_base, per cont.31's methodology note) resolved to sub_162F14, specifically the call sub_40A29C(&v105, ctx, RaceEvent+RACEEVENT_EVENTNAME_OFFSET) - i.e. exactly the site reading our own event-name field. sub_40A29C is a 1-line wrapper around sub_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 at context+32 is false) or builds L"XXXXX[" + rawText + L"]" - the L"XXXXX[" literal is real (an earlier find_bytes search for "58 00 58 00 58 00" (UTF-16 "XXX") had actually already turned it up at 0xa1f1b4, 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.
  • Fix: rather than reverse-engineer sub_40A580's table-registration format (unknown hash/prefab structure) or reimplement sub_40A2B0's EASTL wide-string-building logic, hooked sub_40A2B0 to call the original function unmodified, then post-process its already-built output buffer: if it starts with "XXXXX[", memmove the 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 later free() 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 - added Hook_InternStringDiag/InstallInternStringDiagHook and Hook_FatalLogCallerTrace/InstallFatalLogCallerTraceHook (temporary diagnostics, left in place but harmless/budget-gated), and Hook_ResolveDisplayText/InstallResolveDisplayTextHook (0x40A2B0, the actual fix); main.cpp - all three installed unconditionally from JNI_OnLoad.
  • Verification: Live on-device (Galaxy A9). Screenshot after the fix shows the event card title as clean LAN: TEST LOBBY and the track line as clean REVERSED_REGION2_DESERT_TRACK3 (both previously wrapped). One remaining XXXXX[]XXXXX on 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[]XXXXX bottom action bar from cont.32. Extended the same live-diagnostic technique: added a budget-gated log to Hook_ResolveDisplayText for the empty-content case (logging key/caller), and a second hook directly on sub_40A29C (the tiny wrapper around sub_40A2B0) since sub_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 caller sub_40A29C+0x10-ish, i.e. reached via the wrapper as expected.
    • Second pass (hooking sub_40A29C directly, diffing the field pointer against a newly-added g_lastSyntheticRaceEvent global): the field pointer was nowhere near our RaceEvent (nonsensical RaceEvent+4813788 offset) - 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) to sub_162F14 again, specifically the block: v56 = *(_DWORD*)(v54+12); ...; sub_40A29C(&v105, v52, v56+28); where v54 = v2[76] is our RaceEvent and +12 is RACEEVENT_CATEGORYTAG_OFFSET - our own cont.29 fix, a calloc(1, 16) dummy buffer pointed at from RaceEvent+12 to 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.
  • Fix: enlarged the categoryTag buffer from 16 to CATEGORYTAG_BUFFER_SIZE (64) bytes - eliminates the out-of-bounds read entirely - and populated CATEGORYTAG_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 the XXXXX[...] wrapper automatically stripped by the already-installed Hook_ResolveDisplayText fix - no new display logic needed.
  • Snapshot: lan_event_injection.h - RACEEVENT_CATEGORYTAG_OFFSET comment extended, added CATEGORYTAG_BUFFER_SIZE/CATEGORYTAG_LABEL_OFFSET; InjectSyntheticEvent now 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_lastSyntheticRaceEvent global, and Hook_ResolveDisplayTextWrapperDiag/InstallResolveDisplayTextWrapperDiagHook on sub_40A29C (0x40A29C).
  • Verification: Live on-device (Galaxy A9). Screenshot confirms the bottom bar now reads clean LAN RACE text, no crash, no more DIAG sub_40A29C NULL field log line.
  • Side observation (not a regression, not acted on): the same screenshot's track-name line reverted to XXXXX[]XXXXX (previously showed a coincidental REVERSED_REGION2_DESERT_TRACK3 in cont.32's screenshot). RaceEvent's track-name field (RACEEVENT_TRACKNAME_OFFSET) has never been intentionally set by InjectSyntheticEvent - the earlier text was uninitialized/adjacent heap memory that happened to contain that string, and enlarging the categoryTag allocation 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 RaceEvent for the track name and populate it intentionally (currently unset, ANALYSIS.md §6y marks RACEEVENT_TRACKNAME_OFFSET 72 as 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 in Hook_MapTrackHandleEvent) with kTargetGroupName ("region1_foothills_track1", the real MapTrack this event is attached to). Live-tested: no visible change - the row still showed XXXXX[]XXXXX, proving the on-screen widget does not read this field at all (the cont.32/33 screenshots' REVERSED_REGION2_DESERT_TRACK3 text was never coming from RaceEvent+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):
    1. Widened live diagnostics: extended Hook_ResolveDisplayText to also log the other lookup-miss branch (plain-empty result, no "XXXXX[" marker - the !*(context+32) path inside sub_40A2B0), and broadened Hook_ResolveDisplayTextWrapperDiag (on sub_40A29C) to log every call unconditionally instead of only null-field ones. Raised budgets to 3000. Result: the entire burst only contains ~26 real sub_40A29C calls, and 100% of them are already-known (event name at RaceEvent+88, categoryTag+24 null, 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 calls sub_40A29C/sub_40A2B0 at all - confirmed twice, at two different budget sizes, ruling out "budget too small" as the explanation.
    2. Static layout-file archaeology: found layouts.sb.json already unpacked in NFSMW12MobileTools/ (1.6M lines, 4431 named widget entities). Located event_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 via DataIdsMap/hex-id cross-references).
    3. 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.
    4. vtable sibling exploration: sub_162F14 is 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, OnEvent with 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 shows sub_4D913C(v9, &v105) as a 2-argument call, but raw disassembly of sub_4D913C itself proves it only reads R0 (PUSH{R4,R10,R11,LR}; MOV R4,R0; ...; STR R0,[R4,#0x70] - never touches R1) - 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.
  • Conclusion: the subtitle text is not sourced from RaceEvent+72, is never requested through the sub_40A29C/sub_40A2B0 resolve-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 synthetic RaceEvent never 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 - InjectSyntheticEvent now populates RACEEVENT_TRACKNAME_OFFSET/RACEEVENT_TRACKNAME_CAPACITY_OFFSET with kTargetGroupName (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[]XXXXX placeholder - a known, low-priority cosmetic gap, not a functional blocker. If revisited later, the most promising unexplored angle is a live debugger breakpoint on sub_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 own Outputs map (0300) shows output="EVENT"node="garage/garage_select_car" - the exact same output name ("EVENT") as MapOverworld's, just scoped to a different screen's Flow node. Hypothesis: firing FireOutput again, this time on the event_details screen object instead of the map screen, with a payload-free ctx={0,0} (matching the CONTINUE-style pattern), should reach garage_select_car.
    • Needed a pointer to the live event_details screen - unlike MapScreen, it has no dedicated Tick hook. Added Hook_LayoutScreenCtor on sub_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 first FireEventOutput succeeds.
    • First attempt failed silently: firing FireOutput synchronously from inside Hook_LayoutScreenCtor, right after event_details finished 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 once event_details is 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_LayoutScreenCtor now just records the target screen pointer + a CLOCK_MONOTONIC timestamp; the actual FireOutput call happens from inside Hook_InternStringDiag (confirmed live to keep firing every ~0.25s on event_details' own active update loop, unlike MapScreen::Tick) once ≥1.5s of real time has passed.
  • Live result: Success. LIVE TEST 2: FireOutput returned without crashing, immediately followed by DIAG LayoutScreenCtor: screenName="RestrictedGarage" - the exact screenId confirmed back in cont.20 for garage_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 native FireOutput calls, zero touch simulation, continuing the standing production-path constraint.
  • Snapshot: lan_event_injection.h - added FireOutputFn/FireOutput (raw sub_1BB59C primitive, FIRE_OUTPUT_OFFSET 0x1BB59C), moved InternStringFn/InternString's declaration earlier in the file (needed by Hook_InternStringDiag now), added Hook_LayoutScreenCtor/InstallLayoutScreenCtorHook (LAYOUTSCREEN_CTOR_OFFSET 0x1A571C) with its screen-name diagnostic log, added g_firedEventTest/g_firedSecondEventTest/g_secondEventTarget/g_secondEventTargetSetAt/MonotonicMillisSince() globals, and the deferred-fire block inside Hook_InternStringDiag. main.cpp - InstallLayoutScreenCtorHook() added to JNI_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, into garage_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 confirmed output="CONTINUE"node="garage_select_rollout". Extended the same deferred-fire pattern (capture screen in Hook_LayoutScreenCtor, fire from Hook_InternStringDiag after ≥1.5s) for a third hop.
  • Live result: Success - DIAG LayoutScreenCtor: screenName="RestrictedGarageLoadout" (the exact screenId for garage_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 native FireOutput, zero touch.
  • Next hop attempted and crashed as warned: garage_select_rollout.sb.json's own output="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's libapp_base=0xb9da2000 → real offset 0x2AC744, inside a function named RaceLoaderTask_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 inside OnSceneLoaded* 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)) dereferences a1[23] unconditionally a bit later, and a1[23] was left null exactly because the m_StartLine locator was never found (fault addr 0x4 matches NULL+4 precisely).
    • 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 synthetic RaceEvent has never referenced (no real TrackId/scene reference was ever set - a long-standing open question in this project, noted since cont.18: "TrackId/Location aren't registered anywhere - they're plain prefab data fields"). This is architecturally different from every crash fixed so far (all of which were RaceEvent/Actor component-completeness gaps patchable with a calloc'd buffer or a skip-hook) - it needs an actual track scene loaded, not a bigger dummy buffer.
  • 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_select be 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, though garage_select_car's Restricted=true/GarageType=prerace properties suggest it expects context normally set up by event_detail's own confirm handler - real risk of new missing-context crashes, needs live testing to confirm; (2) irrelevant to the crash just found, since RaceLoaderTask_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 real TrackId fix (cont.36) is needed regardless of navigation shortcuts.
  • Implementation: found the real, already-shipped "BACK" output on each screen (from their own .sb Outputs 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_fourthEventTarget etc.) into one generic mechanism: a kOutputChain[] array of output names ("EVENT", "CONTINUE", "BACK", "BACK", "BACK") plus a single g_chainIndex/g_chainTarget/g_chainTargetPending state, walked one entry at a time by Hook_LayoutScreenCtor (capture) and Hook_InternStringDiag (deferred fire) - same proven timing pattern, no more duplicated blocks, and trivial to extend/reorder for future hops.
  • 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+336 directly (inside our own libmpcore.so, not libapp.so - no offset-correction ambiguity this time, the symbol resolved cleanly). Root cause: navigating back to map_overworld destroys and reconstructs the real MapScreen/MapTrack objects, re-firing MapTrack::HandleEvent (the same hook that originally drove InjectSyntheticEvent) - 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) dereferenced g_mapScreenInstance - a global captured once by Hook_MapScreenTick on the first map visit and never invalidated - now pointing at the freed original MapScreen object. Classic use-after-free. Fix: deleted both leftover TEMPORARY dump blocks (the MapTrack bounds-rect dump and the g_mapScreenInstance scroll-entity dump) from Hook_MapTrackHandleEvent entirely - 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-unused MAPSCREEN_SCROLLENTITY_OFFSET constant.
  • Live result: Full success. All 5 chain steps fired cleanly (LIVE TEST CHAIN[1] through [5]), ending with DIAG 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 cycle map → event → car select → loadout → controlled exit → map now works end-to-end, natively, crash-free.
  • Snapshot: lan_event_injection.h - removed the per-hop g_secondEventTarget/g_thirdEventTarget/g_fourthEventTarget/g_firedSecondEventTest/etc. globals and their three hand-duplicated Hook_LayoutScreenCtor/Hook_InternStringDiag blocks, replaced with kOutputChain[]/g_chainIndex/g_chainTarget/g_chainTargetPending; deleted the two TEMPORARY debug-dump blocks (and MAPSCREEN_SCROLLENTITY_OFFSET) from Hook_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_ResetStartingLine crash (cont.36) needs a real TrackId/scene reference on the synthetic RaceEvent - 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: UIRolloutSelectedEventsub_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+ProfileChangeSet boost::bind RTTI strings through sub_256188sub_256028→vtable 0xaa5f48sub_252A9Csub_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 (see PROGRESS.md cont.25/project_visible_streets_investigation methodology precedent): real adb shell input tap (not the project's own disabled synthetic-tap infra) on the live loadout screen, observed via the existing Hook_InternStringDiag/Hook_FatalLogCallerTrace diagnostics (budgets raised 3000/5000 → 8000/8000).
  • Live discovery: opening a mod slot showed the real CarMod catalog on-screen - НЕТ(free)/ВОССТ. ШИНЫ(TYRES_REINFLATING, 1000$)/ЗАЩ. ОТ СТОЛКН.(1000$)/УСИЛЕННОЕ ШАССИ(1500$) - matching the previously-confirmed CarMod schema. 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_base value logged from a different app launch than the one that produced the caller address (ASLR re-randomizes per launch; the libapp_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, capturing libapp_base via logcat -d | grep libapp_base within ~3s of am start, before more log volume could push it out.
  • Correctly-paired result: with libapp_base=0xb9c9c000 confirmed for the run, tapping a slot then selecting ВОССТ. ШИНЫ produced one single-fire DIAG sub_40A29C call (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 function sub_37BF34 (0x37BF34).
  • Decompiled sub_37BF34(a1, a2): a1 = the loadout slot's UI/component object, a2 = an event pointer. Reads v4 = *(a2+8) (0 = "no mod"/empty, else a CarMod reference); if non-zero, resolves the mod's component data (sub_890EC/sub_B25CC), looks up its localized name/description via sub_40A29C (name) and sub_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 using a1+292/a2+8 at the end (sub_25DEF4) - almost certainly the running total cost. If v4==0 (empty/"НЕТ" selected), takes the else-branch: sets the label to "UI_EMPTY_MOD" and hides the description widget instead. Clears a dirty flag at a1+289 on exit.
  • Traced one level up - sub_37BE74 (0x37BE74, sub_37BF34's only caller): a standard im::Event-dispatch handler on the slot component. lpsrc[1] is the event's type-tag; type 1057 dynamic_casts the incoming event to im::app::events::UIRolloutSelectedEvent and, if it matches, calls sub_37BF34(a1, thatEvent) directly - i.e. a2 in sub_37BF34 IS the UIRolloutSelectedEvent* itself, and the selected CarMod is read straight from UIRolloutSelectedEvent+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 - compares v7[2] (clicked slot id) against a1+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::UIRolloutSelectedEvent is 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 chosen CarMod directly from the event's own +8 field 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 is sub_37BF34 itself: at entry, *(int*)(a2+8) is exactly "the CarMod just selected for this slot" (0 = cleared/empty) and a1 identifies which slot (a1+292 looks like a slot-id field, matching the id compared against in sub_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_fatalLogCallerBudget remain raised to 8000/8000 in lan_event_injection.h from 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 in kOutputChain[] are still commented out (chain currently halts on garage_select_rollout instead 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 reads a1 (slot id via a1+292) and *(int*)(a2+8) (selected CarMod) and stores them into a small persistent {slot → CarMod} array, then restore the "BACK" chain and use that array right before/during the exit to map_overworld to 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 by slotId, matching the field read at a1+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 via InstallModSlotSelectedHook() in main.cpp's JNI_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 two ModSlot: slot N recorded lines back-to-back - slot 0 -> CarMod=0x1 AND slot 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 cost 1 000$ matching only one mod applied). Root cause: im::app::events::UIRolloutSelectedEvent is broadcast to every slot's sub_37BE74 listener, not just the one whose picker was open - both slots' sub_37BF34 calls receive the same event object as a2, so both read the identical *(a2+8) value. Unlike event type 1056 (UIRolloutSlotButtonClickEvent, the click that opens a picker), which sub_37BE74 gates by comparing the clicked slot id against a1+292 before acting, type 1057 (UIRolloutSelectedEvent) has no such gate in sub_37BE74 - it calls sub_37BF34 unconditionally for every listener. The real gate lives one level deeper: sub_37BF34's entire body is wrapped in if (*(_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 read a1/a2 unconditionally 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 289 and changed Hook_ModSlotSelected to check *(uint8_t*)(a1+289) != 0 before 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: CarMod values 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 inside sub_37BF34 via sub_890EC), not the CarMod* 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 - added InstallModSlotSelectedHook(); call in JNI_OnLoad, right after InstallLayoutScreenCtorHook().
  • 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 in kOutputChain[] (still disabled from cont.38's diagnostic pause), (b) actually consume g_modSlotSelections[] at the point of exiting to map_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). Restored kOutputChain[] to its full 5-entry production shape (EVENT, CONTINUE, BACK, BACK, BACK).
  • Persistence implementation: extended the existing native→Kotlin GameEvents bridge (game_events.h/GameEvents.kt, already used for onMapLoaded/onRaceStarted/onRaceEnded) with a new onUpgradesAccepted(slotIds: IntArray, carMods: IntArray) event and FireUpgradesAccepted() (JNI NewIntArray/SetIntArrayRegion marshaling, since this is the bridge's first event carrying data rather than a plain no-arg lifecycle ping). Added PersistAcceptedUpgrades() in lan_event_injection.h, which copies g_modSlotSelections[] into parallel arrays and calls FireUpgradesAccepted(). Wired the call into Hook_LayoutScreenCtor, gated on g_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 since Hook_LayoutScreenCtor is defined before the mod-tracking section that owns the data) prevents a double-fire if MapOverworld is 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 tap call 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 in GLThread, right as LIVE TEST CHAIN[3] fired BACK on the captured RestrictedGarageLoadout pointer. 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. Since Hook_LayoutScreenCtor's target-capture is guarded by !g_chainTargetPending (only captures once per hop), GarageView's construction didn't update g_chainTarget - it stayed pointed at the original RestrictedGarageLoadout instance, which by the time the deferred timer fired 6s later may have already been torn down/replaced by the GarageView transition. Firing FireOutput("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); added g_upgradesPersisted/forward-declared PersistAcceptedUpgrades() near the top; added the persistence-dispatch block in Hook_LayoutScreenCtor; added PersistAcceptedUpgrades() definition next to RecordModSlotSelection. game_events.h - added g_onUpgradesAcceptedMethod, resolved it in InitGameEvents, added FireUpgradesAccepted(slotIds, carMods, count) with its own JNI int-array marshaling. GameEvents.kt - added onUpgradesAccepted(slotIds, carMods) to GameEventListener (default no-op) and dispatchUpgradesAccepted() to GameEvents.
  • 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_modSlotSelections actually containing a pick at the moment PersistAcceptedUpgrades runs) 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.onUpgradesAccepted has 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 relocated B's encoded offset resolves to the wrong target once moved). Hooked sub_1581A0 instead (the real function the thunk tail-calls into, confirmed via disasm to have a safe PUSH {R4-R6,R10,R11,LR}; ADD R11,SP,#0x10 prologue) - functionally identical for every caller, since the thunk always redirects there regardless of who calls it (this project's own FireOutput pointer included, which still resolves to the thunk's address). Added Hook_FireOutputDiag/InstallFireOutputDiagHook (FIRE_OUTPUT_REAL_OFFSET 0x1581A0), logging flowNode, 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 (default true) to lan_event_injection.h - flips off the entire timer-driven kOutputChain auto-navigation (leaving InjectSyntheticEvent/the map pin itself untouched) so real taps drive navigation instead, for this kind of discovery work. Temporarily set false for this session's testing, reverted to true before 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 FireOutput line was cross-referenced against DIAG LayoutScreenCtor's next screen and InputDispatcher'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: caller 0x17aa58, which decompiled to inside sub_17A99C itself (i.e. FireEventOutput, the same primitive this project already calls programmatically) - meaning the true UI click handler is one level further up (whatever calls FireEventOutput), not yet identified, since __builtin_return_address(0) only shows the immediate caller of FireOutput.
    • event_detail's checkmark -> EVENT, AND car_select's checkmark -> CONTINUE: same caller both times, 0x1a7958, decompiling to sub_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's OnClick, 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: caller 0x16c764, decompiling to inside sub_16C660 (0x16C660, size 0x7f0 - 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).
  • Bonus discovery, unplanned but highly informative: since this whole walkthrough used a REAL event (not our synthetic RaceEvent), tapping the real loadout checkmark proceeded past PreRaceLoadingScreen into an actual, fully playable race (screenshot confirms live gameplay: position 6/6, real speed/HUD, wall-scrape sparks) - zero crashes, pidof confirmed the process stayed alive throughout. This directly confirms cont.36's root-cause theory: the RaceLoaderTask_ResetStartingLine crash was specifically about our synthetic RaceEvent never 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=0x1 fired 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 - added kEnableAutoChainTest toggle (guarding the tickCount-based auto-fire block in Hook_MapScreenTick), added the "REAL FireOutput click-handler discovery" section (FIRE_OUTPUT_REAL_OFFSET, g_fireOutputLogBudget, Hook_FireOutputDiag, InstallFireOutputDiagHook). main.cpp - added InstallFireOutputDiagHook(); to JNI_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's InstallInternStringDiagHook etc.).
  • Verification: Live on-device (Galaxy A9), one complete manual walkthrough with kEnableAutoChainTest=false, cross-checking DIAG FireOutput/DIAG LayoutScreenCtor/InputDispatcher/ModSlot log lines against screenshots and real tap timestamps at every hop. Reverted kEnableAutoChainTest to true and 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) and sub_16C660 (loadout-screen-specific confirm handler, which itself calls through to FireOutput same as everything else). Since every real handler ultimately funnels through the same FireOutput/sub_1581A0 primitive 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 extend Hook_FireOutputDiag itself: check whether the resolved output name is "CONTINUE" and whether flowNode matches the currently-tracked loadout screen's own flow node (*(screenOwner+8), computable from g_lastLayoutScreenInstance), and if so substitute the BACK-chain instead of letting the real "CONTINUE"/race-load proceed - without needing to touch sub_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/ResolveHandle machinery already used elsewhere in this project, suggesting a direct call to FireEventOutput-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 on sub_1581A0, cont.41) to detect the specific call: outputName == "CONTINUE" AND the currently-tracked screen (g_lastLayoutScreenName/g_lastLayoutScreenInstance, now updated in Hook_LayoutScreenCtor on every screen construction) is "RestrictedGarageLoadout", with a redundant flowNode == *(screenOwner+8) check. On match: suppress the call, mark a new independent state machine (g_realExitChainActive, separate from the old test-harness's g_chainIndex/kOutputChain) active, and kick off the same proven BACK x3 exit via the existing deferred-fire machinery, reusing Hook_LayoutScreenCtor's screen-capture and Hook_InternStringDiag's 1.5s-deferred-fire pattern.
  • Caught before testing: since Hook_FireOutputDiag was declared void but the real sub_1581A0 returns int - 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 hand sub_16C660 an undefined R0 value to keep computing with, a real correctness risk (not just a style issue - a void-typed function gives the compiler no reason to preserve a callee's return register across the call). Fixed before ever running it: changed Hook_FireOutputDiag's signature to int, changed FireOutputRealFn's typedef to match, and - rather than suppressing the call outright - substituted the output name from "CONTINUE" to "BACK" and let the real sub_1581A0 implementation run for real (same technique already used throughout this file for firing named outputs - InternString a new name, pass it through). This gives sub_16C660 a 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 went RestrictedGarageLoadout -> 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 through RestrictedGarage -> 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 addr 0x74, 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" against g_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 - added IsKnownBackableCarFlowScreen(), 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 a BACK output configured). The persistence trigger (PersistAcceptedUpgrades) was decoupled from the old hop-count in the same pass - now gated on a simple one-shot g_realExitChainTriggered flag (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 to FireOutput at this one call site.
  • Snapshot: lan_event_injection.h - moved g_lastLayoutScreenInstance/g_lastLayoutScreenName declarations up near the top of the file (now needed by Hook_InternStringDiag, defined earlier than their original spot next to Hook_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); changed Hook_FireOutputDiag/FireOutputRealFn to return int instead of void; added the interception block (checks isRealLoadoutContinue, substitutes "BACK", arms the live-tracking deferred loop); added the live-tracking deferred-fire block in Hook_InternStringDiag; added the g_realExitChainTriggered-gated persistence trigger in Hook_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. kEnableAutoChainTest reverted to true and 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 - hook sub_16C660 itself (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's CAR_MAKE_FORD/CAR_MODEL_FOCUS_RS500 sightings (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_16C660 and 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, is sub_16C660((_DWORD*)a1) - no gating, no player-input check. Combined with cont.41's finding that sub_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_16C660 directly (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 on RestrictedGarage (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 showed sub_16C660 itself 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 was Hook_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 from JNI_OnLoad.
  • Working fix: widened the already-proven-safe FireOutput-level interception instead. Since the tick's auto-continue still has to go through the same FireOutput/sub_1581A0 call that's already safely hooked, extended Hook_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-stale flowNode until the real transition catches up) - the original REAL_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, harmless FireOutput substitution, 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 stale flowNode during the initial burst, a handful of genuinely-progressing ones as the screen stack actually unwound: RestrictedGarageLoadout → RestrictedGarage → EventDetails → MapOverworld), landed cleanly on MapOverworld, and correctly fired PersistAcceptedUpgrades: 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_ATTEMPTS raised from 10 to 300; Hook_FireOutputDiag's interception widened with isRepeatedAutoCascadeContinue (catches repeated CONTINUE from any IsKnownBackableCarFlowScreen once g_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/InstallConfirmCarSelectionHook and g_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_ConfirmCarSelection not installed) and app force-stopped afterward.
  • Note on tooling, not code: adb shell screencap/exec-out screencap intermittently returned a stale/constant 14233-byte black PNG for a stretch of this session's testing, unrelated to any app or hook state (confirmed via dumpsys power/dumpsys window showing the app genuinely focused and rendering, and via logcat-based verification proceeding normally throughout) - abandoned screenshot-based verification for the remainder of this session's testing in favor of logcat-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 for onUpgradesAccepted exists 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() in lan_event_injection.h - reuses the exact primitive already live-tested this session (FireEventOutput(g_mapScreenInstance, 0xC0FFEE00u), the same call Hook_MapScreenTick's own boot-time auto-fire makes, and the same one a real map-pin tap ultimately resolves to via sub_17A99C) - just callable at any time instead of only once, automatically, ~2s after boot behind the kEnableAutoChainTest/tickCount>120 gate. 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.kt registers a BroadcastReceiver for a custom action (nfs.mod.mpcore.TEST_OPEN_CARSELECT, RECEIVER_EXPORTED via ContextCompat.registerReceiver for Android 13+'s required-flag rule, unregistered in onDestroy), letting the whole path be exercised from adb 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 on MapOverworld (~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 demand fired, and EventDetails constructed cleanly a moment later - zero crashes, app confirmed alive (pidof) and crash-log-clean afterward.
  • Snapshot: lan_event_injection.h - added TriggerOpenCarSelectOnDemand(). main.cpp - added the JNI export. MultiplayerCore.kt - added external fun triggerCarSelectTest(). GameActivityMain.kt - added carSelectTestReceiver (a BroadcastReceiver field), registered in onCreate right after loadCore() via ContextCompat.registerReceiver(..., RECEIVER_EXPORTED), unregistered in onDestroy; added the IntentFilter import.
  • 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 EventDetails opened 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) EventDetails still 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-skip EventDetails'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_firedAutoOpenCarSelect to Hook_MapScreenTick, calling TriggerOpenCarSelectOnDemand() (cont.44's primitive) once at tickCount > 120 (~2s) - deliberately not reusing kEnableAutoChainTest's block (temporarily set to false for this demo) since that also auto-continues through the entire scripted kOutputChain (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 on EventDetails (the event's own name/stakes screen), not car_select - correct catch: TriggerOpenCarSelectOnDemand only ever fires the same FireEventOutput a real map-pin tap fires (cont.35), which stops at EventDetails by 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 in Hook_LayoutScreenCtor and fire block in Hook_InternStringDiag) - same proven >=1500ms wall-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 reusing kOutputChain/g_firedEventTest, so it stops exactly at car_select instead of continuing on into loadout and back out like the scripted test harness would. TriggerOpenCarSelectOnDemand now arms g_autoSkipEventDetailPending = true right before firing its own FireEventOutput call.
  • Second live run: success. MapOverworld → (2s) → TriggerOpenCarSelectOnDemand fires → EventDetails constructs → (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 - kEnableAutoChainTest temporarily false (was true, needs reverting once this demo phase is over); added kAutoOpenCarSelectAtBoot/g_firedAutoOpenCarSelect and the new g_autoSkipEventDetail* state block near the top of the file (both need to be visible to Hook_InternStringDiag, defined early); added the boot-trigger call in Hook_MapScreenTick; added the capture block in Hook_LayoutScreenCtor; added the deferred-fire block in Hook_InternStringDiag; TriggerOpenCarSelectOnDemand now 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+the EventDetails auto-skip are demo/debug scaffolding (analogous to cont.44's debug broadcast) - not yet cleaned up for production: kEnableAutoChainTest needs reverting to true (or a real decision made about whether it's still needed at all now that cont.44/46 provide a cleaner on-demand path), and kAutoOpenCarSelectAtBoot should probably become false by default once this demo phase is done, with the underlying mechanisms (TriggerOpenCarSelectOnDemand + the EventDetails auto-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 at node+28, keyed by interned-string-pointer, membership-checked via sub_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 .sb data, 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_ref couldn't locate it), and a live empirical field-watch (polling +44/+52/+56/+60/+64/+256/+260 every frame after firing) answered the wrong question (turned out FireEventOutput'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 decompiling GetOutputNode'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 via GetOutputNode, 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 is FlowNode's own per-frame transition processor.
  • The single biggest unlock: hooking sub_1573EC observationally (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=0xd70a5bc0 and the EventDetails-owning tick's own a1=0xd70a5bc0 were 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 .sb data, 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_FlowNodeTick now captures event_detail's own +28 value (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 +28 with the captured event_detail tree reference, and fires FireOutput(mapScreenOwner, "EVENT", ctx) - sub_1581A0's own membership check now succeeds naturally (against the substituted-but-genuine tree), and sub_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 addr 0x1c (=28 decimal) - flowNode+28 dereferenced with flowNode itself NULL. Root cause: g_mapScreenInstance (only ever written by Hook_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 logged flowNode 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 MapOverworld for 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 crashingDIAG LayoutScreenCtor: screenName="RestrictedGarage" - EventDetails never constructed at all. True one-hop jump, MapOverworld straight 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" (caller 0xb9e28958, the same generic sub_1A7920 handler identified in cont.41) and landed cleanly on RestrictedGarageLoadout (the loadout screen), exactly as it does when reached the normal way. This directly answers cont.37's original open risk concern ("car_select's Restricted=true/GarageType=prerace properties 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 - added g_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 - added InstallFlowNodeTickHook() and the new JNI export. MultiplayerCore.kt - added external fun triggerTrueDirectCarSelectJump(). GameActivityMain.kt - added trueDirectCarSelectTestReceiver (debug broadcast nfs.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_mapScreenInstance staleness is a real, general risk for any of these Trigger* functions (not new to this one - TriggerOpenCarSelectOnDemand has the identical unchecked dereference and should get the same null-guard treatment); (2) g_capturedEventDetailsTreeRef is captured once per session from a real event_detail visit - a lobby button wired directly to TriggerTrueDirectCarSelectJump with 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 kEnableAutoChainTest and kAutoOpenCarSelectAtBoot back 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:
    • kEnableAutoChainTesttrue. This is the original cont.35-41 timer-driven test harness (fires the synthetic "LAN: Test Lobby" event's own EVENTCONTINUEBACK×3 cycle automatically, ~2s after boot, entirely independent of the cont.44+ on-demand mechanisms). true is its long-standing default throughout the whole session - it was only ever flipped to false temporarily during cont.42/43/45/46's live testing so it wouldn't race against whatever was being manually driven at the time.
    • kAutoOpenCarSelectAtBootfalse. 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-fire TriggerOpenCarSelectOnDemand() at boot so the user could watch it live without needing to send an adb broadcast. false is 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 calling TriggerOpenCarSelectOnDemand() (cont.44) or TriggerTrueDirectCarSelectJump() (cont.48) via JNI, not a boot-timer.
  • Verification: rebuilt (:app:assembleDebug), reinstalled on-device (Galaxy A9), confirmed the app launches and reaches pidof-visible normal running state with no changes to its startup behavior beyond what these two flags already controlled.
  • Snapshot: lan_event_injection.h only - 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 is false). No other files touched - the underlying mechanisms themselves (TriggerOpenCarSelectOnDemand, TriggerTrueDirectCarSelectJump, the EventDetails auto-skip, both debug broadcast receivers TEST_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 unguarded g_mapScreenInstance dereference should get the same null-guard treatment TriggerTrueDirectCarSelectJump already 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 TriggerOpenCarSelectOnDemand that TriggerTrueDirectCarSelectJump already 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 into g_flowNodeWatchPtr without a null check), added the same if (!flowNode) { Log(...); return; } early-return pattern as TriggerTrueDirectCarSelectJump, and reused the now-guaranteed-non-null flowNode local 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 zero DIAG LayoutScreenCtor output even after 60-90+ seconds and even surviving a full user-initiated device reboot - free -h showed 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-priority cch-empty state, i.e. already marked safe-to-kill) were cleared via am kill to 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 from adb 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 full kEnableAutoChainTest cycle back to MapOverworld) 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 demandDIAG MapScreen flowNode=0xdb2cc080 (non-null, guard passed transparently) → FireEventOutput returned without crashingEventDetails constructed → (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.h only - TriggerOpenCarSelectOnDemand gained the flowNode local + null guard, mirroring TriggerTrueDirectCarSelectJump'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 mWakefulness would 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/inactive g_mapScreenInstance before 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 by TriggerTrueDirectCarSelectJump, 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_select opens → under a second later, the confirm happens by itself → the mod/loadout screen opens → then, abruptly, BACK into car_selectthen abruptly BACK again into event_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 whichever LayoutScreen constructs next, with no check that it actually belongs to our own synthetic event's flow - it was written when only our own scripted FireOutput calls 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 constructs car_select, the harness's capture logic - still watching for "whatever constructs next" - grabs the user's own, real car_select as 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 real car_select and event_detail - exactly the sequence reported.
  • Fix: changed kEnableAutoChainTest's default from true back to false. 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 triggers TriggerOpenCarSelectOnDemand/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 synthetic RaceEvent's minimal/incomplete data confusing whatever "is this session fully configured" check sub_16C660/sub_1EF580() performs (a real, correctly-populated RaceEvent might 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.h only - kEnableAutoChainTest flipped from true to false, 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=true remains 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 (redirecting RestrictedGarageLoadout'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) and g_currentEventIsSynthetic (latched/consumed).
    • TriggerOpenCarSelectOnDemand() and the legacy kOutputChain harness's own FireEventOutput call (inside Hook_MapScreenTick) each set g_nextEventDetailsIsSynthetic = true immediately before firing - both are always firing our own synthetic event (key 0xC0FFEE00).
    • Hook_LayoutScreenCtor latches g_currentEventIsSynthetic = g_nextEventDetailsIsSynthetic (then resets the arm-flag) whenever EventDetails constructs - 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 to false (real) for genuine navigation.
    • TriggerTrueDirectCarSelectJump() (cont.48) bypasses EventDetails' construction entirely by design, so it latches g_currentEventIsSynthetic = true directly instead of going through the arm/consume path.
    • Hook_LayoutScreenCtor also resets g_currentEventIsSynthetic = false whenever MapOverworld constructs - a natural session boundary, so the flag can never leak into a later, unrelated real event visit.
  • The actual gate: added && g_currentEventIsSynthetic to both of Hook_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):
    1. Real event (МАККЛЕЙН → ПОБУДКА, all real taps) → event_detailcar_selectRestrictedGarageLoadout → tapped the real confirm checkmark: logcat showed a clean, uninterrupted DIAG 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.
    2. Synthetic event (nfs.mod.mpcore.TEST_OPEN_CARSELECT debug broadcast, same primitive as cont.44) → auto-skip through EventDetailscar_select → confirmed car → RestrictedGarageLoadout → tapped confirm: logcat showed the expected REAL CONFIRM: intercepted CONTINUE from RestrictedGarageLoadout ... substituting BACK followed by the full cascade (REAL EXIT CHAIN: caught a repeated auto-continue ... ×44) and REAL 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.
  • Snapshot: lan_event_injection.h only. New state block (g_nextEventDetailsIsSynthetic, g_currentEventIsSynthetic) near the top; arm calls in TriggerOpenCarSelectOnDemand() and the legacy harness's Hook_MapScreenTick fire site; direct latch in TriggerTrueDirectCarSelectJump(); latch/reset logic added to Hook_LayoutScreenCtor; g_currentEventIsSynthetic gate added to both conditions in Hook_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 for TriggerTrueDirectCarSelectJump, 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): opened GaragePaint cleanly (confirmed via DIAG 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=0x1 logged), 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 BACK and cascaded cleanly back to MapOverworld; (2) on a second synthetic-event cycle, the loadout's own auto-continue (cont.42/43's sub_16C660, which considers a session with an upgrade selected "fully configured") fired CONTINUE on 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-handled F fatal: Couldn't find MedalPosition component / Dereferencing a NULL component pointer lines (the game engine's own internal log level for conditions it recovers from, immediately followed by this project's own ... FireOutput/Strlen ... returned without crashing confirmations) - these are the same benign messages documented back in cont.36/42/43 for the synthetic event's intentionally-minimal data, not new crashes. pidof confirmed 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 out g_realExitChainActive/Hook_FireOutputDiag's interception (cont.42/43/52), which only ever substitutes BACK for an actual CONTINUE it catches. kEnableAutoChainTest confirmed still false in source. This is real engine behavior, not our mod.
  • New diagnostic hook added (Hook_GenericFireOutputWrapperDiag, GENERIC_FIRE_OUTPUT_WRAPPER_OFFSET 0x1A7920): found via IDA that Hook_FireOutputDiag's existing __builtin_return_address(0) (hooked at FIRE_OUTPUT_REAL_OFFSET 0x1581A0) always reports the SAME address regardless of true caller, because sub_1A7920 - a single generic "fire this named output" wrapper used by real taps AND internal engine logic alike - tail-calls down into FIRE_OUTPUT_OFFSET (0x1BB59C) without pushing its own LR, so the real caller's distinguishing return address gets lost by the time it reaches 0x1581A0. Hooking sub_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_GenericFireOutputWrapperDiag remains 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 TriggerOpenCarSelectOnDemand always shows event_details first. Agreed TriggerTrueDirectCarSelectJump (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_capturedEventDetailsTreeRef only 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 via sub_156F08 - a lazily-loads-and-caches-by-name resolver with its own hash table. This meant the exact resolution sub_1573EC performs on a real map→event_detail transition could be invoked directly, without any live screen construction, by calling GetOutputNode ourselves with map's own already-loaded node data (*(mapFlowNodeExecutor+28), available immediately once MapOverworld constructs) 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 in InstallMapTrackHandleEventHook alongside InternString/FireOutput). Hook_LayoutScreenCtor's MapOverworld branch now does a one-shot (g_eventDetailsTreeWarmedUp) direct GetOutputNode call the moment the map constructs, populating g_capturedEventDetailsTreeRef with 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 TriggerTrueDirectCarSelectJump once crashed with a real SIGSEGV (fault addr 0x38, Cause: null pointer dereference, abort message Dereferencing 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 +28 permanently stuck on the substituted tree after the first failed/no-op attempt - TriggerTrueDirectCarSelectJump doesn't restore +28 after 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_base value 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 own pc 0006563c backtrace-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 raw lr/pc registers 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 when a2 is null, matching fault addr 0x38 (56 decimal) exactly.
    • Its only caller, sub_23F990, passes *(_DWORD**)(v5+12) as that argument (v5 being sub_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_23F990 itself is called from sub_170EC8 (part of car_select/RestrictedGarage's own construction, per the live backtrace/register evidence - r0/r4/r8 all held the exact RestrictedGarage screen instance address logged moments earlier by Hook_LayoutScreenCtor), which first resolves sub_171278(a1) and reads *(a1+320) (looks like a screen-mode/state enum) to decide which path to take; sub_171278 in turn (for at least one mode value, 2) builds something via sub_77B70(0)sub_2550C8(...).
  • Interpretation: this is very unlikely to be anything specific to event_details itself setting up car_select-specific context (the original cont.37/48 worry) - it looks more like a global cache/registry (reached via sub_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 - added g_eventDetailsTreeWarmedUp, GetOutputNode/GetOutputNodeFn/GET_OUTPUT_NODE_OFFSET, the GetOutputNode initialization line in InstallMapTrackHandleEventHook, and the warm-up block in Hook_LayoutScreenCtor's MapOverworld branch. 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_2550C8 trace 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-arg GetInstance() - 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 matching Initialise() (sub_244CA8, RTTI-confirmed via "!s_Instance"/"Initialise") - constructs a self-contained malloc(0xD0) object (internal strings "CurrentState - Car"/"CurrentState - Race" suggest a car/race state tracker) with no external dependencies, and assigns it to dword_AD2A08. Added a direct call to this from TriggerTrueDirectCarSelectJump, gated on dword_AD2A08 still 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 own Initialise() 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 -h showed 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 raised screen_off_timeout to 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 checking free -h and 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) is dword_AD2A08 itself (the singleton, resolved via the no-arg GetInstance() one level up in sub_171278), not some other object as first assumed. The crash is *(_DWORD**)(v5 + 12) - i.e. the singleton's own +12 field, which sub_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: confirmed 0 at the exact moment of a clean, reproduced crash (singleton=0xbf1e3080 *(singleton+12)=0(0x0), immediately followed by RestrictedGarage constructing and then the same fault addr 0x38 SIGSEGV). 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 for GetOutputNode). A first attempt to find a setter via dword_AD2A08's own vtable (off_AA5B94) turned up a large, not-yet-fully-parsed vtable (RTTI/multiple-inheritance-shaped layout, several 0/2/ptr triplets mixed with real function pointers) - not conclusive without more work; a global write-to-+12 search wasn't completed either (IDA's static xrefs don't track global+offset field writes once code holds the resolved instance pointer rather than referencing dword_AD2A08 by symbol again).
  • Snapshot: lan_event_injection.h - SINGLETON_INSTANCE_OFFSET/SINGLETON_INITIALISE_OFFSET/SingletonInitialiseFn/SingletonInitialise declared (kept - SingletonInitialise itself is legitimate, RTTI-confirmed, harmless to keep resolved even though the call site was removed); the TriggerTrueDirectCarSelectJump call site now does a read-only diagnostic log of dword_AD2A08 and its +12 field instead of the removed Initialise() call. Two temporary diagnostic hooks added and then removed after serving their purpose (Hook_GenericFireOutputWrapperDiag/GENERIC_FIRE_OUTPUT_WRAPPER_OFFSET 0x1A7920 sub_1A7920-entry hook from cont.54's dropped idle-timeout thread, and Hook_Sub170EC8Diag/SUB_170EC8_OFFSET added and removed this entry) - their Install* calls are gone from main.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: TriggerTrueDirectCarSelectJump remains crash-prone on a genuinely cold session (no real event_details visit this session) - must still only be called after at least one real (or cont.45/46-style brief) event_details visit, 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: find dword_AD2A08+12's real setter (finish the vtable parse, or search disassembly for LDR/MOVW+#0xAD2A08-adjacent code combined with a #0xC field store) and either call it directly (matching the GetOutputNode pattern) 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+12 dynamically instead of by further static tracing - checked the vtable at off_AA5B94 fully (only 5 real entries: Serialize(), an empty nullsub, Reset(bool), the destructor, and delete - 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_AD2A08 itself 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_AD2A08 to a dedicated shadow copy of the singleton, mprotect it read-only, and catch the very next write via a SIGSEGV handler that reads the exact field offset plus the writing instruction's own pc/lr straight from the signal's ucontext - 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-stop to recover) once the chain ran deep - root-caused to calling mmap() from inside the signal handler on every rearm: the interrupted code was itself deep in a malloc-using insert-loop and almost certainly already held an internal allocator lock that the handler's own mmap() then needed too, classic signal-handler self-deadlock. Fixed by pre-allocating a whole pool of pages with a single mmap() call safely outside any signal context during install, so the handler itself only ever does memcpy (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 - +12 simply 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, set true the moment Hook_LayoutScreenCtor sees a real EventDetails construction (covers both genuine player navigation and the cont.45/46 auto-skip flash - both construct a real EventDetails object, unlike the tree-only warm-up which never does). TriggerTrueDirectCarSelectJump now requires this flag in addition to g_capturedEventDetailsTreeRef before 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 EventDetails visit yet: TriggerTrueDirectCarSelectJump: no real EventDetails construction yet this session (tree warm-up alone isn't safe - see cont.55) - ignoring - clean refusal, zero crash, pidof confirmed alive. (2) warm - fired TriggerOpenCarSelectOnDemand once (real EventDetails construction via the auto-skip path), navigated back to the map, then fired TriggerTrueDirectCarSelectJump: singleton=0xbf45da60 *(singleton+12)=-1084370432(0xbf5dd200) - non-null, confirming the real visit is what populates it - RestrictedGarage constructed cleanly, no crash, screenshot confirmed a fully rendered, interactive car_select screen.
  • Snapshot: lan_event_injection.h only - added g_realEventDetailsVisitHappened (declared next to g_eventDetailsTreeWarmedUp, with a comment correcting the earlier over-optimistic framing), set in Hook_LayoutScreenCtor's EventDetails branch, checked in TriggerTrueDirectCarSelectJump. The watchpoint infrastructure (ArmSingletonWatchpointFromCurrent/SingletonField12SigsegvHandler/InstallSingletonField12Watchpoint/WATCHPOINT_POOL_PAGES) stays in the file, fully functional and deadlock-fixed, but its one call site in Hook_LayoutScreenCtor is commented out - not wired into any build path by default. No other files touched.
  • Outcome/Next: TriggerTrueDirectCarSelectJump is 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 real EventDetails construction 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 populates dword_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 EventDetails visit 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 real FireEventOutput call instead of at MapOverworld construction - 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 +12 on 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 inside FireEventOutput (sub_17A99C, already a known, resolved function pointer in this project since cont.26-28). FireEventOutput internally: resolves the raw event key via ResolveHandle (already known, RESOLVE_HANDLE_OFFSET) against a global "EVENT ID " cache context, builds a small refcounted wrapper around the resolved event data, calls sub_240294(singleton, resolvedEventData) to stash it as the state singleton's "current event context" - then, only after that, calls the same low-level FireOutput/sub_1BB59C this project already knew about. TriggerTrueDirectCarSelectJump had always called FireOutput directly (skipping FireEventOutput entirely, cont.48's original design), so it always skipped this resolve-and-populate step - explaining exactly why a prior real FireEventOutput-based visit (via TriggerOpenCarSelectOnDemand, cont.44) was needed first: that's the only thing in this whole file that ever actually ran sub_240294.
  • Real fix, not a workaround: TriggerTrueDirectCarSelectJump now calls FireEventOutput(g_mapScreenInstance, 0xC0FFEE00u) (our own synthetic event's key, already a resolved pointer, the exact primitive TriggerOpenCarSelectOnDemand already uses) instead of building an "EVENT" name slot and calling FireOutput directly. The +28 tree substitution beforehand is unchanged and still does its job - it governs which tree FireEventOutput's own internal FireOutput call 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) reads 0 immediately before the fix's own FireEventOutput call 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 (pidof alive, 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 TriggerTrueDirectCarSelectJump as the very first call on a genuinely cold session (right after boot, before any real EventDetails visit) 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 (same FireEventOutput call, no +28 substitution) 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-substituting flowNode+28 to g_capturedEventDetailsTreeRef (the cont.55 warm-up's GetOutputNode-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 genuine EventDetails construction - 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 existing g_realEventDetailsVisitHappened gate (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 -h down 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 check free -h and the lock state before trusting a "nothing happens" result as code behavior.
  • Snapshot: lan_event_injection.h only. TriggerTrueDirectCarSelectJump now calls FireEventOutput instead of FireOutput+manual InternString/ctx setup (the actual fix). TriggerOpenCarSelectOnDemand's temporary watchpoint-arming call (used only for this investigation) removed again. WATCHPOINT_POOL_PAGES left at the smaller 300 (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 in TriggerTrueDirectCarSelectJump kept (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_realEventDetailsVisitHappened requirement 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 whatever g_capturedEventDetailsTreeRef itself 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 established game_events.h/GameEvents.kt JNI bridge (GameEventListener.onUpgradesAccepted, with register()/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 described sub_27D1EC as 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_27D1EC directly in IDA before trusting the A/B result, and found the original cont.29 comment itself was imprecise: the function is v1 = *(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 real calloc() pointer is always far larger than 7, so this function always takes the else 0 branch 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 categoryTag calloc'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 same a1/object, i.e. sub_27D1EC's own real argument may not be RaceEvent at all. Whoever picks this up next should trace sub_27D1EC's actual callers/argument first, not assume continuity with the already-understood label-display path.
  • Car+color capture (part 2) - two live dead-ends, one documented promising-but-large path found:
    1. Tried reading RestrictedGarageLoadout's own construction parameter (a2 in Hook_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. a2 is flow/layout-resource construction metadata (which screen/resource to build), not car selection data at all.
    2. 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, required force-stop), almost certainly because something in that call chain holds an already-fetched reference to the real singleton pointer that would diverge once dword_AD2A08 got 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 PC 0xce2a176c, both attempts, offsets far outside the singleton's own 208-byte size, with implausible/garbage LR values 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.
    3. 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/CarPaintSelectionWidget are 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 to im::app::ui::CarSelectButton's own click handler. That class's vtable/constructor haven't been examined yet.
  • Snapshot: lan_event_injection.h only. Net change versus before this entry is close to zero shipped functionality: the RestrictedGarageLoadout a2-dump diagnostic and the InstallInPlaceSingletonWatchpoint() call site (added, then both removed after serving their purpose) are gone from the hot path; the categoryTag index write is reverted; kSyntheticEventClassIndex and its call site are removed entirely. The InstallInPlaceSingletonWatchpoint/InPlaceSingletonSigsegvHandler infrastructure 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 (not RaceEvent, per the two-incompatible-readers finding above). Car+color capture needs im::app::ui::CarSelectButton's own click-handler vtable slot found and hooked, following the Hook_ModSlotSelected pattern 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, mirroring Hook_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 via data_ref), but nearly every slot (95-102 xrefs each) turned out to be generic, shared boost::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 via data_ref to the vtable's own address) and, live-tested with the user's go-ahead, a per-instance "car reference" field at +264 set 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_35E134 is 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 the sub_394F78 factory 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. CarSelectButton is 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.h and main.cpp only. The InstallCarSelectButtonCtorHook()/Hook_CarSelectButtonCtor diagnostic (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::CarSelectButton and 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 (the sub_38DA44 factory-registration function registers many other named widgets besides SelectCar - 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") in sub_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 as CarSelectButton. This confirms sub_38DA44's full 94-call-site registration table is a flat list of ~94 distinct, narrowly-scoped ...Button subclasses (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 zero CarSelectButtons (or anything else from this registry) ever construct while browsing.
  • Property-binding system (sub_152250, "/Cars/Current Car"), run down properly this time: sub_152250 itself is a generic "bind a string property path to a component slot, trying each supported value type in turn" utility (calls out to sub_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_to on sub_152250 itself, not just the string) and traced each to its owning class/context:
    1. sub_20E088 (called from sub_20DD38sub_20B5C4sub_20AF30, whose constructor's own RTTI vtable comment reads im::app::layers::debug::CarPerformanceLayer) - binds "/Cars/Current Car" into a slot on this debug overlay, refreshed on every property-change notification.
    2. sub_210BD0 (called from sub_210880sub_20F5CCsub_20E914, RTTI-confirmed im::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 shared sub_152250 utility for some other property list.
    3. 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/+20 fields) - 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 at SINGLETON_INSTANCE_OFFSET as 52 raw DWORDs and logs them. Called from two points: once as a baseline right when car_select itself constructs (screenName=="RestrictedGarage", in Hook_LayoutScreenCtor), and again - purely observationally, no interception - right when its own confirm checkmark fires "CONTINUE" (new budget-limited branch in Hook_FireOutputDiag, gated on g_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/+64 exactly reverted to their original Ford values (+28 stayed constant across all three - not car-specific; +68 drifted slightly, likely an unrelated frame/session counter) - a real, reproducible, revert-verifiable per-car signal, not noise.

  • +24 decoded 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.h only - DumpSingletonFields (now also dereferencing +24/word-index-6 and dumping+logging its first 16 words, added after the first successful test confirmed +24 as the signal), one baseline call site in Hook_LayoutScreenCtor, one CONTINUE-time call site in Hook_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 +24 points 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 +24 is confirmed - keep only +24 and maybe +28/+68 until their meaning is understood too), (2) extend game_events.h/GameEvents.kt with a new event (e.g. onCarSelected(carId: String)) mirroring FireUpgradesAccepted's JNI pattern, fired from the same Hook_FireOutputDiag CONTINUE 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). Added FireCarSelected(carId) to game_events.h (NewStringUTF/CallStaticVoidMethod, same shape as FireUpgradesAccepted, new cached g_onCarSelectedMethod) and onCarSelected(carId: String)/dispatchCarSelected to GameEvents.kt, mirroring the existing GameEventListener pattern exactly. Hook_FireOutputDiag's car_select-CONTINUE branch now calls GetCurrentCarId() + FireCarSelected(carId) directly (no more budget-limited raw-dump logging). The unused ctor-time baseline call and its dump helper were removed from Hook_LayoutScreenCtor too, 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_descGameEvents: 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/GameEvents surface as onMapLoaded/onUpgradesAccepted - ready for a real Kotlin-side consumer (UI layer or future native-client bridge) to register() 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+176 from 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/+64 probed and ruled out, +64's own +16 field 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 _desc suffix). +60's begin pointer → empty string (likely an unused "custom name" field). +64's own +16 field 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/+16 as 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 from ProbeCarColorFields rather 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 via sub_246950 (called by the RESPRAY popup's PAINT1..PAINT6 swatch 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, independent GetInstance()-style singleton getter (own "s_Instance"/"Not initialised" assert pair, global dword_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 at registryPtr+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) - returns node+1 (the 6-word/24-byte value region) on a hit.
    • Wired both as real function pointers (GetCarRegistry/LookupCarRecord, resolved in InstallMapTrackHandleEventHook alongside 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 own CONTINUE.
    1. Ford F-150 SVT Raptor, repainted blue (2nd swatch) → record+4 = 1. Verified this survived a full app process restart (the transient singleton+176 field correctly reset to 0 on the same read, record+4 did not - direct proof this field is the real persisted color, not session state).
    2. Ford Focus RS500, repainted green (2nd swatch in its own palette) → record+4 = 1 (consistent - same swatch position).
    3. 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 via GetCarRegistry()/LookupCarRecord(carId) - not tied to any specific UI screen or event.
  • Upgrades: found why there's no persistent storage, not just that there isn't one - three-round live investigation:
    1. Live-tested record+8/record+12 (which sub_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).
    2. Traced the purchase path itself (sub_247640, uses the interned string "upgrade" for the cash deduction) to sub_24D5B4, which builds a composite key {carId, upgradeTypeId, ServerTime} (own assert name: "PendingUpgrade") and passes it to sub_257894.
    3. Decompiled sub_257894 and 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/transient std::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 ServerTime field fits this), and in this offline/LAN test environment it never resolves into any lasting state. The already-existing Hook_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=0x1 for "ВОССТ. ШИНЫ", matching that hook's own pre-existing code comment.
  • Snapshot: lan_event_injection.h only. Added GET_CAR_REGISTRY_OFFSET/LOOKUP_CAR_RECORD_OFFSET + GetCarRegistry/LookupCarRecord function pointers (resolved alongside the file's other primitives), DumpCarRecord() + ProbeCarColorFields() + TryLogAsString() (all still TEMPORARY/diagnostic, budget-limited, not yet wired to game_events.h/GameEvents.kt - color is understood but not yet exposed over the JNI bridge the way carId already 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.sb car files) via NFSMW12MobileTools.jar, found each car's own resource file has a PaintJob struct list (Name + SwatchColor/SwatchColor2 DATA_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 (a std::vector<PaintJobDescription>'s begin pointer, 112 bytes/element). Found the real call site/argument via sub_188024 (car_select's own screen Tick, 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 familiar dword_AD2A08 singleton 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 as CarDescription* - live-tested, got back an obviously-garbage pointer (0x66647342, which is literally the ASCII bytes of "Bsdf" misread as an address) and a real SIGSEGV when dereferencing it (fault addr matches exactly). Corrected to singleton+56 per the sub_188024 trace - live-tested again, got a real, sane 112-byte record, and its word[0] dereferenced to the string "Orange" with +96 (packed RGBA) decoding to R=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) - +0 Name, +16 DiffuseTextureFilePath, +32 DiffuseMaskFilePath, +48 BRDFFilePath, +64 BRDFSpecularResponseFilePath, +80 NumberPlateTextureFilePath - then +96 SwatchColor (packed RGBA, one byte per channel), +100 SwatchColor2, +104 Type (int32), +108 UseVinylMap/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 from GetCarRegistry()/LookupCarRecord() (cont.63, for the saved color index) + the new GetPaintJobDescription chain. FireCarSelected (game_events.h) and onCarSelected/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, onUpgradesAccepted fires 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.kt already exposes triggerCarSelectTest()/triggerTrueDirectCarSelectJump() (native jump straight into car_select, PROGRESS.md cont.44/48/57) and GameEvents.kt already fires real onCarSelected/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:
    1. First pass: portrait phone mockup, 9 screens, Compose-overlay pattern reused from CarSelectionBadge.
    2. 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.
    3. 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 (isValidAddr accepted out-of-range IPv4 octets like 999.1.1.1 — tightened to a proper per-octet 0255 check) and an initial-screen index bug (state.screen started on the lobby browser instead of the map).
  • 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.html and LOBBY_UI_DESIGN.md added at repo root (persisted, not just an ephemeral Artifact); ARCHITECTURE.md trimmed. No files under launcher/ or native_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 actual mpcore/app Compose 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: +132 on 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 against RaceEventCtor (sub_2A4B58)'s own decompile: it explicitly zeroes this same +132 field, confirming 0/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) into InjectSyntheticEvent (kSyntheticEventCarClass constant) — live-tested with 1 (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 as SINGLETON_CARCLASS_FILTER_OFFSET, with its own (third!) index scheme decoded from sub_165630's own switch (0=MUSCLE, 1=SPORTS, 2=GT, 3=SEDAN, 4=SUV, 5=SUPER, 6=ALL) — added a small conversion table (RaceClassToUiFilterIndex) so kSyntheticEventCarClass stays the single source of truth for both fields.
  • Setting singleton+68 early (in InjectSyntheticEvent, 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_165630 doesn't just set the field, it also calls sub_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 decompiled sub_16692C fully: clears the old list, calls sub_23FF50(&result, sub_890EC(), 1) (an "enumerate owned cars filtered by the current class field" style call) then populates UI rows one by one via sub_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 as Hook_LayoutScreenCtor's own a1 for screenName=="RestrictedGarage".
  • Tried calling RebuildCarClassList(a1) (resolved sub_16692C function pointer) right after RestrictedGarage constructs, using the already-captured screen instance — live-tested, crashed for real: SIGSEGV, "Dereferencing a NULL component pointer", fault addr 0x44, inside sub_16692C itself, with a1 correctly matching the just-built screen instance (confirmed via the log line printed immediately before the crash). Some dependency sub_16692C needs 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.h only. Added RACEEVENT_CARCLASS_OFFSET/SINGLETON_CARCLASS_FILTER_OFFSET/REBUILD_CAR_CLASS_LIST_OFFSET + kSyntheticEventCarClass/RaceClassToUiFilterIndex, both field-writes wired into InjectSyntheticEvent (live, active, safe), RebuildCarClassList function pointer resolved but its one call site removed after the crash. kSyntheticEventCarClass left at 0 (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 call RebuildCarClassList(a1) — candidates matching this file's own established patterns for "needs to happen slightly after construction settles" are a short deferred timer (like g_autoSkipEventDetailTargetPending's own delay mechanism) or piggybacking on the screen's first real per-frame Tick (sub_188024, already known from cont.42/43) instead of its constructor. Decompiling what specifically sits at offset 0x44 relative to whatever's null at crash time (likely one of sub_16692C's own referenced sub-fields on a1, 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 mpcore as 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 are CashReward (a race event's bronze/silver/gold reward definition, §6aa/6z) and the ISaveable family (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 in DEBUG_MENU.md §3.
  • Reused, not reinvented, the existing overlay mechanism: same ComposeView-added-to-mFrameLayout pattern as CarSelectionBadge (CarSelectionOverlay.kt, cont.63b), same GameEventListener-as-Compose-state adapter shape, and the same already-proven GameEvents.onMapLoaded() signal (fires off the existing MapTrack::HandleEvent native hook — no new native hook needed) to decide when the button should appear.
  • New files: mpcore/src/main/java/nfs/mod/mpcore/DebugFeatures.ktobject DebugFeatures { var debugMenuEnabled: Boolean = true }, the one explicit gate for all debug tooling, deliberately placed in mpcore (not app) per direct instruction. app/src/main/java/com/ea/ironmonkey/DebugMenuOverlay.ktDebugMoneyState (local Compose state, apply() setter), DebugMenuVisibility (GameEventListener, tracks mapLoaded), registerDebugMenuListener(), and the DebugMenuButton/DebugMoneyDialog Composables (FAB → AlertDialog with a digit-filtered OutlinedTextField + Apply/Cancel).
  • Wired into GameActivityMain.kt: new buildDebugMenuOverlay() (bottom-end corner, deliberately opposite CarSelectionBadge's top-start corner so the two never overlap), added to mFrameLayout in onCreate only if (DebugFeatures.debugMenuEnabled) — flipping the flag off means the view is never built or added, not just hidden.
  • Verification: ./gradlew :app:compileDebugKotlinBUILD SUCCESSFUL, no new errors/warnings beyond pre-existing unrelated deprecation warnings elsewhere in GameActivityMain.kt. Not yet exercised on-device (no adb/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 in mpcore) added to ARCHITECTURE.md §4d, same split pattern as LOBBY_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 RebuildCarClassList from 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 hooking sub_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's onRestart lifecycle resuming a backgrounded activity, not a fresh construction - re-tested properly with a full am 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 after RestrictedGarage constructs - 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 addr 0x7 this time (vs 0x44 for 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_16692C most 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 offsets 0x44/0x7 on 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 - only RACEEVENT_CARCLASS_OFFSET's field-write (the working label) and RebuildCarClassList's own function-pointer resolution (a proven-real primitive, kept resolved for whenever this is picked up again) remain. kSyntheticEventCarClass back at 0 (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_16692C actually need, and does our synthetic event flow have it at all" question. Recommended next step if resumed: test the exact same RebuildCarClassList(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-decompiled GetPaintJobDescription/sub_B3564 and found its own out-of-range handling is broken - it logs an assertion but still returns vectorBegin + 112*index using the original, still out-of-range index, 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 plain malloc (not calloc) and never explicitly zeroed - so its own +4 field (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 bounds sub_B3564 itself reads (carDescPtr+104/+108), validates them, and clamps colorIndex to [0, count) itself before ever calling GetPaintJobDescription - 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 left true and has zero scoping: Hook_BuildTrackScenePath unconditionally overrides the track/environment for every race load in the game, not just our own synthetic LAN test event - confirmed via main.cpp, no gate at all. Live-reproduced on a genuinely fresh save: SIGSEGV, null pointer deref, fault addr 0x14, 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's PreRaceLoadingScreen. 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: default kEnableTrackSubstitutionHook to false - 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 (kEnableTrackSubstitutionHook default flipped to false). 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 on g_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 from MapTrack::RefreshEvents/sub_369040 whenever 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_77B70sub_251188sub_77B70sub_2515D0/sub_250F34) and reading a reflective "medal" property off it (sub_4F99F0/sub_4F9A80, the same generic property-getter pattern already reverse-engineered for CashReward's Bronze/Silver/Gold in earlier sessions) - this directly drives RefreshEvents's (this+276)/(this+272)*100 completion badge. Also found a same-shaped per-driver "medal" write path (sub_235798/sub_236ED0, building/reading a results-table row with name/vehicle/localPlayer/time/medal columns) 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/ping firing 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 same sub_77B70/sub_251188/sub_2515D0 family), re-audited every hook main.cpp installs unconditionally (i.e. not behind any of the existing kEnable* toggle flags): CopSoundsTickSkip, GetComponentNameSkip, StrlenNullGuard, InternStringDiag, FatalLogCallerTrace, ResolveDisplayText, ResolveDisplayTextWrapperDiag, LayoutScreenCtor, ModSlotSelected, FireOutputDiag, FlowNodeTick. Added a temporary kEnableDiagnosticAndTestHooks toggle to install none of them (keeping only MapTrackHandleEvent/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 except GetComponentNameSkipHook - live-tested again, medal still recorded correctly - isolating that one hook as the sole cause.
  • Root cause: GetComponentNameSkipHook (Hook_GetComponentName, hooking sub_240548) was added in cont.29/30 as a workaround for one narrow SIGSEGV: our own synthetic test actor (from InjectSyntheticEvent, never going through normal prefab registration) wasn't found in a hash-table lookup inside GetComponentName, 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." Decompiling sub_240548's actual callers found this was never "purely cosmetic" for real gameplay: sub_240294 doesn't just log the returned name, it writes it into a named-cache-context object's own +8 field (*(a1+8) = sub_240548(...), the cache context itself resolved via sub_7566C - the same cache-context accessor the medal-lookup chain in AddEvent also uses), and sub_240294 is called from sub_17A99C - the same real map-event-processing function this project's own FireEventOutput targets. 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 from JNI_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 from JNI_OnLoad, function itself left in place - now in crash_workarounds.h after 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.h had 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 from JNI_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 shared InstallArmTrampolineHook(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. Every Install*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/GetCurrentCarColor and 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 in InjectSyntheticEvent's FakeActor construction, not a standalone car-read concern.)
  • New crash_workarounds.h: the narrow, single-purpose crash mitigations from cont.29-34 (CopSoundsTick skip, GetComponentName skip [kept disabled per cont.67's finding, with that lesson documented directly on the hook], Strlen null-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 touches game_events.h's FireUpgradesAccepted.
  • 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 (GetComponentNameSkipHook correctly still absent, per cont.67) and that LAN synthetic-event injection + map navigation behave identically to before the refactor (same MapTrack enumeration, same synthetic-event injection log line, same WARM-UP capture) - no crash, no missing hook, no behavior change.
  • Snapshot: lan_event_injection.h 3149 → 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 on lan-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.json files (62 events, from an earlier session) plus two freshly-unpacked full track scenes (region4_chicago_track1, region1_foothills_track1, ~75k DATA_Elements each), 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). The 0xcfeb5c/0xcfeb8c/0xcfebb8 vtable addresses recorded in ANALYSIS.md §3.1 are stale (from an old .i64) — real addresses are 0xaa7d28/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=CarDescriptionName string, +36=ColourIndex int) 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 real TrackNavigator::Resolve (sub_3261B0) — the spline-distance+lateral-offset → world-position transform ARCHITECTURE.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 existing CopSoundsTick hook. A second caller (sub_F8078) not yet examined.
  • Civilian traffic: confirmed data-level (RaceEvent.TrafficCarCount, TrafficFlow component) but the runtime spawner wasn't traced — likely TrafficCarCount=0 alone is sufficient (same pattern as every other per-race data override in this project), needs a live A/B test to confirm.
  • Snapshot: ANALYSIS.md new §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. No main.cpp/lan_event_injection.h changes — this entry is planning/RE only.
  • Outcome/Next: subtask 2 is now RE-complete enough to start implementing hooks. Recommended order: (1) OpponentCollection substitution 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 the TrackNavigator::Resolve finding.

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): hooked OpponentCollection::PopulateFromProperties (sub_2B649C) and StreetRaceStartingGrid'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 via adb UI taps): confirmed they are NOT the same objects - StreetRaceGrid::Place's vector held addresses that never appeared in OpponentCollection'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/ColourIndex to a fixed test car ("ford_focus_rs500_2010_desc") at Populate time (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 same Opponent objects (or a copy taken strictly after Populate runs) - 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): hooks sub_F7E9C (the cooldown+count-check gate before SpawnCopCar) 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.TrafficCarCount is not a plain fixed-offset field, it resolves through the same reflective per-name property lookup already seen for "medal" (cont.67) and ColourIndex. A post-deserialization field overwrite (the plan from cont.69) doesn't apply the way it did for TrackName/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, both false) 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, new cop_traffic_disable.h, both wired into main.cpp's JNI_OnLoad (unconditionally installed, behavior internally gated by the runtime flags above - diagnostic logging stays on, low-risk/budget-capped). ANALYSIS.md new §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 concluded TrafficCarCount needed 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, on sub_2A4D70 itself, zeroing +116 after the real deserializer runs. Confirmed via logcat: fired once per event at map-load time, correctly zeroed real non-trivial values (1, 2, 10 for one event) to 0, 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.h now 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::Place hook that calls the same two real engine primitives (sub_291BA4 for opponents, sub_2914DC/PlaceCar directly for the player) in a randomly reordered sequence - rand() picks which of the count+1 slots 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/5 on 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 flags false).
  • 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), TrafficCarCount zeroing (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) returns v13/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.5 lands exactly on the same center (0) whenever its index a6 ≡ 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 normal MinDistanceBetweenRacers..MaxDistanceBetweenRacers gap - a spacing sized for two cars in different lanes, not the same one. When the player's random slot landed adjacent to an opponent with a6 ≡ 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)*2 instead of the normal minDist..maxDist) in Hook_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 to false, rebuilt, reinstalled - device left in a safe, normal-play state.
  • Snapshot: opponent_substitution.h's Hook_StreetRaceGridPlace gets 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_F5EA4 is a per-tick dispatcher, itself only called from sub_F5BB4 (CopManager's broader per-tick Update, which also runs unrelated bust-timer/etc. bookkeeping - too broad to skip wholesale). sub_F5EA4 branches 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 same SpawnCopCar/sub_F85B8 (confirmed via xrefs_to - exactly 2 callers, both now covered) - hooking only sub_F7E9C left sub_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_F7E9C to the shared dispatcher sub_F5EA4 - one hook now covers both leaves, without touching sub_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=0 field-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_33D734 is TrafficCarSpawner's populate function (own assert string confirms the class name), called twice per race from sub_33C020 (once per traffic direction/lane-group), with its max-count parameter read from a completely different object (*(a1[3]+16)/*(a1[3]+20)) than RaceEvent.TrafficCarCount. Hooked sub_33D734 directly, forcing that max-count parameter to 0 - but the underlying loop has a do { ... } while shape 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 via xrefs_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_F5EA4 for cops, sub_33D734/sub_33C020 for 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/InstallCopSpawnSchedulerSkipHook renamed to Hook_CopSpawnDispatcher/target sub_F5EA4 (was sub_F7E9C); new Hook_TrafficCarSpawnerPopulate/InstallTrafficCarSpawnerZeroHook hooking sub_33D734, gated by new g_enableTrafficSpawnerZeroTest. opponent_substitution.h's Hook_StreetRaceGridPlace unchanged this entry (already fixed in cont.73). All test flags reverted to false and 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 by sub_2A8CE8/sub_33C020 - for a per-tick method reading the a1[28]/a1[30] handle slots sub_33C020 writes) 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 same sub_10B09C/sub_D5138 transform-write pair already known from PlaceCar (subtask 2.2) and SpawnCopCar (subtask 2.4) - the actual moment any car's position becomes real, third confirmed user of that pair. Exactly two callers, found via xrefs_to: sub_2A0470 and sub_C201C.
  • First attempt (disproven live): sub_2A0470 is registered as the handler for a "ResetLine" event via sub_31AAEC (the same event-subscription idiom SpawnCopCar uses 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_2A0470 isn'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.001 pattern). Calls sub_C26A0 twice: 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 fields sub_C26A0 itself writes on its own "can't resolve position yet, queue it" fallback path. Distinct field layout from PlaceCar/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_C201C skip 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). The sub_2A0470 hook 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 - new Hook_TrafficCarControllerTick/InstallTrafficCarControllerSkipHook on sub_C201C (g_enableTrafficControllerSkipTest), new Hook_TrafficResetLineHandler/InstallTrafficResetLineSkipHook on sub_2A0470 (g_enableTrafficResetLineSkipTest, kept as a harmless secondary hook despite not being the primary fix). main.cpp installs both unconditionally (behavior gated by the flags). All traffic/cop test flags reverted to false and 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 in bmw_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 .so strings). 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 the RaceEvent's own "Opponents" property (sub_2B649C calls sub_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 truncate OpponentCollection'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_C201C traffic-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: hook sub_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 time TriggerOpenCarSelectOnDemand()'s FireEventOutput() 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/trueDirectCarSelectTestReceiver in GameActivityMain.kt called straight into JNI from BroadcastReceiver.onReceive() (the Android main thread) - the only call sites in that file that skipped the gameGLSurfaceView.queueEvent { } wrapping every other native-touching call site there correctly uses to marshal onto the GL/render thread, where MapScreen/FlowNode state actually lives. Fixed by wrapping both in queueEvent { }. Confirmed via tombstone that the crash's thread changed from the main thread to the correct GLThread after 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-existing Hook_FireOutputDiag's output="EVENT" log line across every crash). FireEventOutput builds 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_240548 and sub_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_PrefabLookupDiag on sub_240548, collapsing a corrupt end<begin range 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 likely sub_173350, the twin lookup/error-builder one level up that sub_240294 also 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 reaching sub_240294/sub_240548 as their a2, a downstream user of that zeroed triple is the more likely actual crash site than anything inside sub_240548 itself; not yet confirmed with a hook on sub_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 the 0xC0FFEE00 synthetic-key family was first chosen (cont.19-ish era), never triggered before because nothing had called FireEventOutput this way (out-of-band, via broadcast) until this session's synthetic-test push.
  • Snapshot: lan_event_injection.h gains Hook_PrefabLookupDiag/InstallPrefabLookupDiagHook on sub_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 debug BroadcastReceivers now correctly wrapped in queueEvent { } (real fix, keep). cop_traffic_disable.h gains Hook_CarReset/InstallCarResetOnceOnlyHook on sub_C26A0 (g_enableTrafficOnceOnlyResetTest) as the corrected traffic fix, superseding cont.75's sub_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-false safe baseline before the session paused - next session must explicitly check g_enableCopSpawnSkipTest/g_enableTrafficOnceOnlyResetTest/g_enableBlanketOpponentSubstitutionTest/g_enableRandomPlayerGridPositionTest in cop_traffic_disable.h/opponent_substitution.h and revert any left true before 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 until sub_173350's own entry is instrumented the same way sub_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.