Files
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

219 KiB
Raw Permalink Blame History

ANALYSIS.md — libapp.so Reverse Engineering Notes

Target binary: native_lib/libapp.so (NFS Most Wanted 2012, Android v1.3.128, Firemonkeys/Iron Monkey engine, ARM EABI5 32-bit, Thumb/ARM mixed). IDA database: native_lib/libapp.so.i64 (opened via ida-pro-mcp idalib MCP server, session handle re-created per session — see "IDA session" below).

This document is a living record. Update it as findings are made; do not wait until the end of a session.


1. Provenance of prior findings (from chat-export-1785094024785.json)

A previous chat session (Open WebUI, model "deepseek-v4-flash uncensored", 2026-05 timeframe) analyzed a libapp.so downloaded from a third-party file-sharing link (private-ai.tools/files/*.so) inside a sandboxed Linux container, using radare2 + hand-written Python byte-scanning — not IDA, and not this project's native_lib/libapp.so. The user re-uploaded the file at least 3 times under different hashes during that conversation, so it is unclear whether it was ever the same build as ours.

Verification performed this session: opening our actual native_lib/libapp.so.i64 and searching for the same RTTI/mangled class names confirms this is the same engine and the same general class taxonomy (see §3), but at completely different addresses — the old chat's addresses top out around 0x00350000 while our .text segment alone spans 0xa1aa80xc28c44 and the image is 0xe52ebc bytes (~14.9 MB). The old chat's specific offsets, vtable layouts, and struct field guesses do not apply directly and must be re-derived. They are recorded below only as architectural hypotheses, not verified facts.

The old chat's generated "C++ bindings header" was also never actually completed — the final message was a Python script emitting a header template whose format-string placeholders (0x%08X) were never substituted with real values (the export cuts off mid-generation). So no usable header exists from that session; it must be rebuilt from scratch against our binary.

1.1 What the prior session got right (reusable knowledge)

  • Game/engine identification: EA "Iron Monkey" engine (im::app::*, im::components::* C++ namespaces), Need for Speed Most Wanted 2012 Mobile. JNI entry points under com.ea.ironmonkey.*.
  • SB/SBA resource format (fully reconstructed from NFSMW12MobileTools Java source, not from the binary — this part is source-verified, not a guess):
    • Header: "SBIN" (4 bytes) + version (1 byte, 0x03 for NFS MW12).
    • Chunk stream, each chunk: sig(4) + data_size(4, LE) + fnv1_32_hash(4, LE) + data[size] + pad-to-4.
    • Chunk types: ENUM, STRU, FIEL, OHDR, DATA, CHDR, CDAT, BULK (texture mip offsets), BARG (raw texture bytes).
    • Field types (SBinFieldType): INT8/16/32/64, FLOAT32, BOOLEAN, ENUM_ID_INT16/32, CDAT_STRING, SUB_STRUCT, GAME_UNIT, plus opaque HEX_* types.
    • Ordinary .sb: SBIN→ENUM→STRU→FIEL→OHDR→DATA→CHDR→CDAT. Save files swap ENUM after STRU/FIEL. Texture .sba appends BULK→BARG.
    • Save files are NOT parsed into an object model by NFSMW12MobileTools — only HEX + string edits are supported for saves. Confirmed present in our game_cache/ (see §5).
    • This matches the directory layout we actually have: game_cache/published/{prefabs,data,flow,stringdata,textures,models,sounds}/... (see §5).
    • ⚠️ Tool reliability caveat: NFSMW12MobileTools.jar unpack does not reliably unpack every .sb/.sba file — it has real, reproducible parser bugs (confirmed example: region3_colorado_track2.scene.sb throws NegativeArraySizeException in SBin.getCleanElementHex/parseDATABlock on every attempt, while sibling files like region3_colorado_track1.scene.sb from the same directory unpack cleanly with identical invocation — see §6m). This is a pre-existing bug in the third-party tool, not something introduced by this project, and not worth "fixing" upstream for our purposes. Don't treat a crash as "this file can't be analyzed" — try the workarounds first: -disableDATAObjectsUnpack (skips per-object field parsing but still emits the top-level CDAT_Strings table, i.e. every unique string referenced anywhere in the file — often enough on its own to answer "does entity X exist / what's it named", exactly as used in §6m) or -disableMipmapUnpack (for .sba texture-pack issues). If a specific file still won't unpack under any flag, a same-directory sibling file is usually a decent structural proxy (same region/asset-authoring convention) when an exact answer isn't required. Command reference: java -jar NFSMW12MobileTools/NFSMW12MobileTools.jar unpack <file.sb> [-disableMipmapUnpack] [-disableDATAObjectsUnpack] — run from a directory containing HCStructFileArray.json (copy from the tool's own repo root) alongside the target file; output is <file>.json next to the input.
  • High-level race-loading architecture (conceptually confirmed, see §3 for real anchors in our binary):
    • Menu → Flow-machine (SB-scripted reactive state machine, /published/flow/*.sb) → FlowAction "DoLoadRace"RaceLoaderTask (boost::shared_ptr-managed, polymorphic) → parses RaceDefinition from /published/data/races/<id>.sb → builds OpponentCollection (array of Opponent descriptors) → RaceStartingGrid/StreetRaceStartingGrid places cars using TrackNavigator (track-spline coordinate system: distance_along_spline, lateral_offset, height_offset ↔ world Vector3) → loads car/track prefabs → InRaceState (namespace im::app::race::states) runs the race.
    • Important safety conclusion (still believed valid conceptually): RaceLoaderTask cannot be safely constructed and invoked from an arbitrary moment — it's a boost::shared_ptr-owned polymorphic task queued through the Flow-machine, with refcount fields checked internally; calling it out of context risks a crash. The safe integration pattern is hook, don't originate: let the game's own Flow-machine create/drive RaceLoaderTask normally (player picks any existing event from the menu), and intercept it at well-defined points (opponent-list build, starting-grid placement, track-navigator position resolution) to substitute custom data — rather than trying to synthesize the whole load sequence from scratch.
    • This maps directly onto our mod's needs: subtask 2 (arbitrary map + N players) and subtask 4 (coordinate interception) are naturally the same hook points.
  • Text rendering claim in the task brief needs correction (see §4) — the previous chat never investigated this; it's a fresh finding this session.

1.2 What must be re-derived from scratch (do not trust old numbers)

  • All function addresses (RaceLoaderTask::*, OpponentCollection::Build, TrackNavigator::*, Health::*, NitroBehaviour::*, etc.)
  • All vtable layouts and indices
  • All struct field offsets (the Opponent "0x50 bytes" struct, TrackNavigator fields, etc. — these were never disassembled against our binary, only guessed by pattern-matching a different file)
  • The claimed GOT/PLT/vtable addresses in the old chat's "priority hook map" table

2. IDA database state (libapp.so.i64)

  • Opened via idalib MCP (idb_open), auto-analysis + Hex-Rays already available (hexrays_ready: true).
  • 50,772 total functions, only 2,789 named (~5.5%) — the rest are sub_XXXXXX. No demangled C++ names have been applied to functions yet (searching list_funcs for *RaceLoaderTask*, *Opponent*, *TrackNavigator*, *Health*, *Nitro* returns zero function matches), even though the mangled RTTI name strings for these classes exist in .rodata/data segments and are found via string/regex search. In other words: the .i64's size (192 MB) comes from IDA's analysis caches (Hex-Rays microcode, xrefs, etc.), not from prior manual RE work — there is no pre-existing "someone already named all the classes" state to build on. We are starting from a clean, auto-analyzed base.
  • Segments: .text (0xa1aa80xc28c44, ~11.7 MB), .ARM.extab/.ARM.exidx (unwind tables), .rodata (0xc810100xd6fbc0), .data.rel.ro.local (0xd715580xd77ce4), .init_array/.fini_array. Image base is 0x0 in the IDB (i.e., all addresses here are file-relative / load-relative offsets, matching what mpcore's APP_ADDR() macro expects — see §6).
  • 10 JNI entry points auto-detected as "entrypoints", all under com.ea.ironmonkey.* (GameActivity, GameGLSurfaceView, MogaController (game controller support), RunLoop, and JNI_OnLoad itself at 0x56a52c).
  • Imports include OpenGL ES 2.0 (glCreateShader etc.), FMOD (via _ZN4FMOD12ChannelGroup...), POSIX sockets (socket/recv/send/connect — used by the EA "Synergy" backend HTTP/HTTPS client, see §3.4, not by any game-level multiplayer protocol), pthreads, and EA Nimble bridge registration functions.

3. Confirmed class/subsystem inventory (RTTI-verified, this session)

Method: find_regex over decoded strings to locate Itanium-mangled RTTI type names (N2im3app...E) and boost::shared_ptr counted-impl wrappers (N5boost6detail17sp_counted_impl_pI...E), which reveal exactly which C++ classes exist even though functions aren't named. Addresses below are the string locations (evidence the class exists), not yet the vtables — vtable/function recovery is future work (see §7 plan).

3.1 Race / track / opponent subsystem (im::app::race::*, im::app::track::*)

Class Namespace String address
RaceLoaderTask im::app::race 0xcfe924 (sp_counted_impl wrapper at 0xcf1420)
OpponentCollection im::app::race::description 0xcfeaf8
Opponent im::app::race::description referenced inside boost::bind signature at 0xcfe884 (bind_t<..., mf1<..., RaceLoaderTask, shared_ptr<Opponent>>...>)
RaceStartingGrid im::app::race::description 0xcfeb5c
StartingGrid (base) im::app::race::description 0xcfeb8c
StreetRaceStartingGrid im::app::race::description 0xcfebb8
InRaceState im::app::race::states 0xcff010 (with boost::bind callbacks referencing Checkpoint, Driver component weak-ptrs)
TrackNavigator im::app::track 0xd00454
TrackNavigatorSubSystem im::app::track 0xd004c0 (implements ISceneComponentListener<TrackNavigator>)

3.2 Car subsystem (im::app::car::*)

Class String address Notes
NitroBehaviour 0xcf2f58
AICarController 0xcf1c7c base AI controller
CopAICarController 0xcf29f0 police AI
PlayerAICarController 0xcf2f98
RaceAICarController 0xcf35d4
PlayerCarController 0xcf31a0
CarLoaderTask 0xcf2458

Not yet re-located this session (present in old-chat hypothesis, still need string/RTTI confirmation in our binary): Health, DamageDealtMultiplier, CarDamage, Nitro (base), SpikeStrip (im::app::bt::SpikeStrip). These were found via strings/r2 in the other binary; treat as "likely present, not yet confirmed here" until searched.

3.3 Text / UI rendering — corrected (this section was wrong in an earlier revision, see below)

Earlier revision of this section incorrectly concluded text is rendered purely natively via GLES/EAMText with no Java bridge. That was wrong — it only checked for Java_com_ea_ironmonkey_* exported JNI functions (native called from Java) and missed the reverse direction: native code calling into Java via cached FindClass/GetMethodID/CallVoidMethod upcalls, which don't show up as exported symbols at all. The user pointed to the actual mechanism, already reverse-engineered and sitting in the launcher project as launcher/app/src/main/java/com/ea/ironmonkey/BitmapGraphics.kt (comment in that file: "Весь текст в игре отрисовывается от сюда" — "all game text is drawn from here"). Verified this session against libapp.so.i64 by decompiling the actual call sites (functions renamed in the IDB accordingly):

  • BitmapGraphics_ctor_jni @ 0x5640ecFindClass("com/ea/ironmonkey/BitmapGraphics") + GetMethodID("<init>", "(II)V") + NewObject(width, height). Matches BitmapGraphics(width: Int, height: Int), which internally creates an android.graphics.Bitmap (ARGB_8888) and wraps it in a Canvas.
  • BitmapGraphics_drawString_jni @ 0x5625a0 (thunk at 0x56274c) — lazily resolves and calls GetMethodID("drawString", "(Landroid/graphics/Paint;Ljava/lang/String;II)V"). Matches fun drawString(paint: Paint, text: String, x: Int, y: Int) = canvas.drawText(...).
  • BitmapGraphics_createPaintFromFamilyName_jni @ 0x562a58 / BitmapGraphics_createPaintFromFile_jni @ 0x562b94 — call the companion-object createPaintFromFamilyName(String, F): Paint / createPaintFromFile(path, F): Paint statics.
  • ResolveFontPaint_ttfOtfOrFamily @ 0x51145c — checks for a .ttf/.otf file at the requested font path first, falls back to createPaintFromFamilyName (system font family) otherwise.
  • BitmapGraphics_blitBitmapToAtlasTexture @ 0x563d30 — the missing link, found by tracing xrefs to the "getBitmap" string: calls BitmapGraphics.getBitmap(), then AndroidBitmap_getInfo/AndroidBitmap_lockPixels, then memcpys the locked pixel buffer row-by-row with a vertical flip into what is, by the region/offset arithmetic (v27,v28,v29 = atlas rect x/y/width, bounds-clipped against a requested sub-rect), a native GL texture atlas (consistent with the EA::Graphics::OGLES20::Texture class seen elsewhere in the binary). Flip direction makes sense: Android Bitmap rows are top-down, GL texture data is bottom-up.

Corrected conclusion: text rendering pipeline is Kotlin BitmapGraphics (owns an offscreen Bitmap+Canvas) → Canvas.drawText via Paint/Typeface (handles proper font shaping for all 11 locales in game_cache/published/stringdata/, including CJK, which a bespoke native shaper would struggle with) → JNI readback via AndroidBitmap_lockPixels → row-flipped memcpy into a shared native GL font-atlas texture → drawn every frame by the game's normal GLES renderer as textured quads. The EAMText/GlyphMeshGLES/FreeType strings noted in the previous revision are real and still present in the binary, but their exact role relative to this BitmapGraphics path (alternate/legacy renderer? metrics-only? a different UI layer?) is not yet determined — do not assume they're the primary path; the BitmapGraphics bridge above is the one with a live, traceable call chain from a known Kotlin source file.

Why this matters for the mod: since BitmapGraphics.kt is fully ours to extend (it's reverse-engineered launcher source, not opaque binary), we can add a new method — e.g. drawMarker(x: Int, y: Int, color: Int) using canvas.drawCircle(...) — and it will be blitted into the game's own font-atlas texture and rendered through the game's existing GLES pipeline exactly like any other UI text. This gives a low-risk, verified path for drawing simple lobby indicators (e.g. green/red event markers on the map) without needing to author new Flow/SB screens and without needing a separate Android overlay View. It does not by itself solve touch input or full interactive lobby screens (player list, ready buttons) — that's a separate concern, still likely served best by an overlay View for now (see ARCHITECTURE.md).

3.4 Networking / backend — no reusable multiplayer transport

Searched for gamespy|eaonline|matchmak|lobby|leaderboard|raknet|socket|multiplayer|EA::Online|Synergy|CDMA|Freeway. Findings:

  • Extensive EA "Synergy" backend integration: SERVER_SYNERGY_{DIRECTOR,MTU,PRODUCT,DRM,USER,TRACKING,CIPGL,S2S}, hardcoded synergy endpoint URLs (synergy-dev/int/stage.eamobile.com), pinned TLS certs (synergy-GeoTrustGlobalCA.crt etc.), and world/friends leaderboard events (SPEvent_GetWorldLeaderboardDone/Error, SPEvent_GetFriendsLeaderboardDone/Error, MostWantedLeaderboard). This is EA's account/IAP/DRM/leaderboard HTTP(S) backend (likely long dead), not a peer race-sync protocol.
  • Raw POSIX socket imports (socket/recv/recvfrom/connect/send/sendto) exist but are consistent with the above HTTP(S) client, not a custom UDP game protocol.
  • No RakNet, no GameSpy, no P2P/lobby-discovery code found. Confirms the task brief's premise: there is nothing to reuse for multiplayer transport — RakNet must be integrated fresh, both client (embedded in the mod) and dedicated server (standalone), as already planned.
  • libnimble.so (separately investigated, see §6) is EA's Nimble SDK — a generic native↔Java bridge/callback framework (EA::Nimble::JavaClass, BridgeCallback, MTXNativeCallbackBridge for in-app-purchase UI, PushNotification) — unrelated to multiplayer, but relevant as a pattern for how native↔Java callback bridging is done in this codebase if we need more JNI bridge surface later.

4. libgnustl_shared.so, libfmodex.so, libfmodevent.so, libnimble.so — quick disposition

  • libfmodex.so / libfmodevent.so: FMOD audio engine runtime — not relevant to the multiplayer mod except that hooked code must not disturb their init order.
  • libgnustl_shared.so: GNU libstdc++ runtime — no action needed.
  • libnimble.so: EA Nimble SDK (see §3.4) — native↔Java bridge/callback plumbing for IAP (MTX), push notifications, identity. Not part of the race/multiplayer surface. Its Java_com_ea_nimble_bridge_BaseNativeCallback_native{Callback,Finalize} pattern is a useful reference for how this codebase wires native callbacks into Java if the lobby UI ends up needing a similar bridge.

5. game_cache/ — confirms SB/SBA architecture in practice

Directory layout under game_cache/published/: prefabs/{cars,tracks,racefsms,racetypes,checkpoints,roadblocks,traffic,environments,garage,skydomes,props,lensflares,blacklisttech}, data/{races,cars,careers,achievements,sponsors,enginesounds,autolog,pursuit,tiers}, flow/{menus,race,postgame,frank,...}.sb, stringdata/<LANG>/, textures/..., models/..., sounds/..., fonts/, layouts/, tweaks/, particles/, replays/. Plus published.1x/2x/4x texture-pack variants (resolution tiers).

This confirms: prefabs/tracks = track geometry, data/races = RaceDefinition SB files, prefabs/racefsms = likely the actual InRaceState/Flow FSM scripts driving a race (worth inspecting directly — plain SB, no binary RE needed), flow/race/*.sb = the Flow-machine scripts for the race-start UI sequence the user described (location → event list → info screen → car select → race). These SB files are directly readable/editable with NFSMW12MobileTools without touching the native binary at all for a large fraction of subtask 2/3 work (e.g., authoring a custom RaceDefinition SB with our own opponent list, if we go the "replace the file the game reads" route instead of the "hook the C++ struct after parse" route).


6. launcher/mpcore/ — existing draft state, and a verified ground-truth anchor

Confirmed via direct file read: mpcore is exactly what the task brief said — an early draft, not production code. Current contents:

  • src/main/cpp/main.cpp: JNI_OnLoad resolves libapp.so base via FindLibrary(), logs it, then raise(SIGSTOP) (presumably to attach a debugger before the process continues) and returns. A pending_thread() function exists but is unused (never spawned) — it sleeps 10s then reads/logs a value at APP_ADDR(0x00E4B8EC) and calls unProtect(APP_ADDR(0x00E4A738)). All hooking is currently commented out.
  • util/armhook.cpp: a working Thumb-mode (16-bit ISA) inline-hook toolkitunProtect (mprotect with EACCES fallback), makeNOP/makeJMP/makeBLX (Thumb branch encoding), WriteHookProc/InstallHook/InstallJMPHook/InstallBLXHook/InstallMethodHook (vtable-slot overwrite)/CodeInject (register-move injection into a small stub), all built around a fixed trampoline region memlib_start..memlib_end = APP_ADDR(0x000A1B44)..+0x1A36 plus a separate mmap'd PROT_RWX scratch page for original-bytes backup. This confirms hooking must target Thumb encoding, not ARM32 for at least this region of the binary (consistent with the 16-bit MOVS/STR/BX LR opcodes the old chat's radare2 dump was implicitly assuming when scanning for "0x2050" MOVS patterns — that detail was accidentally right even though the addresses were wrong).
  • Verified ground truth: a commented-out line in main.cpp reads WriteMemory(APP_ADDR(0xC8C9D8), (uintptr_t)"APPLICATION_NO", 14) after logging the existing string at that address. Checked against our .i64 via get_string: 0xC8C9D8 does contain the string "APPLICATION_OK" — confirming the mod author was working against this exact binary (not a different build), and that APP_ADDR() is a direct file-offset-to-runtime-address identity mapping (base address 0 in the IDB matches). This is our first fully-verified address in this session and a template for how to cross-check future finds: locate a string/constant in IDA, then confirm the same offset in the live/dumped binary.
  • NativeLib.kt (Kotlin/Java side): stub class, System.loadLibrary("mpcore") commented out — not yet wired into the app's load sequence.
  • Build system: Gradle module already produces libmpcore.so for armeabi-v7a (matches our target ABI) via CMake (src/main/cpp/CMakeLists.txt), for both Debug and RelWithDebInfo.

Implication for integration plan: the armhook.cpp toolkit is a solid, already-working foundation for ARM32 Thumb inline hooking — we should build on it rather than writing a new hooking library from scratch, once we've confirmed it handles our specific hook sites (it was seemingly exercised only against the 0xC8C9D8 string-patch and a couple of NOP/log experiments so far, not against a real virtual-function hook).


6a. RaceLoaderTask real vtable — located and partially mapped (subtask 1 groundwork)

Method used (repeatable for the other classes in §3.1/3.2): from the class's RTTI name string, find who points to it (xrefs_to) — that location is type_info->name, so type_info itself sits 4 bytes earlier. Then find_bytes for a little-endian pointer to that type_info address across the binary; the real class vtable starts 4 bytes after the match (the found slot is the typeinfo_ptr field of the vtable group, per Itanium ABI: [offset_to_top][typeinfo_ptr][vfunc0][vfunc1]...). Cross-check: the resulting array should be a long unbroken run of .text addresses immediately followed by the class's own type_info fields — exactly what was found, confirming the technique. (Two other find_bytes hits for the same typeinfo address were false leads: one was the Iron Monkey engine's own custom reflection/pointer-tagging table — same {ptr, 0x17} pattern seen earlier for BitmapGraphics, unrelated to C++ vtables — the other was a different class's base_type_info field, i.e. some other class derives from RaceLoaderTask, not yet identified.)

RaceLoaderTask vtable: 0xd86210 (19 virtual function slots), immediately followed by its own type_info at 0xd8625c ({vtable=0xdc12a0, name=0xcfe924 "N2im3app4race14RaceLoaderTaskE", base_typeinfo=0xd79b78}).

Slot Address Renamed to Role / evidence
0 0xd5b04 Shared thunk (xrefs from multiple unrelated typeinfo structs 0xd79b60, 0xd79b90, 0xd7a300, plus ours) — generic LoaderTask base-class helper, not RaceLoaderTask-specific. Not decompiled in depth.
1 0x2d8e04 RaceLoaderTask_dtor_complete Sets vptr back to 0xd86210 (own vtable), decrefs several shared-ptr-like members at this+100/112/124/136/148/156, destroys an internal vector of ref-counted elements (12-byte stride).
2 0x2d8f18 RaceLoaderTask_dtor_deleting Calls slot 1, then operator delete. Standard Itanium destructor pair.
3 0xd5818 Same shared-thunk region as slot 0. Not decompiled.
4 0x2dbba4 RaceLoaderTask_ExecuteLoadSequence Main load orchestrator. Calls SetLoadProgress(this, N) (via sub_42EE9C) with progress fractions 0.1, 0.2, 0.3, 0.4, 0.45, 0.5, 0.6, 0.7, 0.8 interleaved with stage calls (sub_2DB384, sub_2DA710, sub_2DAA7C, sub_2D994C, sub_2DAB50, sub_2D9AD0, sub_2DB534, sub_2D969C — candidate individual load stages, addresses known but not yet decompiled/named). Loads /published/texturepacks_ui/in_game.sba (loading-screen texture) partway through. This is the prime hook candidate for subtasks 1/2 — top-level entry to intercept before track/opponent data reaches the stage functions.
5 0xd5820 Same shared-thunk region as slot 0/3. Not decompiled.
6 0x2d83cc nullsub (unused/pure-virtual slot).
7 0x2d9ba4 Takes (this, a2) where a2 looks like a 3-word ref-counted handle; assigns it into this+64/68/72, decrefs the old value, calls sub_D7C00/sub_2DDED4. Likely a "Set<SharedResource>" setter (track ref? scene ref?) — role unconfirmed, but the field it writes (this+64..72) is read by slot 16.
8 0x2db160 RaceLoaderTask_ResolveDriverPositionComponent Calls dynamic_cast<DriverPosition*> against RTTI im::components::Componentim::app::race::data::DriverPosition. Direct link to the coordinate/position system — relevant to subtask 4 as well as 1/2.
9 0x2d9d10 RaceLoaderTask_SetupPlayerCar References string "playerCar". Large function (~1.1KB), gated by a state-check helper (sub_360CC0, shared with slot 11).
10 0x2d9088 Same sub_360CC0 state-gate pattern as slots 9/11, but no distinctive string found — purpose unconfirmed (candidate: a third car-category setup, e.g. traffic).
11 0x2dae0c RaceLoaderTask_SetupOpponentCar References string "opponentCar". Same state-gate pattern as slot 9 — clear sibling function (player vs. opponent car setup). Prime hook candidate for subtask 2.
12 0x2d8884 Trivial one-line wrapper calling sub_2DC82C(a1, a3) — thin delegation, not investigated further.
13 0x2d8a74 Allocates small objects and links them via another vtable (off_D86110, not yet investigated) — looks like constructing an auxiliary completion-callback object. Not fully understood.
14 0x2da2a0 RaceLoaderTask_ResetStartingLine References string "ResetLine". Large function (~1.1KB). Prime hook candidate for subtask 2's starting-grid placement.
15 0x2d83d0 nullsub.
16 0x2d8f34 Reads the field slot 7 writes (this+64), iterates a vector at this+76/80 (same vector shape as the destructor's), calling sub_18B670 per element — looks like a listener-notification loop. Role unconfirmed but clearly paired with slot 7.
17 0x2da880 RaceLoaderTask_HandleSpikeStrip Calls dynamic_cast from im::app::bt::BlacklistTech to im::app::bt::SpikeStrip, references string "Spike Strip". Confirms spike strips are handled as a kind of trackside BlacklistTech prop during race load.
18 0x2d8c1c Not yet decompiled.

6b. ExecuteLoadSequence's 8 stage functions — decoded (subtask 1 hook point found)

Decompiled all 8 stages called from RaceLoaderTask_ExecuteLoadSequence (0x2dbba4), in call order:

Order Address Renamed to Role / evidence
1 0x2db384 RaceLoaderTask_BuildTrackScenePath ★ Subtask-1 hook point. Builds "published/prefabs/tracks/<name>.scene.sb" from a string field at this+8-struct +72 (the track name, presumably parsed from the RaceDefinition SB), optionally builds "published/prefabs/environments/<env>.prefabs.sb" too (only if two fields differ — an environment-override check), then calls GenericLoadScene_trackEnvWrapper with both paths. To load an arbitrary track: patch the track-name string this function reads, or hook the function and rewrite the constructed path before it reaches the loader.
0x2dbf58 GenericLoadScene_trackEnvWrapper Thin wrapper forwarding to sub_33C284(out, trackPath, envPath, -1, 0) — likely the engine's shared generic scene/asset loader (not race-specific), not yet investigated further.
2 0x2da710 RaceLoaderTask_SetupAIDifficultyProfiles Builds three literal strings "ai_easy", "ai_hard", "default" and passes them to sub_121288 along with a value read from this+44-struct+152. AI behavior-tuning profile lookup/registration.
3 0x2daa7c RaceLoaderTask_TagPlayerEntity Calls two virtual functions on this (vtable+36, vtable+28) and writes the literal string "Player" into an offset (+52) of the object returned by the first virtual call — tags/names the player's entity in the scene graph.
4 0x2d994c RaceLoaderTask_RegisterTrafficFlow Scans a component vector via dynamic_cast against RTTI im::app::traffic::TrafficFlow, then registers whichever component matches. Wires up traffic/AI-pathing infrastructure from the now-loaded track scene.
5 0x2dab50 (not renamed — uncertain) Copies three fields (words 3840, i.e. +152/156/160) from the this+8-struct into a large object at this+44-struct+304 (offsets +3996..4004), plus a byte at +120→+4036. Looks like copying RaceDefinition metadata (camera/weather/track-meta?) onto the loaded scene/race object — exact semantics unclear, left uncommented-name.
6 0x2d9ad0 (not renamed — uncertain) Allocates a small object, passes this+16 (the field group RaceLoaderTask slot 7 sets / slot 16 reads — see §6a) into sub_2B6900, wraps the result in a ref-counted adapter (different vtable, off_D86150, not RaceLoaderTask's own), registers it into the scene at this+44-struct+152. Looks like constructing an anonymous listener/callback object tied to whatever slot 7/16 manage — not confidently named.
7 0x2db534 RaceLoaderTask_LoadRaceFSM Loads "/published/prefabs/racefsms/{0}.prefabs.sb" (template-substituted, confirms the game_cache/published/prefabs/racefsms/ directory noted in §5 is exactly the race rules/state-machine prefab). Searches the resulting scene for a component matching RTTI im::app::race::Race via dynamic_castthis is the runtime race-FSM/rules controller object, stored at this+52. Then iterates the opponent/car vector (same vector shape as the destructor) and dynamic_casts each entry to im::app::car::Healthconfirms im::app::car::Health exists in this binary (RTTI-searchable, not yet done — see §7), tagging a flag byte at Health+528 conditionally.
8 0x2d969c RaceLoaderTask_DispatchInitialFSMEvents Dispatches a sequence of numbered events (codes 17,18,22,2,20,19 at priorities 2..7) via a generic sub_2D9398(this, eventCode, priority, args) — almost certainly SendEvent(raceFSM, code, priority, args) into the Race FSM object stage 7 just loaded — followed by a race-type-dependent final code (switch on a byte at this+8-struct+112: 0→11, 1→(12 or 0), 2→10, 5→13, default→46), then event 9. Event-code meanings not yet decoded, but this is clearly the "kick off the freshly-loaded race FSM" step.

Conclusion for subtask 1: RaceLoaderTask_BuildTrackScenePath (0x2db384) is the concrete hook point — it's stage 1 of 8, runs before anything else touches track data, and constructs the track path from a single string field. Two viable approaches, to be decided once the field's origin (where this+8-struct+72 gets populated from the RaceDefinition SB) is traced: (a) hook this function and substitute the track-name argument before path construction, or (b) find and patch the earlier SB-parsing step that fills that field in the first place. Either avoids touching RaceLoaderTask_LoadRaceFSM/ExecuteLoadSequence's overall sequencing, keeping the change minimal.

6c. Is there a "SceneLoader"? — theory and findings

The user recalled a "SceneLoader" from memory. No C++ class with that literal name exists (searched RTTI strings exhaustively; also checked the reverse-engineered Java launcher sources — nothing named SceneLoader there either, unlike BitmapGraphics which did turn out to be real). However, the underlying system the user is likely remembering is real and now identified:

  • sub_33C284sub_33B038 is a generic, engine-wide scene-loading function, confirmed independent of RaceLoaderTask. Traced via xrefs_to: it has 9 separate call sites spread across four unrelated address ranges (0x21e9f4, 0x233f30, 0x23bec8, 0x23e8c4, 0x242a28, 0x245cd8, 0x24a1f4, 0x24d2f4, plus our own GenericLoadScene_trackEnvWrapper at 0x2dbf58). It builds an im::app::NFSScene object (confirmed via RTTI: N2im3app8NFSSceneE, and via string refs NFSScene::PreUpdate/NFSScene::PostUpdate) from a pair of paths. This is functionally "the scene loader" — shared infrastructure used by race loading, and evidently by several other subsystems (menu/garage/track-test — see below), even though it's not organized as a named class.
  • im::app::NFSScene participates in a generic async task-pipeline abstraction: im::general::pipeline::Stage<boost::shared_ptr<im::app::NFSScene>> (RTTI confirmed at 0xd88004). Located its vtable (0xd88040) but it turned out to be pure template scaffolding (destructor pair + a pure_virtual_called trap at slot 3) — the actual "load in progress"/"is complete"/"get result" logic must live in an undiscovered concrete subclass. Not pursued further this session.
  • im::app::LoaderTask (RTTI confirmed N2im3app10LoaderTaskE, base of RaceLoaderTask per §6a) is the generic async "task" abstraction — sibling classes confirmed: im::app::MenuLoaderTask, im::app::StartupLoaderTask. So the engine's actual naming is LoaderTask (for the task/lifecycle side) + NFSScene (for the loaded-result side) + the shared sub_33C284 free function (for the actual load work) — three different pieces, none literally called "SceneLoader", together doing what that name implies.

Two new hot leads found while chasing this (not yet decompiled in full)

  1. sub_233F30 (0x233f30) — references both /published/data/races/ (the RaceDefinition SB path) and published/prefabs/tracks/ + .scene.sb (the track path) in the same function, then calls the generic scene loader directly. This may be the actual top-level "load race by ID" entry point that creates/drives RaceLoaderTask — which earlier investigation (see §1.1's provenance notes and the original "who calls DoLoadRace" question) never located. Worth decompiling in full next.
  2. sub_24A1F4 (0x24a1f4) — references strings "TrackTestLayer", "Car", "start", "finish". Strongly suggests an existing track-testing/debug harness that loads a track directly, likely bypassing the normal race-select Flow entirely. If confirmed, this could be the safest possible hook point for subtask 1 — reusing an already-exercised internal test path instead of a raw hook into RaceLoaderTask's internals. Worth decompiling in full next, and worth checking whether it's reachable from the existing devmenu module in the launcher (the launcher already has a dev-menu concept — this could be a natural fit).

Both addresses are commented in the .i64 as "HOT LEAD" for continuity.

6d. TrackTestLayer — a real, built-in EA QA track-testing tool (major subtask-1 lead)

Followed up on 0x24a1f4 per user request. This is the constructor of a real class literally named TrackTestLayer (confirmed: sub_42BAFC(a1, "TrackTestLayer") tags the object immediately before the vptr is set) — a genuine EA/Firemonkeys internal QA tool, fully present and functional in this shipped binary, not something we're inferring loosely.

TrackTestLayer_ctor (0x24a1f4) signature and behavior:

  • Second constructor argument is a pointer to the scene/track path string to load.
  • Immediately calls the generic scene loader (sub_33C284, see §6c) with that path — i.e. this bypasses RaceLoaderTask/Flow entirely and loads a scene directly.
  • After loading, locates "start"/"finish" named nodes in the scene (sub_695A10) — the track's start/finish line markers.
  • Spawns a hardcoded list of 8 real test cars (description IDs: she_day_cou_65, ast_one_77_10, aud_r8_v10_10, bmw_m3_gts_10, lam_mur_sv_71, lot_evo_stk_10, mer_sls_amg_10, nis_240_zg_71 — Shelby Daytona Coupe, Aston Martin One-77, Audi R8 V10, BMW M3 GTS, Lamborghini Murciélago SV, Lotus Evora Stack?, Mercedes SLS AMG, Nissan 240SX), each with a RaycastCar (physics) component and bound to the scene's TrackNavigator component — i.e. cars actually drive on the track via the coordinate system we found in §6a/§6b.
  • Writes telemetry to /var/{0}.csv per car (lap-time/QA logging, standard for this kind of tool).

Callers / how it's triggered:

  • TrackTestLayer_factory_wrapper (0x24aec4) — a thin TrackTestLayer(path) factory, called from 0x21fd8c inside DebugTestHarness_DispatchByName, and also referenced as a data pointer at 0x22092c (a second, not-yet-identified call site).
  • TrackTestLayer_QABatchRunner_tick (0x243160) — an automatic QA batch-runner: every N frames (default 30, at this+40), advances through up to 15 track-path entries in a runtime string table (dword_DD0B54, bounded by dword_DD0C44/dword_DD0C48 — all-zero in the static image, so populated at runtime, not found statically; likely from a debug config file), constructing a fresh TrackTestLayer for each. I.e., there's a built-in "cycle through every track automatically" QA mode too.
  • DebugTestHarness_DispatchByName (0x2217ec) — takes a single const char* modeName argument. Matches it against category prefixes: "Track/" (→ TrackTestLayer), "Performance/", "CarPreview/" (with a confirmed concrete path /published/prefabs/garage/car_preview.scene.sb), "CarThumbnailMaker/", "CollisionTest/", "RacingLine/", plus standalone modes "MetaPerformanceLayer", "MemoryLeakCheck/ClearMemory", "MemoryLeakCheck/NoClear", and — critically — "MainMenu"/"MAIN_MENU", which is presumably the normal shipping-game default.
  • Only one caller found this session: ResourceDirListeners_Init_maybeCallsDebugHarness (0xcf904), itself called once at startup from 0xde1e4. Where the actual runtime modeName value comes from is not yet traced — candidates: a debug config file, an environment variable, a hardcoded literal further up the call chain, or (less likely in a shipping build) a command-line/Intent-extra. This is the single most valuable next step: if modeName is externally overridable (e.g. read from a file we can write, or an env var mpcore can set before libapp.so's init runs), we could boot the game directly into TrackTestLayer with an arbitrary path — a fully legitimate, pre-built loading path that bypasses RaceLoaderTask, Flow, and the menu system entirely, and comes with working start/finish detection and reference cars already bound to TrackNavigator.

Practical implication for subtask 1 (and partially 2/4): this is now the strongest candidate approach, ahead of hooking RaceLoaderTask_BuildTrackScenePath (§6b) — construct TrackTestLayer directly. It gets us: scene loading, start/finish detection, and car-to-TrackNavigator binding all for free, using code EA already tested.

6e. Where the debug mode string comes from — traced to a dead end (use a direct call instead)

Traced ResourceDirListeners_Init_maybeCallsDebugHarness (0xcf904) in full: its last line is return DebugTestHarness_DispatchByName(*(const char **)(a1 + 292)); — the mode-name string is read from offset +292 of its argument object. That function is called exactly once, at boot, from the app bootstrap function sub_DE180 (0xde180), as ResourceDirListeners_Init_maybeCallsDebugHarness(v9) where v9 comes from a multi-step "resolve current instance" call chain (sub_3C969C→sub_5821AC→sub_1911C8→sub_19073C→sub_D0AAC→...→sub_D2880) — not sub_DE180's own parameter, a separately-resolved object. Did not fully identify this object's class or trace who populates its +292 field with a concrete value.

A second, related mechanism was found a few lines later in the same sub_DE180 bootstrap function: it looks up a config value for the literal key "flow" (via sub_3BF344, backed by a global config/tweaks singleton at dword_DD2E9C) and compares it against the literal string "STARTUP_RACE" — if equal, a special code path runs instead of the normal Flow-init callback. This looked very promising (a named, deliberate "boot straight into a race" switch), so it was checked against every plausible shipped-asset source:

  • 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) were unpacked via NFSMW12MobileTools (no binary RE needed — see ANALYSIS.md §1.1 for the tool). None contain a "flow" key or a "STARTUP_RACE" value. (debug_options.sb did turn out to be a rich, directly-editable in-game debug menu tree — see the bonus finding below.)
  • The reverse-engineered launcher Java sources (launcher/app/src/main/java/...) have no STARTUP_RACE string and no getIntent()/getStringExtra() handling that looks related (the only getIntent/getStringExtra usages found are in EA Nimble's push-notification/referrer-tracking code, unrelated).

Conclusion: this is very likely an EA-internal, build-time-only debug switch (probably set via their internal QA build tooling, e.g. a custom launcher argument or build flavor never shipped to us), not reachable through any config file, Intent extra, or asset present in this project. Chasing the "legitimate" source further is a dead end with the material available.

This does not block subtask 1. We already have everything needed to use TrackTestLayer without going through this dispatch mechanism at all: its constructor address (0x24a1f4) and calling convention (2nd arg = track/scene path pointer) are known (§6d). The practical plan is to call TrackTestLayer_ctor/TrackTestLayer_factory_wrapper directly from a mpcore hook once libapp.so is loaded and initialized, passing our own path — bypassing the mode-string plumbing entirely rather than trying to trigger it "the intended way."

6f. First live test: calling TrackTestLayer_ctor directly from mpcore (on-device, WayDroid)

Actually implemented and tested this, twice, on the running WayDroid setup (com.ea.games.nfs13_na, no_devmenu debug build). Both attempts crashed, but each crash pinpointed a concrete, understood cause — this is real progress, not a dead end.

Design choice: used a plain std::thread with a 15-second sleep in mpcore's JNI_OnLoad, calling the constructor directly, rather than an inline hook via armhook.cpp's InstallHook. Reasoning: that hook infrastructure is untested (see ANALYSIS.md §6 / PROGRESS.md), and mixing an untested hook mechanism with a first attempt at calling a new function would make any crash ambiguous (hook bug vs. constructor-call bug). A direct delayed call isolates the variable under test. raise(SIGSTOP) (existing in main.cpp, presumably a debugger-attach aid) was commented out for this experiment since it suspends the whole process, including the new thread.

Attempt 1 — crash inside the string-builder helper itself. Called sub_CF5F8(destObj, cstr) with what was assumed to be a 2-argument "assign from C-string" signature. Crashed instantly (SIGSEGV, SEGV_MAPERR) at sub_CF5F8+0x68, inside a memmove call. Root cause, confirmed by decompiling sub_CF5F8 properly: its real signature is sub_CF5F8(dest, rangeBegin, rangeEnd) — a [begin, end) range constructor (length = rangeEnd - rangeBegin), not an implicit-strlen C-string assign. Every caller seen throughout the binary manually scans for the string's end pointer first (the odd-looking do { ...*ptr++... } while(*ptr) patterns noted in earlier decompiles were exactly this) before calling it with 3 arguments. Calling with only 2 left the 3rd ABI register (end) as garbage, producing a bogus memmove length. Fixed by computing trackPath + strlen(trackPath) as the third argument.

Attempt 2 — crash inside the start/finish node lookup. After the fix, the log confirmed the call reached TrackTestLayer_ctor (0x24a1f4) itself and got well past scene loading and car-catalog setup (no crash there — meaningful validation that scene loading via the generic loader works when called this way). Crashed later, inside sub_FDA64 (called on the result of sub_695A10("start"/"finish", ...) — the named-node lookup from ANALYSIS.md §6d). Root cause: sub_695A10 returns {0,0} when no top-level scene node with that exact name exists, and TrackTestLayer_ctor passes that result straight into sub_FDA64 without a null check, which dereferences it (offset +0x14) and segfaults. Checked the actual test track used (published/prefabs/tracks/region1_foothills_track3.scene.sb, picked somewhat arbitrarily from the 48 available) by unpacking it via NFSMW12MobileTools: it does contain "start"/"finish" as nested path segments (e.g. checkpoints/banner/en/mesh_start/mesh/vertices) and several "Name": "Start" (capitalized) entries, but apparently not a top-level node named exactly "start" (lowercase) — either a case-sensitivity mismatch or this specific track genuinely isn't structured the way TrackTestLayer expects (it may not be the actual track used by any real race event — picked without checking that).

What this establishes: the calling convention, ABI, and approach are correct — we successfully call from a foreign thread into libapp.so's C++ internals, construct its native string objects correctly (after the fix), and drive real engine subsystems (scene loading, car catalog) without crashing. The remaining blocker is track-specific (this constructor expects the target scene to already have "start"/"finish" markers at the top level) and not fundamental. mpcore's current committed code will crash on launch as-is (still targets region1_foothills_track3.scene.sb) until either a track confirmed to have the right top-level markers is substituted, or the missing null-check is patched around.

Follow-up (same session): tried a race-linked track — same crash, ruling out "wrong track" entirely.

Cross-referenced game_cache/published/data/races/event_01_race.prefabs.sb (a real, shipped race event): its TrackName field points to region4_chicago_track4. Unpacked region4_chicago_track4.scene.sb via NFSMW12MobileTools and confirmed it contains "actor" structs named exactly "start" (id 1C610000, prefab /start.prefabs.xml/start) and "finish" (id AA090000, prefab /finish.prefabs.xml/finish) — and, critically, both IDs are listed in the scene's top-level "actors" DataIdsMap (id 04000000), alongside 11 other sibling actor IDs — i.e. these are not deeply nested; they're direct entries in what is almost certainly the exact list sub_695A10's lookup iterates.

Rebuilt mpcore targeting this track and re-ran the live test on WayDroid. Identical crash, same PC (sub_FDA64+0x18, i.e. dereferencing a null lookup result). This rules out "wrong/non-gameplay track" as the explanation — the data is present and structurally exactly where expected, yet the runtime lookup still fails.

Revised theory: the failure is not about the scene's authored content but about runtime state at the moment of the lookup. Two candidates, neither confirmed:

  1. Scene loading may be asynchronoussub_33C284/sub_33B038 (§6c) could parse/instantiate the scene over multiple engine ticks rather than fully synchronously within the call, and TrackTestLayer_ctor (in its normal, EA-authored call context) may rely on being re-entered or polled across frames before doing the start/finish lookup, something a single direct constructor call from a foreign thread can't replicate.
  2. TrackTestLayer may depend on ambient global/singleton engine state (e.g. a "current active world/scene" pointer) that's normally established by whatever code path constructs it in EA's own tooling — a path we never found (§6e) and therefore can't replicate — rather than being self-contained from just its two constructor arguments.

Recommendation: stop iterating on TrackTestLayer blindly. Three attempts (bad-track-guess, then a confirmed race-linked track, both crashing identically) is enough signal that this isn't a simple parameter-tuning problem, and further guessing without new information is unproductive. Two real forward paths, to decide with the user:

  • (a) Properly instrument/trace the scene-load completion state (e.g. poll whatever ref-count or "loaded" flag sub_33C284's output object exposes before attempting the lookup) — a scoped, legitimate next RE step if TrackTestLayer is still wanted.
  • (b) Pivot subtask 1 to hooking RaceLoaderTask_BuildTrackScenePath (§6b) instead — the engine's own always-used, always-correctly-initialized code path for every normal race load, sidestepping whatever implicit dependency TrackTestLayer has. Given TrackTestLayer itself has an unguarded null-deref bug (§6d) that fires on a supposedly-correct track, treating it as a reliable, maintained tool in this specific shipped build (1.3.128) is now in question — it may be stale/unexercised in this build even though the code is present.

User chose (b). See §6g for the implementation and where live testing currently stands.

6g. Pivoted to hooking RaceLoaderTask_BuildTrackScenePath — ARM-mode inline hook, installed and stable; end-to-end test blocked by WayDroid networking

Key discovery before implementing anything: disassembled RaceLoaderTask_BuildTrackScenePath (0x2db384)'s prologue and found it is compiled in ARM mode, not Thumb — E92D41F0 (PUSH {r4-r8,lr}), E24DD040 (SUB SP, SP, #0x40), both classic 32-bit ARM encodings (condition-code nibble E = "always", a dead giveaway vs. Thumb's 16-bit encodings). This matters a great deal: armhook.cpp's existing InstallHook/makeJMP/HOOK_PROC machinery is hardcoded for Thumb targets (its HOOK_PROC byte sequence starts 01 B4 01 B4 01 48..., all 16-bit Thumb opcodes). Using it on this ARM-mode function would misinterpret/corrupt the target — the existing hook infrastructure cannot be used here as-is. This confirms the earlier caution (ANALYSIS.md/PROGRESS.md) about that code being untested was well-founded, and now there's a concrete reason it would specifically fail on this target.

Traced the exact field being overridden: sub_F5154's real signature is sub_F5154(dest, literalCStr, srcRangeObjPtr) — the third argument is the address of a 3-word {begin,end,capacity} string object (same layout as sub_CF5F8's objects, read via a3[0]/a3[1]), not a raw string. So in RaceLoaderTask_BuildTrackScenePath's call sub_F5154(&v16, "published/prefabs/tracks/", a1[8] + 72), the track name lives as an inline 3-word string object at byte offset 72 within the RaceDefinition-like struct pointed to by RaceLoaderTask.this[8] (word index 8 = byte offset 32). To override the track: read raceDefPtr = this[8], then call sub_CF5F8(raceDefPtr + 72, newName, newName + strlen(newName)) to overwrite that field in place before the path gets built — reusing the exact same string-range helper already understood from the TrackTestLayer work (§6f), just applied to an existing object's field instead of a fresh stack buffer.

Implemented (launcher/mpcore/src/main/cpp/main.cpp): an ARM-mode-correct inline hook, separate from armhook.cpp (documented inline why):

  • Overwrite the target's first 8 bytes (exactly 2 whole ARM instructions — safe, ARM instructions are always 4 bytes wide, so there's no mid-instruction split risk the way there is in mixed 16/32-bit Thumb-2) with LDR PC, [PC, #-4] (0xE51FF004) + the hook function's address.
  • A trampoline (RWX mmap'd page) holds the 2 displaced original instructions (both confirmed position-independent — plain PUSH/SUB, no PC-relative addressing, so relocating them is safe) followed by the same LDR PC, [PC,#-4] pattern jumping back to target+8 to resume the original function in ARM mode.
  • Mode-switch correctness: the hook function's address (a normal Thumb-2-compiled C++ function, since mpcore builds as Thumb-2 by default) has bit 0 set automatically by the compiler/linker for ARM/Thumb interworking — loading it into PC via LDR triggers the CPU to switch to Thumb state, per standard ARMv5T+ interworking semantics. The trampoline's jump back to target+8 uses a plain (bit-0-clear) address, staying in ARM mode as required.
  • The hook itself: reads this[8] (the RaceDefinition pointer), overwrites the string object at +72 with a hardcoded override track name (region3_colorado_track2 — deliberately different from any track a real race would use, so a successful override is visually unmistakable), logs, then calls through to the original via the trampoline.
  • Installed unconditionally and immediately in JNI_OnLoad (no delay needed, unlike the TrackTestLayer experiment — patching code bytes is safe at any time since nothing is being called yet; the patched function only executes later, whenever the player naturally starts a race through the normal menu).
  • The old TrackTestLayer thread-spawn experiment (§6f) is left in place but commented out (superseded, not deleted, consistent with this file's existing style of preserving prior experiments as commented-out code).

Live test status (WayDroid): hook installed cleanly — mpcore_log confirmed "Installed RaceLoaderTask_BuildTrackScenePath hook, orig trampoline=0x..." and the app continued running (and slowly growing in RSS, i.e. actively doing work, not frozen) for 2+ minutes afterward with no crash — reasonably strong indirect evidence the 8-byte ARM patch itself is correctly formed and didn't corrupt anything nearby. However, the screen stayed black the entire time and never reached the main menu, so the hook itself was never actually exercised (it only fires when RaceLoaderTask_BuildTrackScenePath is called, i.e. once a race load begins). Diagnosed the black screen: not a bug in this session's changesip route inside the WayDroid container shows only the local 192.168.240.0/24 subnet with no default gateway, confirmed by 100% ping packet loss to 8.8.8.8; the boot log shows NIM_ERROR: No network connection during EA's Nimble/EASP init, and the app likely hangs on a network-dependent init step (or a long timeout) before ever reaching the menu. Fixing this needs root inside the WayDroid container (waydroid shell requires root; no passwordless sudo available in this session) — a genuine infrastructure gap, not something resolvable from here without the user's involvement.

Bottom line: the hook mechanism itself is implemented and shows no signs of corrupting the binary (survives 2+ minutes of live execution). What's unverified is whether it actually fires and successfully substitutes the track name when a real race is started, because the WayDroid container currently can't get far enough into the game's boot sequence to reach the menu. Next step once network is available: launch, reach the menu, start any race event, and confirm via mpcore_log that the hook fired and via screenshot that region3_colorado_track2 (not the race's real track) loads.

Correction (same day): the "no network" theory above was wrong. The user identified the real cause: the black screen is because this build (applicationId com.ea.games.nfs13_na) has no game_cache to find — WayDroid already has a cache, but only under the package name com.ea.games.nfs13_mod (confirmed: com.ea.games.nfs13_mod is installed there separately, versionName 1.3.128, and /sdcard/Android/obb/com.ea.games.nfs13_mod/main.1003128.com.ea.games.nfs13_mod.obb, a 623MB file dated 2020, exists on the device). The NIM_ERROR: No network connection log line is expected/harmless — a stub the user placed during reverse engineering, not a real blocker.

6h. Switching to com.ea.games.nfs13_mod — versionCode fix was necessary for install, but its OBB-lookup justification is UNVERIFIED

Changed launcher/app/build.gradle.kts: applicationIdcom.ea.games.nfs13_mod (namespace left as com.ea.games.nfs13_na deliberately, since source files still import com.ea.games.nfs13_na.BuildConfig and changing namespace would require updating those). First install attempt failed with INSTALL_FAILED_VERSION_DOWNGRADE (our versionCode=1 vs. the already-installed real game's versionCode=1003128) — this is a confirmed, OS-level fact (Android's package installer itself enforces version-code-based downgrade protection; adb install -r -d bypasses it, or bumping our own versionCode to match avoids it entirely). Set versionCode = 1003128, versionName = "1.3.128" to match, after which adb install -r succeeded without needing -d.

The comment originally added alongside that change — that the game's own code looks up its OBB file via main.<versionCode>.<applicationId>.obb, so versionCode needed to match for asset loading — was an unverified assumption, not a checked fact, and the user correctly asked for evidence. Checked properly:

  • grep (case-insensitive) for obb across the entire launcher source tree (all .java/.kt/.xml) — zero matches.
  • find_regex for addAssetPath|mountObb|StorageManager|\.obb in libapp.sozero matches (this check was actually already done earlier in the session, but its implication for the versionCode comment wasn't connected until asked).

Conclusion: there is no evidence in this codebase (Java or native) that the game constructs or checks an OBB filename against its own versionCode, or uses Android's OBB/StorageManager APIs at all. The main.1003128.....obb file on the test device is most likely a leftover from the original Play Store install of the real game (Google's own expansion-file delivery mechanism placed it there historically), not something this reverse-engineered launcher's code actively looks for. The versionCode fix was corrected in build.gradle.kts to state only what's actually confirmed (avoids the installer-level downgrade block) and flags the OBB-lookup theory as unverified. It should not be treated as "the fix" for the black-screen/cache-loading problem — that mechanism is still unidentified.

Still open: how does this build actually locate game_cache/published/... at runtime? Three candidate mechanisms were floated as hypotheses, none confirmed: (a) Android's native AAssetManager reading straight from the APK's own bundled assets/ folder (plausible: EAIO.Startup() does pass activity.assets, an AssetManager, into native code) — but this would require game_cache to be bundled inside the APK's assets/, not on external storage at all; (b) plain loose files read via fopen/NDK file I/O from some external-storage path (would need to find where that path is set) — for AllocationList and the actual root/prefix, not yet traced; (c) some other, not-yet-considered mechanism. Not pursued further this session — genuinely needs either tracing the native file-open call chain from RaceLoaderTask_BuildTrackScenePath's eventual sub_33B038/sub_33C284 down to the actual fopen/AAssetManager_open call, or the user's own recollection from prior reverse-engineering of this launcher.

Update — resolved live, same day: switching the launcher project (now at /home/megboyzz/AndroidStudioProjects/NFSMostWanted128, symlinked as launcher/) to its current/correct state and rebuilding actually answered this empirically rather than requiring more static RE: the boot log shows Mounting SKU: texture_dxt3 to /published, Mounting SKU: 1x/2x/4x to /published, followed by real asset loads (FlowManager splash.sba sba, ResourceManager: Add texture: /published/texturepacks_ui/splash_1775.sba_0) — confirmed on-screen too, the actual EA logo splash rendered (not a black screen) on WayDroid. So the OBB is mounted somewhere such that /published/... resolves — mechanism (a)/(b)/(c) above still isn't pinned down architecturally, but it demonstrably works end-to-end now with the corrected launcher+native_libs+OBB file combination. Not worth further static tracing unless it breaks again.

6i. CRITICAL: the previous session's .i64 was stale — silently out of sync with the on-disk binary; rebuilt fresh

What happened: the user replaced native_lib/libapp.so (and the whole native_lib/ set, and the launcher/ project itself — see §6h) with corrected versions partway through the previous session. native_lib/libapp.so.i64 was not rebuilt at that point, but continued to open "successfully" and even reported survey_binary metadata (sha256/md5) that exactly matched the new on-disk file — which looked like reassuring confirmation that the database was current. It was not. Proof, obtained by direct comparison:

  • Raw bytes read straight from the current native_lib/libapp.so file at offset 0x2db384 (via plain Python open().seek().read(), bypassing IDA entirely): 74 00 8D E2 0C 00 8D E5 04 00 A0 E1 15 B6 06 EB → decodes to ADD R0,SP,#0x74 / STR R0,[SP,#0xC] / MOV R0,R4 / BL ....
  • What the (at-the-time still-open, "hash-matching") .i64 showed for that same address: PUSH {R4-R8,LR} / SUB SP,SP,#0x40 / LDR R2,[R0,#0x20] / ... — the RaceLoaderTask_BuildTrackScenePath prologue found and hooked in §6g/§6h.

These are different instructions at the same address — conclusive proof the open .i64 was analyzing stale, cached content, not the file on disk it claimed to correspond to. The likely mechanism: survey_binary's reported sha256/md5 is computed live by re-reading whatever is currently at the recorded input_path on disk, independent of whatever was actually analyzed and cached inside the .i64 when it was first built — so a hash match there proves the external file is unchanged since some point, but says nothing about whether the loaded database's internal snapshot still matches it. Takeaway for future sessions: never trust survey_binary's hash fields alone as proof an .i64 is current after a binary might have changed underneath it — cross-check actual disassembly/raw bytes at a known address directly, exactly as done here.

Fix applied: moved the stale database aside (native_lib/libapp.so.i64.stale_2026-07-31, not deleted — kept for reference/comparison against the old, pre-relocation build if ever needed) and opened native_lib/libapp.so fresh via idb_open (pointing at the .so, not an existing .i64), forcing a full auto-analysis. Verified the fix the same way it was diagnosed: disassembly at 0x2db384 in the fresh session now matches the raw file bytes exactly (ADD R0,SP,#0x74 / STR / MOV R0,R4 / BL).

New binary's basic stats (survey_binary, fresh session): image_size 0xb167d0 (~11.6MB, vs. the old build's 0xe52ebc/~14.9MB), 34,726 total functions (vs. 50,772), 2,425 named, 21,031 strings, 19 segments. JNI entry points are now Java_com_ea_ironmonkey_GameActivityMain_* (matching the renamed GameActivityMain Kotlin class from §6h — the native side was rebuilt to match too, not just Java). New debug-assertion strings not seen in the old build ("GetComponent: called with a component type that allows multiple instances...", "Dereferencing a NULL component pointer.") and libc++'s __ndk1 namespace suggest a different/newer NDK toolchain and possibly a less aggressively stripped build than before. This is a genuinely different compiled build, not merely a renamed or re-packaged file.

Good news: the architecture holds. Re-ran the RTTI string search (RaceLoaderTask|TrackTestLayer|NFSScene|TrackNavigator|OpponentCollection|BitmapGraphics) against the fresh binary — every one of these classes is still present, confirming this is the same engine/codebase lineage, just recompiled with different addresses/layout. One correction/refinement: TrackTestLayer's full namespace is confirmed as im::app::layers::debug::TrackTestLayer (a layers::debug namespace — previously only knew the bare class name), which also confirms the earlier characterization of it as a debug/QA tool directly from its own mangled namespace, not just inferred behavior.

Practical implication: every specific address in §6a–§6h (RaceLoaderTask's vtable at 0xd86210, RaceLoaderTask_BuildTrackScenePath at 0x2db384, TrackTestLayer_ctor at 0x24a1f4, BitmapGraphics_* functions, etc.) is invalid for this binary and needs to be re-derived from scratch using the same techniques (RTTI name → find_bytes walk to vtable, decompile candidate functions, confirm via distinctive strings/constants). The mpcore hook installed in §6g/§6h currently targets a now-meaningless offset in this binary and must not be re-enabled as-is. The current mpcore/main.cpp (per the user's own recent edit, visible in the system reminder) has already moved on from that hook to a fresh dl_iterate_phdr-based base-address lookup with no hooks installed yet — consistent with starting over.

Bonus finding: game_cache/published/tweaks/debug_options.sb is a real, editable in-game debug menu

While searching for "flow", unpacking debug_options.sb revealed it's a full debug-menu configuration tree (PropertyNameCHDR/DebugMenuPath entries), directly useful for the mod and editable with zero binary patching (NFSMW12MobileTools unpack → edit JSON → repack). Top-level categories: AI, Black Market, Camera, Collision, Garage, HUD, RacingLinePreviewLayer, Shader. Notable entries directly relevant to this project:

  • AI/Race/Max Num AI Opponents, AI/Race/Disable AI Opponents — direct control over opponent count, relevant to subtask 2.
  • AI/Race/Disable Rubber Banding, AI/Race/Disable Nitro, AI/Race/Enable Player AI Nitro, AI/Race/Weaken Opponents (top-level Weaken Opponents also exists) — race-tuning toggles.
  • Everything is Available, Everything is Free — content-unlock flags.
  • Infinite Player Nitro, Disable Traffic, Disable HUD, Enable Soak Test — misc QoL/testing toggles.
  • AI/Race/Draw AI Track Navigators, AI/Dynamic Raceline/* debug-rendering toggles — could help visualize the TrackNavigator coordinate system from §6a while developing subtask 4.

Not yet investigated: how debug_options.sb's values actually get read into the running game (same dword_DD2E9C-style config singleton as "flow", presumably) and whether editing this file alone is sufficient or whether the game reads a device-local copy that would need pushing separately. Worth a quick practical test via WayDroid before relying on it.

6j. RaceLoaderTask rediscovered in the fresh binary — vtable, 6 slots confirmed, BuildTrackScenePath equivalent found

Per user instruction ("Начни с RaceLoaderTask"), redid the §6a RTTI-walk against the current, verified-fresh native_lib/libapp.so.i64 (rebuilt in §6i). Same technique: RTTI name string → xrefs_totype_info struct → find_bytes for a pointer to that type_info → real vtable starts 4 bytes after the match.

  • RTTI name N2im3app4race14RaceLoaderTaskE now at 0xa2f570 (was 0xcfe924 in the old binary).
  • type_info struct at 0xaa7938 = {vtable=0xb15fb4, name=0xa2f570, base_typeinfo=0xa9b5a0}.
  • find_bytes for 0xaa7938 (LE) returned 3 hits: 0x620d0 (false lead — the engine's own custom reflection/type-registry table, pattern {ptr, 0x1802} this time instead of the old binary's {ptr, 0x17} — same false-lead shape as §6a, just a different tag constant), 0xaa78e8 (the real vtable — confirmed by the same "long run of .text addresses immediately followed by the class's own type_info" pattern used in §6a), 0xac6514 (unexamined — likely a different class's base_typeinfo field, same as the unresolved false lead in §6a).

RaceLoaderTask vtable: 0xaa78e8, 18 virtual function slots (0xaa78ec0xaa7930) — one fewer than the old binary's 19 (§6a slot 0's "shared thunk" role may have been folded/removed; not confirmed, low priority).

Slot Address Renamed to Role / evidence
0 0x7e320 Very low .text address, shared across many unrelated vtables (same "generic base-class thunk" shape as old binary's slot 0/3/5) — not decompiled.
1 0x2a7e58 RaceLoaderTask_dtor_complete Sets vptr back to 0xaa78ec (own vtable), decrefs a member at a1[41]. Same shape as old slot 1.
2 0x2a8130 RaceLoaderTask_dtor_deleting Calls slot 1 then sub_3D0C04 (operator delete). Same shape as old slot 2.
3 0x7e450 Shared-thunk region, same as slot 0. Not decompiled.
4 0x2a8144 RaceLoaderTask_ExecuteLoadSequence Main load orchestrator, direct equivalent of old slot 4 (0x2dbba4). Calls a progress-setter (sub_408AB4, floats 0.1..0.8) interleaved with 8 stage sub-calls (0x2a8424, 0x2a89a4, 0x2a8b4c, 0x2a8ce8, 0x2a8e2c, 0x2a9274, 0x2a9338, 0x2a9bcc), references /published/texturepacks_ui/in_game.sba.
5 0x7e458 Shared-thunk region. Not decompiled.
6 0x2ade58 nullsub_258 — matches old slot 6 (also a nullsub) in relative position.
7 0x2aa934 Setter: a1[16..18] = a2[0..2] with refcount release on the old value — matches old slot 7's "Set" shape. Role unconfirmed.
8 0x2aab34 Takes (this, a2); builds a key via sub_66FFB0/sub_2AAE68, then appends a 3-word entry {a2[0],a2[1],a2[2]} into a growable vector at this+76/80 (realloc via sub_D746C), plus registers into this[11]+160 via sub_27DFF0. Vector-append shape suggests building a collection (candidate: opponent-car list), but no distinctive string found — do not treat as confirmed "SetupOpponentCar" (old slot 11 had the literal string "opponentCar"; this one doesn't). Left unnamed.
9 0x2aafac RaceLoaderTask_SetupPlayerCar References string "playerCar" — same as old slot 9.
10 0x2ab4c4 Single-arg (this); refcounted lookup/creation against a component at this+40/this+32, dispatches through a callback vtable at off_AA7978. No distinctive string. Not confidently named.
11 0x2abbf0 Trivial 1-line thunk: return sub_2AFED0(a1, a3); — pure forwarding, not investigated further.
12 0x2abc04 Factory: allocates either a 0x14-byte or 0xAC-byte object (branching on a flag byte at *a3+76), tagging it with mini-vtable off_AA7990 or off_AA5048. These mini-vtables are refcount-only closure wrappers ({funcptr, funcptr}, no RTTI/typeinfo — confirmed by reading their first word, which is a plain code address, not a typeinfo pointer), so class identity can't be recovered via RTTI here. Not confidently named.
13 0x2abe08 RaceLoaderTask_ResetStartingLine References string "m_StartLine" + "Assertion failed (" — direct equivalent of old slot 14 ("ResetLine").
14 0x2acfa8 nullsub_257.
15 0x2acfac Iterates the same vector shape as slot 8 (this[19]/this[20], 3-word stride), calling sub_152250(raceCtx, ..., key, ...) per element plus once before the loop — looks like a per-entry registration/lookup pass over whatever collection slot 8 builds. Not confidently named.
16 0x2ad4a8 RaceLoaderTask_HandleSpikeStrip dynamic_cast<im::app::bt::BlacklistTech> → im::app::bt::SpikeStrip — direct equivalent of old slot 17.
17 0x2ada80 Allocates 3 closure objects (mini-vtables off_AA7A58/AA7A80/AA7AA8, same no-RTTI shape as slot 12) and registers each via sub_2848A0(this[40], ...), then loops calling a virtual at *this+68 per element of a collection at *(this+16)+68. Looks like registering 3 event listeners against whatever this[40] is (a dispatcher/FSM?), then notifying per-collection-element. Not confidently named.

Renamed and saved this round (6 of 18 slots, all backed by a distinctive string or exact structural match to the old binary — the rest are left as sub_/generic to avoid repeating the "unverified claim" mistake from §6h): RaceLoaderTask_dtor_complete, RaceLoaderTask_dtor_deleting, RaceLoaderTask_ExecuteLoadSequence, RaceLoaderTask_SetupPlayerCar, RaceLoaderTask_ResetStartingLine, RaceLoaderTask_HandleSpikeStrip.

BuildTrackScenePath equivalent found — stage 1/8 of ExecuteLoadSequence, address 0x2a8424. Decompiling all 8 stage sub-calls, the first one (0x2a8424, called first, matching old slot's call order) references the exact strings "published/prefabs/tracks/" (0x9d7498) and ".scene.sb" (0x9d74b2) — the same path-construction signature as old RaceLoaderTask_BuildTrackScenePath (0x2db384, now stale). Renamed to RaceLoaderTask_BuildTrackScenePath.

ARM-mode confirmed at the new address too: raw bytes at 0x2a8424 are F0 4F 2D E9 = 0xE92D4FF0 = PUSH {R4-R11,LR} (cond nibble E, classic 32-bit ARM encoding) — same situation as §6g's old-binary finding, so the same custom ARM-mode inline-hook design (overwrite first 8 bytes / 2 whole ARM instructions with LDR PC,[PC,#-4] + hook address; trampoline relocates the displaced instructions) is directly reusable here, just against this new address. Not yet re-implemented in mpcore this round (the old hook code was removed from main.cpp by the user per §6i — see PROGRESS.md).

Next step: trace where RaceLoaderTask_BuildTrackScenePath's track-name field (equivalent of old this[8]+72) gets populated, confirm the field offset in this binary (register/stack layout may differ from the old build), then re-implement the ARM-mode hook in mpcore/src/main/cpp/main.cpp targeting 0x2a8424, matching the design already proven working in §6g (just against new addresses).

6k. ARM-mode hook implemented and live-tested on real hardware (Pixel 6a) — mechanism works, exposes a real data-consistency limit

Per user instruction, implemented the ARM-mode inline hook designed in §6j/§6g against the fresh binary's RaceLoaderTask_BuildTrackScenePath (0x2a8424), in launcher/mpcore/src/main/cpp/main.cpp, and tested live via adb on the Pixel 6a (GrapheneOS) device — see reference-pixel6a-grapheneos-testing memory. This is the first live test of the mod's actual hook mechanism on any device (WayDroid never got far enough; the previous ARM hook in §6g was tested against the now-stale old binary).

Implementation (Hook_BuildTrackScenePath): reads raceDefPtr = a1[8] (word offset 8 = byte 32, confirmed identical to the old binary), then repoints the {begin,end} pointer pair for the track-name field (raceDefPtr+72/+76) at a static literal "region3_colorado_track2" — and, in a follow-up fix, also the environment-name field (raceDefPtr+100/+104) at "colorado" (matches the .prefabs.sb's actual region folder). Deliberately does not free/reallocate the original buffers (leaks them — one tiny allocation per race load, negligible) since BuildTrackScenePath only ever reads these fields, never frees them; this avoids the capacity-field-offset guessing risk flagged as a concern before implementation. Hook installed via 8-byte ARM-mode patch (LDR PC,[PC,#-4] + hook address) at JNI_OnLoad, exactly as designed in §6g, just retargeted to the new address; trampoline relocates the 2 displaced PUSH/ADD instructions (both confirmed position-independent) and jumps back to target+8.

Live test 1 (track-name override only): installed cleanly (mpcore_log: Installed RaceLoaderTask_BuildTrackScenePath hook at 0xd7eff424, trampoline=0xe69e3000 — matches libapp_base + 0x2a8424 exactly), no crash through menu navigation. Started the "Петерсон стрит" event (a real, working event — confirmed crash-free with the unmodified build in the same session, see PROGRESS.md). Hook fired (BuildTrackScenePath hook fired: overriding track name -> region3_colorado_track2) and the engine genuinely started loading Colorado-region assets (Add asset: /published/textures/collidables/texture_collidables_colorado.sba, colorado skydome references) instead of the real event's track — conclusive proof the field override reaches the engine's actual path-construction logic. However, ~150ms later: Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x00000038 in a background thread (Thread-9), Cause: null pointer dereference, register r0=00000000.

Live test 2 (track-name + environment-name override): hypothesized the crash was caused by the environment field still pointing at the original (non-colorado) event's environment while the track name pointed at colorado — a plausible mismatch given BuildTrackScenePath builds "published/prefabs/environments/" + envName + ".prefabs.sb" from that same struct. Added the environment override and retested. Identical crash — same fault address 0x38, same thread name Thread-9, and (confirmed by computing pc - libapp_base from both tombstones) the exact same code offset both times (0x53a604), ruling out the environment-mismatch theory.

Root cause, confirmed by decompiling the crash site (sub_53A5FC at 0x53a5fc, file offset 0x53a604 is its second instruction):

int sub_53A5FC(int *a1, int *a2) {
    if (a2) { *a2 = a1[14]; a2[1]=a1[15]; a2[2]=a1[16]; a2[3]=a1[17]; }
    return a1[13];
}

a1[14] = byte offset 14*4 = 56 = 0x38exactly the crash fault address, and r0 (== a1, the first argument) was 00000000 at crash time, per the tombstone register dump. So some caller passed a NULL object pointer into this small getter (looks like a generic transform/bounds accessor — copies a 4-word block, e.g. a quaternion or bounding-sphere, plus a flags word). xrefs_to found 21 distinct call sites across what looks like physics/rendering component code (sub_496AE8, sub_4970D8, sub_53EDAC, sub_53F060, sub_5749A0, sub_57AB8C, sub_57B0C4, etc.) — this is common shared utility code, not something track/environment-specific, so tracing the exact call chain back to a specific RaceLoaderTask stage was not pursued further this session (would need substantial additional tracing across 21 call sites).

Working theory (consistent with the evidence, not yet independently confirmed by tracing further): RaceDefinition carries more than just track/environment name — checkpoint list, opponent list, starting-grid data, etc. (see RaceLoaderTask_ResetStartingLine, RaceLoaderTask_LoadRaceFSM's checkpoint/car iteration in §6j's stage table) — none of which our hook touches. Those still reference IDs/objects from the original "Петерсон стрит" event, which don't exist in the substituted Colorado scene. A background streaming/physics thread (Thread-9) walking one of these now-dangling references gets a NULL lookup result and calls the unguarded getter on it. In other words: swapping only the track-name/environment-name fields performs a real, engine-level scene substitution, but is not sufficient for a fully arbitrary track swap — the race-logic fields (checkpoints, opponents, starting grid) need to come from a source consistent with the new track, not the old event.

Bottom line for subtask 1: the hook mechanism itself (ARM-mode 8-byte patch, trampoline, field-pointer override, no-free-leak strategy) is proven correct and crash-free in isolation — the crash is a downstream data-consistency issue, not a hook bug. Confirmed no tombstones/crashes at all when the same event was played through normally (unmodified) earlier in this session (see PROGRESS.md), isolating the regression to the override itself. Next step: either (a) pick a substitute track from an event whose checkpoint/opponent data is actually compatible with the new scene (unlikely to generalize), or (b) extend the hook to also intercept/rewrite the checkpoint and opponent-placement stages (RaceLoaderTask_ResetStartingLine, RaceLoaderTask_LoadRaceFSM) so they resolve against the substituted track's own data instead of the original event's — the more general, correct fix for subtask 1's "arbitrary track" goal.

6l. Debugger attach attempted, blocked by environment; lightweight Log() diagnostics confirmed the §6k theory instead

Per user request, tried attaching lldb to the live game process on the Pixel 6a for a full memory-map view (the "harder path" alternative to more Log() calls), since the device is userdebug with su available.

Debugger attempt — inconclusive, blocked by environment, not by anything RE-specific: pushed a version-matched lldb-server (confirmed exact match: both client and server report lldb version 18.0.1, identical revision hash d8003a456d14a3deb8054cdaa529ffbf02d9b262, client sourced from $ANDROID_SDK/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/). su 0 lldb-server platform --listen '*:1234' --server started and bound the port cleanly (netstat confirmed LISTEN), adb forward and raw TCP connect both worked. But the gdb-remote handshake never got a reply (error: failed to get reply to handshake packet) even with the client-side timeout raised to 90s, and a raw Python socket probe sending a well-formed $QStartNoAckMode#b0 packet got zero bytes back within 10s on an otherwise-healthy connection. Root cause eventually found: the device's screen had locked (fingerprint lock screen) partway through the session, and while locked, adb shell/su round-trips degraded unpredictably (34ms one moment, 80300s+ hangs the next, tracked via time adb shell echo) — almost certainly Android's Doze/screen-off throttling affecting the su-elevated shell and/or lldb-server's connection-handling thread. After the user physically unlocked the device, adb shell responsiveness returned to consistent sub-100ms — but by then the debugger session itself needed re-establishing from scratch, and rather than keep re-attempting, pivoted to the lighter-weight Log() approach (see below) at the user's direction, since it doesn't depend on a fragile interactive session. Not marked as "doesn't work" — the version-matching and forward/listen mechanics are confirmed correct; a retry after ensuring the device stays unlocked/awake (e.g. adb shell svc power stayon true or disabling screen timeout first) would very plausibly succeed. Worth revisiting if Log()-based diagnostics hit their limit.

Log()-based diagnostic (the approach actually used this round): added temporary diagnostic logging to Hook_BuildTrackScenePath (launcher/mpcore/src/main/cpp/main.cpp) dumping, before any override is applied:

  • The original track name (to confirm which real event/track was hit).
  • raceDef+40/44 — a second {begin,end} string field sub_2A9338 (RaceLoaderTask_LoadRaceFSM, stage 7) reads and compares against itself (!= — turned out to just be an "is this non-empty" check, not an equality-against-another-field check as originally guessed in the stage-7 decompile).
  • *(raceDef+12) — a nested struct pointer sub_2A9338 dereferences, then reads that struct's +48/+52 as the actual name substituted into "/published/prefabs/racefsms/{0}.prefabs.sb".

Live result on the real "Петерсон стрит" event (before override, i.e. ground truth):

diag: original track name = 'region1_foothills_track4' (len=24)
diag: raceDef+40/44 field = '' (len=0, begin=0xd5670a0c)
diag: racefsm name = 'point_to_point_fsm_newintro' (len=27)

This is a meaningful finding: the race-FSM prefab (point_to_point_fsm_newintro) is a generic, reusable race-type template — "point to point" checkpoint-race rules, not a per-track asset — confirming the racefsms layer itself isn't what breaks when the track is swapped. This refines (without contradicting) the §6k working theory: the crash isn't from loading the wrong FSM (the FSM is track-agnostic by design), it's from the generic FSM's checkpoint/actor lookups failing against the substituted scenepoint_to_point_fsm_newintro presumably walks checkpoint-tagged actors by name/count in whatever scene is currently loaded, and region3_colorado_track2's actual checkpoint layout doesn't satisfy whatever this FSM variant (note the _newintro suffix — possibly implies an expected intro-camera/cutscene actor too) expects.

A quick strings-based comparison of region1_foothills_track4.scene.sb vs region3_colorado_track2.scene.sb for checkpoint-related tokens (Checkpoint, start, finish) found identical type/prefab names in both — but strings can't distinguish instance counts or IDs within the packed SB DataIdsMap structure, so this doesn't confirm or rule out a checkpoint-count/tag mismatch. A real answer needs either the full NFSMW12MobileTools-based SB unpack-and-diff (fast, no binary RE) or the debugger. Done immediately after, below — root cause now confirmed directly.

6m. Root cause confirmed: checkpoint-container name AND count both mismatch between the original event and the substituted track

Per user request, unpacked both .scene.sb files via NFSMW12MobileTools.jar unpack (jar at NFSMW12MobileTools/NFSMW12MobileTools.jar, requires HCStructFileArray.json copied into the working directory) and diffed their checkpoint actor structure directly — no debugger needed, this closes the investigation with hard evidence.

Tool note: region3_colorado_track2.scene.sb (the exact file we hook to) crashes the unpacker's full DATA-object parser (NegativeArraySizeException in SBin.getCleanElementHex/parseDATABlock — an unrelated pre-existing bug in the community tool, not something we introduced). Worked around two ways: (a) -disableDATAObjectsUnpack still parses the CDAT string table (CDAT_Strings) even though it skips structured per-object field parsing, which was sufficient here; (b) as a cross-check, region3_colorado_track1.scene.sb (a sibling colorado track, same region) unpacks fully with no flags and confirms the same pattern independently.

region1_foothills_track4.scene.sb (the real track behind the "Петерсон стрит" event we've been testing against, confirmed via the live diag: original track name log in §6l) — full unpack, walked the top-level 04000000 actors DataIdsMap (11 entries: checkpoints_timetrial_event_2, end_of_track, environment_sound, event_02_finish, finish, foothills_export_group, roadblock_level_02, root, skydome, start, track_info), then the checkpoint container's own children map:

  • Container name: checkpoints_timetrial_event_2 — note the _event_2 suffix, tying it to a specific numbered event instance, not a generic per-track asset.
  • 6 checkpoints: timetrialcheckpoint, timetrialcheckpoint_2timetrialcheckpoint_6.

region3_colorado_track2.scene.sb (our override target) — via the CDAT_Strings table (all unique strings referenced anywhere in the file, present even with -disableDATAObjectsUnpack):

  • Container name: timetrial_checkpoints — generic, no event-number suffix, structurally different name than the foothills track's container.
  • 8 checkpoints: timetrialcheckpoint, timetrialcheckpoint_2timetrialcheckpoint_8.

Cross-checked against region3_colorado_track1.scene.sb (full parse succeeded): same generic container name (timetrial_checkpoints, no event suffix) and a different checkpoint count again (7) — confirming this naming convention (generic container, no event-number tie-in) is consistent across the colorado region, and that per-track checkpoint counts vary freely, not fixed at 6.

This is the confirmed root cause of the §6k SIGSEGV: RaceDefinition (or whatever populates the checkpoint-lookup path reached via RaceLoaderTask_LoadRaceFSM's generic point_to_point_fsm_newintro FSM) looks for a checkpoint container tied to the original event — by name (checkpoints_timetrial_event_2) and/or by an expected count of 6 — inside whatever scene is currently loaded. Our hook substitutes the scene with region3_colorado_track2, which has no actor named checkpoints_timetrial_event_2at all (its container is timetrial_checkpoints) and has 8, not 6, checkpoints. The lookup fails, returns NULL, and a background thread walking the (non-existent) 6th/7th checkpoint or the missing named container dereferences it unchecked — matching the tombstone's r0=NULL / fault addr 0x38 exactly.

Practical implication for subtask 1 (arbitrary track loading): a clean, general "load any track" hook needs to do more than swap the track/environment name strings (§6k's approach). Two viable directions, neither implemented yet:

  • (a) Track-compatible substitution: only offer tracks whose checkpoint container is named to match what the current event expects (i.e. rename/alias at the hook level: also patch whatever field the FSM uses to look up the container name, pointing it at the substituted track's actual container name — here timetrial_checkpoints instead of checkpoints_timetrial_event_2 — this is a bounded, mechanical fix: one more string-field override, same pattern as the track/environment overrides already working).
  • (b) Generic N-checkpoint handling: confirm whether the FSM's checkpoint walk is truly hardcoded to a specific expected count (would need to also override wherever that count is read from) or dynamically discovers however many checkpoints exist under whatever container name it's given (more likely, given timetrialcheckpoint_N numbering is open-ended per track) — if the latter, fixing (a) alone might be sufficient for a full solution.

Recommended next step: extend Hook_BuildTrackScenePath (or add a second hook nearer RaceLoaderTask_LoadRaceFSM, 0x2a9338) to also override the checkpoint-container-name field with the substituted track's real container name (timetrial_checkpoints for any colorado track, but this will differ by region/track — needs to be read from the target .scene.sb or hardcoded per supported track). This is the same low-risk "repoint a {begin,end} string pair" pattern already proven working for track/environment names in §6j/§6k — just needs the right field offset identified (not yet located; RaceLoaderTask_LoadRaceFSM's decompile in §6k shows the racefsm name field at *(raceDef+12)+48/52, but not yet which field feeds the checkpoint-container lookup specifically — likely a sibling field on that same nested struct, worth checking first).

6n. region3/colorado is cut/incomplete content — switched hook target to region4_chicago_track4; exposed the real bug (missing null check in RaceLoaderTask_ResetStartingLine)

Continued the §6m whack-a-mole (patching each newly-discovered null-deref with a defensive ARM-mode entry-hook/trampoline guard — sub_53A5FC, sub_52A9B8, sub_52A620) until it led somewhere conclusive rather than indefinitely: sub_58E5E8, a recursive spatial-index/BVH builder, crashing on a NULL array pointer at a1+152 — traced its allocation (sub_58E2E8) back to a per-item count populated by iterating actor bounding-boxes (sub_58B65C), which only reaches zero/absent if the substituted scene's geometry never actually loaded.

Checked directly: game_cache/published/models/environments/ contains folders for chicago, desert, foothills, garage, newyorkno colorado folder at all, even though region3_colorado_track1/2/3.scene.sb and a single un-numbered colorado.prefabs.sb exist under prefabs/. Every other region ships numbered per-track environment prefabs (foothills1..6, desert1..6, chicago1..6, newyork1..6); region3/colorado has only the one combined file, no colorado1..6. Unpacked region3_colorado_track2.scene.sb and confirmed it references "published/models/environments/colorado/region3_colorado_track2.m3g" as a loose external file — which was never shipped for the mobile release. Conclusion: region3/colorado is unfinished/cut content — its track-layout and prop data survived in the package, but its environment model was never exported as a loadable asset. No amount of downstream null-guarding can fix this; the geometry genuinely cannot load.

Action taken: reverted all 4 defensive null-guard patches (sub_53A5FC/sub_52A9B8/sub_52A620, the sub_58E5E8 chain was never patched, investigation stopped there) and switched Hook_BuildTrackScenePath's override target to region4_chicago_track4 (confirmed shipped and playable — ties to event_01_race.prefabs.sb's real TrackName, verified start/finish actors, per §6). First rebuild used kOverrideEnvName = "chicago" — wrong: hit Could not open database at published/prefabs/environments/chicago.prefabs.sb (immediate SIGSEGV), because — like all other regions — environment prefabs are the numbered per-track files, not a generic <region>.prefabs.sb (that pattern is unique to unfinished colorado). Fixed to "chicago4" (matching region4_chicago_track4) — geometry then loaded cleanly (no more "not found"/"could not open" warnings in logcat).

With real geometry loading, hit a new, different, and much more informative crash: fault addr 0x14, r0=r1=r2=r3=0, inside sub_870E8 — decompiled cleanly as a textbook ECS GetComponent<im::app::race::Checkpoint>(entity) helper (iterate a Component* range on entity, dynamic_cast each to Checkpoint, return the first match). Crash is the very first field read (entity[5], i.e. entity+20 = 0x14, matching the fault address exactly) — entity itself is NULL. Its only 3 callers are all inside the already-named RaceLoaderTask_ResetStartingLine (0x2abe08, named in an earlier session from its "m_StartLine" string reference) — which looks up an actor by name via sub_672D64(&result, scene, raceDef+164/180/196/212) for m_StartLine/m_FinishLine/m_EndOfTrack/a 4th field, then calls GetComponent<Checkpoint> on the result with no null check, unlike every other access in that same function (which all consistently assert "Dereferencing a NULL component pointer" first). This is a genuine, pre-existing engine bug that the original developers never had reason to hit, because every shipped event's named actors always existed in its own paired scene.

Added temporary diagnostics dumping raceDef+164/180/196/212 as {begin,end} string pairs (same layout as the track/env name fields) and reproduced live on "Петерсон стрит" (still targeting region4_chicago_track4/chicago4):

raceDef+164 name = 'start'
raceDef+180 name = 'event_02_finish'
raceDef+196 name = 'end_of_track'
raceDef+212 name = 'checkpoints_timetrial_event_2'

This is the true, final root cause, and it fully subsumes §6m's checkpoint-count theory: m_StartLine/m_EndOfTrack are generic names, present in effectively any track's scene — those lookups succeed regardless of substitution. But m_FinishLine and the checkpoint-container field hold per-event custom actor names (event_02_finish, checkpoints_timetrial_event_2 — the literal event-2/timetrial identifiers, tied to the original "Петерсон стрит" event's own data, not to any track/scene convention). No substituted scene will ever have an actor named event_02_finish unless it happens to be the exact original track. This is why §6k/§6m's whack-a-mole never converged: the underlying issue isn't a fixable data mismatch, it's that time-trial/checkpoint-style events are inherently incompatible with simple track-name substitution — their RaceDefinition hard-references scene-specific actor names that only the original track satisfies.

Confirmed by direct test: switched to a different event of the regular point-to-point race type — "Побудка" (Macklein street, class "Каждый день", original track region5_newyork_track2). Diagnostics on this event show only generic names:

raceDef+164 name = 'start'
raceDef+180 name = 'finish'
raceDef+196 name = 'end_of_track'
raceDef+212 name = '' (empty)

No custom suffixes anywhere. Rebuilt/reinstalled with the reverted (no defensive patches) build, played this event with region4_chicago_track4/chicago4 substituted in — the race loaded and ran successfully: live gameplay on chicago4's geometry, correct HUD (position 6/6, timer), AI opponents present, no crash, sustained over multiple seconds. The BuildTrackScenePath hook mechanism is fully validated end-to-end for regular races.

Practical implication for subtask 1: the simple track/environment-name override (as implemented) is sufficient and correct for regular point-to-point races. Time-trial/checkpoint events need one of: (a) also overriding raceDef+180/+212 to generic names when the target scene doesn't have the original's custom-named actors (risks silently changing the event's intended finish-line/checkpoint layout), or (b) restricting arbitrary-track substitution to regular-race event types only and leaving time-trial events pinned to their original track. Not decided/implemented yet — a scope decision, not a bug to patch.

6o. Scope decided (regular races only); cleaned up to the minimal working hook; visually confirmed with a baseline A/B comparison

Following §6n's finding that time-trial/checkpoint events are structurally incompatible with simple track-name substitution (their RaceDefinition hard-references per-event custom actor names), the user made the scope call directly: the track-substitution hook only needs to support regular point-to-point races. Time-trial and single-opponent/pursuit-style races are explicitly out of scope — their crashes are expected-unsupported, not bugs to chase. Recorded as a standing project-memory fact (track-substitution-scope) so future sessions don't re-litigate or re-chase this.

Cleanup: removed all temporary investigation code from Hook_BuildTrackScenePath in launcher/mpcore/src/main/cpp/main.cpp — the raceDef+40/44, +12 nested-struct, and +164/180/196/212 diagnostic Log() dumps (their job was done once §6n's findings were captured). The 4 defensive null-guard patches from the §6n whack-a-mole (sub_53A5FC/sub_52A9B8/sub_52A620, plus the abandoned sub_58E5E8 investigation) were already reverted in §6n itself. Current main.cpp state: JNI_OnLoad installs exactly one hook, Hook_BuildTrackScenePath, which does nothing but repoint the track-name and environment-name {begin,end} string pairs on raceDef (+72/76 and +100/104) to kOverrideTrackName = "region4_chicago_track4" / kOverrideEnvName = "chicago4". No diagnostics, no defensive guards, no dead code.

Visual A/B verification (requested by the user — logcat text isn't proof the geometry itself changed, only that the string pointers were overwritten): built and ran two variants of the identical event ("Побудка", a regular race at Macklein):

  • Hook disabled (temporarily commented out the InstallBuildTrackScenePathHook() call in JNI_OnLoad, rebuilt, reinstalled): loads the real, original region5_newyork_track2 — a nighttime downtown street, a "HOTEL" building, road signs reading "Franklin Plaza / Rochelle Hall" and "Emerson Greenway", a distinct guardrail/road style.
  • Hook enabled (re-reverted the comment-out, rebuilt, reinstalled): loads region4_chicago_track4 — a daytime highway/overpass, road signs reading "South 92", "McClane", "Ripley's Point", visibly different geometry, lighting, and time-of-day.

Different time-of-day, different road geometry, different signage, different buildings — conclusive, non-coincidental visual proof the same event now genuinely renders different track geometry depending solely on whether the hook is installed. Subtask 1 (arbitrary track loading) is now considered validated end-to-end for regular races — both mechanically (hook installs, fires, no crash, sustained multi-minute gameplay) and visually (real geometry swap, not just a label change).

6p. Street/POI event lists are NOT simple SB data; found the engine's file-open chokepoint instead (validates a memory-only patching strategy)

Two separate investigations, prompted by the user's question about adding a virtual "LAN: " entry to an existing street's event list, and the follow-up theory that SB files could be edited entirely in-memory from mpcore without touching game_cache/the OBB.

1. Where street/POI groupings come from — not found in data yet. Unpacked all 62 game_cache/published/data/races/event_*.prefabs.sb files (NFSMW12MobileTools, full struct parse, no workaround flags needed — small files, none hit the NegativeArraySizeException bug). Each contains one RaceEvent struct with a Location field — but across all 62 events, Location only takes 4 values: Chicago, Foothills, NewYork, Desert (the coarse region, matching the environment). It is not the street-level label shown on the map (МАККЛЕЙН/ПЕТЕРСОН СТРИТ). Name is a localization key (EVENT_NAME_2, etc.), not a literal string either. Also unpacked flow/menus/map_overworld.sb (the map screen's Flow-machine script) — it contains only generic UI screen/transition wiring (output/node/Transition triples for buttons like EVENT, GARAGE, STORE) and a layout: map_overworld reference; no event-ID lists, no street names, no coordinates. Conclusion: the street/POI clustering that groups nearby events under a named map pin is not stored as simple, easily-editable SB data anywhere checked so far — it's most likely computed natively in C++ at runtime (e.g. proximity-clustering each RaceEvent's track start-position against a small set of named zone boundaries), which would need further binary RE (not attempted yet) to locate precisely, not just an SB edit. This directly answers the original "street event" question: adding a synthetic entry isn't a quick data-only change; it needs a native hook once the clustering code is found.

2. The memory-only-patching theory — confirmed correct, and the hook point is now identified. Traced how libapp.so actually opens files on disk, starting from the RaceEvent-parsing code's generic string-keyed property accessors (sub_4F9A80 etc., confirming SB files are parsed into an in-memory key-value property bag once, then read by name everywhere — not re-parsed per access) down to the real I/O layer:

(resource-loading code, not yet fully enumerated)
        │
        ▼
sub_8604A0(a1, path, a3)          "load whole file into buffer" helper
        │  calls sub_8598A4(path, "rb")
        ▼
sub_8598A4(path, mode) -> FILE*   only 4 callers total in the whole binary
        │  calls j_fopen(path, mode)
        ▼
j_fopen (0x89d350)                 thin thunk to libc fopen(), only 4 callers
        │
        ▼
      libc fopen()

sub_8598A4 (0x8598a4) is the practical hook point: a small, direct (const char* path, const char* mode) -> FILE* function with exactly 4 call sites in the entire binary (sub_8604A0 "read whole file" helper, plus 3 others not yet inspected: sub_8BBD94, sub_8D6D9C, sub_8FC64C). This is far more general-purpose than the track/environment-name hook — it's the choke point for every file the engine opens by path, not just track scenes.

What this enables: hook sub_8598A4, check the incoming path against a list of virtual filenames we care about (e.g. "published/data/races/event_02_timetrial.prefabs.sb", or an entirely new, game-never-shipped filename), and if it matches, return a FILE* from fmemopen()/open_memstream() backed by a buffer we control (a hand-edited copy of the original SB bytes, or a fully synthetic one) instead of calling through to the real fopen. For every other path, call through unmodified (pass-through, matching this project's "no-op when not relevant" hook principle). This achieves genuinely disk/cache-untouched patching — no native_lib/game_cache file is ever modified, the substitution happens purely in mpcore's memory at load time, and it works for any SB file the engine reads (race definitions, flow scripts, checkpoints, etc.), not just the track path fields the current BuildTrackScenePath hook touches.

Not yet done: actually implementing/testing this hook (would need a mmap/trampoline install like the existing BuildTrackScenePath hook, since sub_8598A4 is a plain ARM function — first-instruction check not yet done), confirming the mode-string comparison approach works for text vs. binary opens, and inspecting the 3 other callers (sub_8BBD94/sub_8D6D9C/sub_8FC64C) to rule out anything env-specific. This is a substantial, foundational new capability (general asset override, not just track substitution) — worth a deliberate go/no-go and its own test cycle before implementing, rather than folding into the existing hook.

6q. sub_8598A4 hook implemented and tested — mechanism proven, but it's the wrong chokepoint for game assets (corrects §6p)

Implemented the hook proposed in §6p as a proof of concept: trampoline-hooked sub_8598A4 (libapp_base + 0x8598a4, same PUSH/SUB-relocation pattern as BuildTrackScenePath), logging every (path, mode) pair and substituting an fmemopen()-backed buffer for any path containing "event_02_timetrial". The substitute payload: event_02_timetrial.prefabs.sb unpacked via NFSMW12MobileTools, CashReward.Gold/Silver/Bronze edited 10500/8500/700099999/88888/77777, repacked to a valid .sb, embedded as a C byte array (mpcore/src/main/cpp/test_event_02_data.h). Gated behind its own toggle flag (kEnableFileOpenHook, same pattern as §6o's kEnableTrackSubstitutionHook), on a new branch (file-open-hook-poc, based on track-hook-toggle-flagmaster in this repo is a stale, unrelated baseline predating all of this work, confirmed this session, not touched).

Mechanism confirmed working: the hook installs cleanly and genuinely intercepts real engine fopen() calls — logcat shows sub_8598A4 hook: fopen('/home/ogami/output-arm/openssl.cnf', 'rb') firing during app startup (a build-machine-path leak from EA's OpenSSL config, harmless but proves real interception).

Wrong function for game assets: navigated through several screens that definitely read .sb data (map, event list, the event_02_timetrial-backed "Петерсон стрит" → "На время" event card, which still showed the original 10 500$/8 500$/7 000$ unchanged) — sub_8598A4 was never called again after the one OpenSSL open at startup. Checked the other 2 named callers of the shared j_fopen thunk that §6p's trace was built on: sub_859528 is a generic stream-command dispatcher (seek/tell/flush/close opcodes, not a general per-asset opener) and sub_8AC1F4 is a file-hashing utility (reads in 1KB chunks through an accumulator function, sub_8AC10C — looks like a checksum/integrity-check pass over a whole file, not the resource loader). None of j_fopen's 4 callers are the actual published/data/races/*.sb-reading code path.

Revised theory: individual game asset files are most likely not opened via fopen()/j_fopen() at all. The likelier design (common for mobile games, avoids per-asset syscall overhead): the whole OBB/asset bundle is opened/mapped once at startup (candidate: the sub_3FB6E4/"Mounting SKU" registration path from §6c's exploration, or a raw open()+mmap() pair — open does have 11 callers in this binary, not yet checked), and individual "files" like event_02_timetrial.prefabs.sb are served by looking up a name in an in-memory index/directory and returning a pointer+length slice into that already-mapped memory — no repeated per-file fopen. If true, this is actually a better hook target than a fake FILE*: intercepting after the name→pointer lookup would let a substitute just be a raw buffer swap, no fmemopen needed.

Status: hook mechanism (trampoline install, path matching, in-memory substitute payload, toggle flag) is proven and reusable — only the target function was wrong. Not reverted (harmless with kEnableFileOpenHook currently pointed at the wrong function, effectively a no-op for game data since sub_8598A4 is never called with a matching path) — left in place on the file-open-hook-poc branch as a ready-to-repoint scaffold. Not yet done: trace the sub_3FB6E4 mount-table path, or the 11 open() call sites, to find the real name→data resolution function.

6r. Found VFS::OpenInputStream (the real universal file-resolver) — then pivoted per user direction: runtime object injection, not file substitution

Continuation of §6q's search for the real path→data resolver. Traced up from sub_208C88 (RaceEvent-loading function, confirmed via its "/published/data/races/" string reference and by producing the same raceDef struct the BuildTrackScenePath/ResetStartingLine hooks already use) through sub_6753FCsub_4F0138, which calls a virtual method at vtable offset+8 on a lazily-constructed singleton (sub_40E8E8(), whose class vtable is off_AB2084, explicitly confirmed as the engine's VFS class via the literal string "VFS::AddVariant(" in its constructor sub_40E9F4). Read the vtable bytes directly (get_bytes at 0xAB2084) and resolved slot+8 to sub_410808, which decompiles to exactly VFS::OpenInputStream (confirmed via its own literal strings: "openInputStream \"", " .. returning variant \"", "\" mapping to fs path \"") — and, decisively, sub_4F0138 prints the exact literal "Could not open database at " when this returns null, the identical error text observed live in §6n when the chicago vs chicago4 environment-name mistake was made. This conclusively identifies sub_410808 as the true, universal, per-path file resolver used for every published/... asset in the game (not fopen-based at all - §6q's sub_8598A4 hook was chasing a red herring; individual SB files are resolved via this VFS virtual-path→real-fs-path mapping, then handed to a per-backend "open real file" call, not raw libc fopen). First 2 instructions (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C) are the same hookable, position-independent shape as every other hook this session.

Not pursued further as a hook target, per explicit user redirection: sub_410808 returns a refcounted C++ stream interface object (AddRef/Release-style calls visible on it in sub_4F0138), not a raw buffer/FILE* - faking one correctly would need reverse-engineering its exact vtable contract, a nontrivial and crash-prone undertaking. More importantly, the user clarified the actual goal isn't "swap file content before the game reads it" at all - it's runtime injection into already-loaded/parsed game objects (e.g. the map loads normally, then a hook adds a synthetic "LAN: " entry into an already-populated street's event list) - the same category of technique already proven working for RaceDefinition (raceDef+72/76 field repointing in BuildTrackScenePath), just applied to a different, later point in the pipeline. sub_410808's file-open-time hook doesn't serve that goal even if fully implemented.

New lead for the actual goal: searching RTTI for the map/street screen surfaced im::app::flow::nfs::MapScreen (the map screen's controller class - sibling of the already-known im::app::flow::nfs::PostRaceMetagameScreen/GarageScreen/CarUnlockScreen/CongratsScreen family) and, in one of its method signatures, boost::shared_ptr<im::app::ui::MapTrack> - strongly suggesting MapTrack is the per-event/per-marker runtime object the map screen holds one of per visible event. Not yet done: find where MapScreen builds/holds its MapTrack collection (constructor or an UpdateXxx/Populate-style method), and MapTrack's field layout (display name, reward, target event-ID/track reference) - once both are known, the established "read/repoint fields on an already-loaded C++ object" technique (same as every hook this session) can inject a synthetic entry after the real collection is populated, exactly matching the user's actual request. Checked career.prefabs.sb (the one non-numbered file under data/careers/) as a possible data-driven source for street groupings first - it only holds progression tiers (tier_1..tier_55-style TierItems) and unlockable car lists, not street/location data, ruling it out.

6s. Runtime injection into a street's event list — found the real classes and the setup function, not yet the exact "add card" call

Continuation of §6p, redirected by explicit user feedback: the user does not want a disk-touching approach (writing a substitute file to app-private storage, or faking a VFS stream) — they want the street's event list modified in memory, at runtime, after the map has already loaded, the same way a real multiplayer client would announce a discovered LAN lobby as an extra, synthetic event card under an existing street pin (e.g. МАККЛЕЙНLAN: <lobby name>), without touching game_cache/the OBB/native_lib at all. This reframes the goal from "override what a file contains" to "hook the C++ code that turns loaded RaceEvents into UI, and inject one extra fake entry into its output."

Confirmed classes (RTTI-verified):

  • im::app::flow::nfs::MapScreen — the world-map screen controller. Its main setup function is sub_1781BC (0x1781bc, ~8.7KB, by far the largest function found in this project so far) — too large to fully decompile through the MCP tool in one call (response gets truncated); explored via targeted disassembly windows instead.
  • im::app::ui::MapTrack — one visual pin/marker widget on the map (one instance per street shown, e.g. "МАККЛЕЙН", "ПЕТЕРСОН СТРИТ"). Has a TrackId/TrackName-keyed property read from the layout (confirmed via the literal error string "Couldn't find TrackId property on MapTrack widget " and a live "TrackName" property-name string used inside sub_1781BC).
  • im::app::ui::MapTrackEventList — the list-of-event-cards widget. Looked up by a fixed, singular widget name: "event_list" (not one list per street — confirmed via sub_17A4CC, a generic FindOrCreateLayoutEntity<T>(scene, name) helper reused for several named widgets, called with the literal string "event_list" at 0x178308-0x178318). A sibling "map_scroll" widget is looked up the same way immediately after. This means the event list is one shared widget that gets repopulated each time the player selects a different street pin, not N separate always-populated lists — matches the observed UI flow (map → tap pin → event cards appear).
  • sub_1781BC contains exactly 2 dynamic_cast<Node → MapTrack> calls (0x179034, 0x179af4), each inside a loop over the scene's child nodes filtering for MapTrack instances — i.e. two separate passes over "every pin on the map" (likely: one pass to wire up click handlers via the boost::bind-based MapScreen::OnTrackClicked(shared_ptr<MapTrack> const&) binding also found in this function's .data.rel.ro references, and a second pass for something else not yet identified - badge/lock-state refresh is a plausible guess, not confirmed).

Not yet found: the specific call, somewhere in the ~7KB of sub_1781BC not yet inspected, that iterates loaded RaceEvents, matches each one's TrackName field against a MapTrack pin's TrackId, and adds a corresponding child card into the "event_list" widget. This is the actual hook point needed - either to call the same function ourselves with a synthetic/fake RaceEvent-shaped argument (reusing the engine's own card-construction logic, much lower-risk than hand-building a UI node), or to hook whatever lower-level "add child to MapTrackEventList" method it ultimately calls.

Why static disassembly stalled here: sub_1781BC is too large to decompile through the MCP tool in one shot (Hex-Rays output gets cut off around 62K characters before reaching the interesting part), and windowed raw-disassembly reading (as used successfully for smaller functions all session) doesn't scale well to a function this size - each 50-instruction window only covers a tiny fraction, and there's no shortcut like a distinctive string to search_text for near the exact call (tried "TrackName"/"TrackId"/RaceEvent/dynamic_cast - all found something relevant but not the precise add-card call yet).

Recommended next step (not yet attempted at the time this section was first written): given the static-analysis approach is hitting diminishing returns on this specific function, live debugging on-device would likely be faster here. See §6t for the actual attempt.

6t. Live debugging: attach/registers/disassembly work via a cross-arch lldb-server, but breakpoints do not — every "negative result" below this point is unverified, not evidence

What's genuinely confirmed: the ARM32 lldb-server failures from earlier the same day (both lldb and Frida blocked, see PROGRESS.md) traced to a real bug in this NDK's ARM32 lldb-server build - a control experiment (attaching it to a harmless system process, systemui) reproduced the identical GetMaxU64 invalid byte_size! assertion and unresolvable-PC symptom seen against the game, proving it's generic to that binary, not anti-debug in the game. The AArch64 lldb-server (same NDK) attached cleanly to that same process, and - since Linux/Android's ptrace lets a 64-bit tracer debug a 32-bit compat-mode tracee - also attaches cleanly to the 32-bit game process itself: correct PC, correct ARM32 disassembly, correct thread names, no assertion. This part is solid and reusable: for passive inspection (attach, read registers/memory, disassemble at whatever point the process happens to be stopped), use the AArch64 lldb-server against this device regardless of the target's own bitness.

What is NOT confirmed, and was wrongly reported as confirmed in an earlier version of this section: that setup was used to breakpoint several functions (sub_1781BC's post-widget-lookup point, sub_17A4CC, sub_7D2E8, sub_208C88, sub_7CE58) across multiple live sessions, tap a street pin each time, and observe zero hits - which was written up as a real negative result ("the click handler doesn't call X"). This was premature. Prompted by the user directly questioning the evidence, a sanity check was run: breakpointing j_malloc_0 (a function guaranteed to be called continuously - dozens of times per second at minimum) through the exact same setup. It also never fired, waiting 15 full seconds during active gameplay. A hardware breakpoint was tried as a fallback and failed outright with an explicit error: failed to set breakpoint site ... hardware breakpoint resources might be exhausted or unavailable. Conclusion: this cross-arch configuration cannot actually insert working breakpoints (software or hardware) at all - lldb prints a success-looking Breakpoint N: address = 0x... message regardless, which is only confirming the address resolves, not that a trap was successfully placed and will fire. The passive capabilities (attach, register read, disassembly at an already-stopped point) are real; active tracing (breakpoints, and by extension anything depending on them) is not currently usable with this tool/device combination.

Consequence: every "X was never called" claim from the breakpoint experiments this session (previously written up as §6t/§6u findings about sub_1781BC, sub_17A4CC, and sub_7D2E8's callers being load-time-only) is retracted as unverified - not necessarily wrong, just not actually tested. The RaceEvent-loaded-once-at-startup theory and the "always exactly 3 card slots" UI observation both still stand on their own (the former from static call-site counting in §6s, the latter from direct visual observation of screenshots), but the live-debugging support claimed for them does not hold.

Follow-up: tried a genuine same-architecture gdb/gdbserver pair, ruled that out too. Installed gdb-multiarch on the host (extracted from the .deb without root, via apt-get download + dpkg-deb -x - no sudo available in this environment). It could not talk to the AArch64 lldb-server at all (Invalid hex digit 59 parsing register replies - a genuine wire-protocol incompatibility between LLDB's and GDB's remote-serial-protocol dialects, independent of the cross-arch issue above). Obtained a real, native ARM32 gdbserver (extracted only the one binary from the legacy android-ndk-r16b zip - Google's NDK dropped gdbserver after r17 - via a full 852MB download, since partial/range-request extraction against dl.google.com failed with SSL/range errors in this environment). Native-architecture attach worked meaningfully better than either lldb path: interrupt correctly stopped the process and produced a real, correct backtrace (syscall() ← libart.so, matching genuine ARM32 register/stack state) - clearly better than anything the cross-arch lldb-server could do. A software breakpoint was accepted (Breakpoint 1 at 0xc389bbfc, on j_malloc_0), but issuing continue crashed gdbserver itself (the game process survived unharmed; gdbserver's process simply disappeared, and the GDB client reported Remote connection closed) - no breakpoint hit was ever observed. Most likely cause: a ~7-year version skew between this 2017-era gdbserver (bundled with a GDB 7.x/8.x-generation NDK) and the 2024 gdb-multiarch 15.1 client - qSupported handshake warnings (unrecognized item "timeout", Ignoring packet error) were visible in earlier connection attempts, confirming real protocol drift between the two ends even where the connection nominally succeeded.

Overall conclusion across all three attempts (cross-arch lldb-server, cross-arch lldb-server vs. gdb-multiarch, native gdbserver vs. gdb-multiarch): passive inspection (attach, interrupt, read registers/memory/backtrace at whatever point the process happens to be stopped) is achievable and was proven working more than once; actively resuming execution with a breakpoint installed has not been achieved by any combination tried in this environment.

Root cause identified (user research, not further tooling changes needed to explain it): the Fairphone 5's SoC has no native AArch32 (32-bit ARM) hardware support at all — like an increasing number of recent Qualcomm chips, it runs 32-bit code (this project's armeabi-v7a-only libapp.so included) through a software translation/compat layer, conceptually similar to Intel's Houdini layer on x86 Chromebooks, rather than real 32-bit silicon. This retroactively explains every symptom hit this session in one stroke: there are no genuine AArch32 hardware debug registers underneath for a hardware breakpoint to program (matches the explicit "hardware breakpoint resources might be exhausted or unavailable" error); a software breakpoint's PTRACE_POKETEXT patches bytes in what may not be the actual code stream the translator is executing, so it can install without error yet never trigger; and register-context edge cases (the ARM32 lldb-server's VFP/NEON assertion) are unsurprising against a translated/emulated register file. Passive operations (attach, interrupt, reading whatever the OS-level compat layer reports as current register/stack state) still worked because those go through the kernel's own ptrace compat translation, which is solid — only the "patch code and expect it to run" class of operation is affected.

Practical implication: this isn't a tooling bug to keep working around on this device - live breakpoint-based debugging needs a device whose SoC still has genuine AArch32 hardware. reference-pixel6a-grapheneos-testing (already available, already proven to run this project's armeabi-v7a libs with "no Houdini/binary-translation layer" per its own memory entry) is the most immediately-available candidate to retry this on. The user also plans to bring a Pixel 5a or a 2018 Galaxy A9 specifically for this. Until confirmed working on genuine 32-bit-capable hardware, treat this project's debugging capability as read-only (attach-and-inspect-current-state only) and rely on static IDA analysis for anything requiring "does function X get called when Y happens."

6u. Static analysis (post-debugging-pivot): decoded the actual click hit-test and per-slot lock refresh inside sub_1781BC — real evidence, no debugger needed

Per the user's direction after §6t ("continue with statics for now"), went back into sub_1781BC with func_profile/disasm windows instead of full decompile (which truncates around 62K characters for this function). Note: the plain callees tool returned an empty list for this function for unclear reasons (possibly a size-related edge case) — func_profile with include_lists: true worked correctly and returned all 46 real callees, so prefer that tool for large functions going forward.

§6s's "exactly 2" call-site count stands, re-confirmed: xrefs_to on the MapTrack RTTI typeinfo address (0xac6434) surfaced a third reference at ~0x17a02c inside sub_1781BC, but checking it directly showed it's just a reused literal-pool constant (ARM32 LDR =literal pool word), not a distinct dynamic_cast call — that region (0x179f54-0x17a118) is unrelated first-run onboarding logic (checks "have I shown this before" flags and pushes "TUTORIAL_MAP"/"CONGRATULATIONS_POPUP" messages onto a queue at MapScreen+0x230). The two real call sites remain ~0x179034 and ~0x179af4 (analyzed below).

Region A (0x178afc-0x178e40): a straight-line "populate selected-event summary" block, not a per-card loop. Reads five named properties — TrackName, Completion, EventName, class_restriction, event_type — via the generic property-getter sub_15F2DC (four of them) and sub_15E934 (for event_type), each call reading from a single object at [MapScreen+0x120] and caching the {ptr,len}-style result pair into fixed MapScreen fields (0x1F8/0x1FC, 0x200/0x204, 0x208/0x20C, 0x210/0x214, 0x218/0x21C respectively), with the old cached value released (refcounted Release-style vtable call) before each overwrite. This reads from one already-resolved object, once, straight-line — it's a details/header panel populated from "whatever is currently selected," not a loop building N cards.

Region B (0x179a2c-0x179cfc): the actual touch/click hit-test. Confirms (now via static evidence, not the retracted live-debugging claims from §6t) that clicking a street pin does not reload or re-extract any RaceEvent — it's a pure nearest-neighbor search over already-resident objects:

  • Gets the current touch/click point from a global singleton (sub_890EC()+0x9C/0xA0), stored into MapScreen+0x13C/0x140.
  • Iterates MapScreen+0x1C8's child array (begin=+0x5C, end=+0x60 — this is the map_scroll container's children), dynamic_cast<MapTrack> on each (typeinfo _ZTIN2im3app2ui8MapTrackE at 0xac6434, confirmed to only be referenced from 3 places project-wide, see below).
  • For each successfully-cast MapTrack*, gets its position via sub_369EEC and computes a blended squared-distance to the touch point (0.5 mix factor against the pin's extents, via VFP), tracking the minimum seen so far.
  • For the current closest match, copies two fields directly off the MapTrack object itself — offsets +0xB8 and +0xBC — into MapScreen+0x1C0/+0x1C4. There is no VFS call, no prefab load call, no sub_7D2E8/sub_208C88 call anywhere in this path. This is decisive, statically-verified confirmation that each MapTrack pin already carries a pointer (almost certainly to its associated RaceEvent-derived data, or a thin wrapper around it) set once when the street/map loads, and a click is nothing more than "find nearest pin, copy its pre-existing pointer into the selection slot."

Region C (0x179d30-0x179dfc): per-card-slot "locked" refresh — real evidence for the "fixed slot pool" theory. Walks a linked list rooted at MapScreen+0x18C (sentinel = a stack local holding the list's end()); for each list node (one per card slot), if not already resolved (node+0x10 == 0), reads the "locked" property via sub_406644 on the slot's associated object (node+8), then calls a vtable setter (vtable+0x5C) on that object with the locked value, followed by a refresh/invalidate call (sub_4D364C) on a sub-object at node+8 → +0xC0. This is a fixed-size (or at least pre-existing, non-dynamically-added) collection of slot entities whose lock state gets pushed per-refresh — consistent with, and now backed by real disassembly for, the "always exactly 3 card slots" UI observation from earlier sessions (still not confirmed as exactly 3 by count, but the mechanism — toggle pre-existing slots' locked flag rather than add/remove children — is now confirmed).

Searched the whole binary for other MapTrack-related functions: only 3 functions anywhere reference the MapTrack RTTI typeinfo (0xac6434) — sub_1781BC (this one), sub_17C120, and sub_17FC6C. Both of the other two are now fully decompiled and characterized:

  • sub_17FC6C (~1.2KB) — a "scroll/pan the map camera to a named track" helper: resolves a target track name from a small candidate list, finds the matching MapTrack child, computes a tween/scroll command into MapScreen+308..+376. Almost certainly "auto-scroll to next unlocked event."
  • sub_17C120 (~4.4KB) — MapScreen's per-frame update/tick handler, not a one-shot setup function: dispatches touch-down events by dynamic_cast-testing the tapped node against UIButton (e.g. the "BLACKMARKET" button) then MapTrack, caching the hit into MapScreen+0x1A0/0x1A4; drives an "unlock reveal" animation state machine (MapScreen+0x134 state values incl. 3/1000) that reads an "unlocking" property and calls a setter (vtable+92) on the newly-unlocked pin's icon object when a "NEXT_EVENT"-flagged pending-unlock list becomes empty; clamps camera scroll bounds; and periodically (every 4s of playtime) fires a QA-only "Soak Test" feature that auto-picks a random "RACE" track. None of this writes MapTrack+0xB8/+0xBC either — it only reads/reacts to already-attached pin state.

Still not found: the function that writes MapTrack+0xB8/+0xBC in the first place — i.e., where a RaceEvent's data actually gets attached to a pin when a street/map scene loads. All 3 functions that dynamic_cast to MapTrack in the whole binary are now accounted for and none of them do this write, so the real population site must construct/populate MapTrack instances without ever needing to dynamic_cast to their own type (e.g. it already has a statically-typed MapTrack* from a factory/constructor call, no RTTI check needed) — the RTTI-xref search approach is exhausted. This is the real target for the user's "inject a synthetic LAN lobby event under an existing street" goal — either construct a fake pointer at that offset from a synthetic RaceEvent-shaped struct and write it directly into an already-loaded (but currently locked) MapTrack's +0xB8 field at runtime, or find and re-enter the same population function with synthetic data so the engine's own logic builds it correctly (much lower-risk, matches the general approach already favored in §6s). Next static-analysis approach should pivot away from RTTI-xref searching (dead end, confirmed) toward finding MapTrack's constructor directly — e.g. via its vtable symbol (should sit near the RTTI typeinfo in .data.rel.ro, same pattern used successfully for RaceLoaderTask in §6a) and following xrefs to that.

6v. FOUND IT: MapTrack::AddEvent — the exact function that attaches a RaceEvent to a street pin, and the concrete injection point for the user's "LAN lobby" goal

Continuation of §6u, abandoning the RTTI-xref dead end in favor of the proven §6a technique: locate a class's real vtable via entity_query on names near its RTTI typeinfo address, then follow xrefs to that instead of to the typeinfo. This worked immediately.

MapTrack's real vtable: _ZTVN2im3app2ui8MapTrackE at 0xaabfdc (found in the same .data.rel.ro neighborhood as the typeinfo, via entity_query). Its usable function-pointer table starts at off_AABFE4 (standard Itanium ABI: vtable symbol points at the offset-to-top slot; the RTTI pointer and actual vtable-proper follow).

MapTrack's real constructor: sub_368860 (found via xrefs_to on the vtable symbol). Sets the vtable pointer, then memset(this+0xB8, 0, 0x23) — confirms +0xB8 (and everything through +0xDA) starts zeroed, i.e. every MapTrack pin is created with no event data attached; it must be populated by a separate step. (sub_3688D4, the other vtable-referencing function, is the destructor — releases held smart-pointer members then chains to the base class dtor.)

MapTrack's factory: sub_395B38 (the sole caller of the constructor) — a make_shared<MapTrack>-style allocator (raw object + a separate refcounted control block). Its sole caller is sub_38DA44, a ~27KB function that turned out to be a dead end: it's a generic class-factory registration bootstrap (hundreds of {"ClassName" string, ctor function pointer} pairs registered into a lookup table for the data-driven layout/prefab deserializer — the same string-keyed pattern seen for "MostWantedLeaderboard" and dozens of other unrelated classes). It doesn't populate anything itself; it just tells the generic layout loader how to construct a MapTrack when one is named in a .sb/layout resource.

The real chain, found via the "TrackId" error string ("Couldn't find TrackId property on MapTrack widget ", only ever emitted from one function):

  1. sub_369040 = MapTrack::RefreshEvents() (or equivalent). Reads its own TrackId layout property (via sub_3684B4); for a small internal collection of name/zone strings (this+0x224..0x228), looks each one up in a global registry (sub_242778) to get a collection of matching RaceEvent handles, and calls sub_369AB0(this, &eventHandle) for every match. It then also drives all of the pin's visual state — "locked"/"available"/"new"/"blacklist" property setters, a "blacklist_icon" visibility toggle, and (critically) a completion-percentage badge: (this+0x276) / (this+0x272) * 100, formatted and pushed via sub_15F2DC(this+0x184, "completion") — the same generic property-setter used for EventName/TrackName etc. in sub_1781BC (§6u Region A).
  2. sub_369AB0 = MapTrack::AddEvent(RaceEventHandle*) — has exactly one caller (sub_369040's loop), confirming it's a dedicated, single-purpose method. For the given event handle it: push_backs it into a growable vector living directly on the MapTrack object (this+0x240 begin / +0x244 end / +0x248 capacity — a real std::vector-style growth pattern, confirmed via the sub_3DE038-realloc-then-memmove sequence when full) — so a street pin can and does hold multiple events, this is not a single-event field; increments this+0x272 by 3 (3 medals — Gold/Silver/Bronze — per event) and this+0x276 by however many medals the player has actually earned for it (via sub_4F0C1C/sub_4F9A80, reading a "medal" field off some save/progress lookup) — feeding directly into RefreshEvents's completion-percentage badge; and updates this+0x216/0x217/0x218/0x220 lock/availability/"new" flags based on the same save-data lookup.
  3. sub_368DFC = MapTrack::HandleEvent(eventTypeId, im::Event*)MapTrack's override of the engine's generic message-handler virtual method. On event type 1025, dynamic_casts the event to im::app::events::FlowSetLayoutScreenEvent ("the screen this pin lives in just finished its layout pass") and, if it matches, calls RefreshEvents() on itself. On event type 1048 (UIButtonClickEvent) matching its own embedded button component, it invokes a stored boost::function callback (this+0x256) — a second, independent click-handling path from the raw touch-coordinate hit-test found in sub_1781BC §6u Region B (this one is UI-focus/button-event-driven, not distance-based).

This settles the multi-session architecture question: population is bottom-up and per-pin, not top-down. There is no single "MapScreen iterates all RaceEvents and assigns them to pins" function to find, because that isn't how it works — each MapTrack pin, upon receiving its own FlowSetLayoutScreenEvent, independently looks up and self-registers whichever RaceEvents match its own TrackId from a shared registry.

Concrete implementation path for the user's actual goal (inject a synthetic "LAN: <lobby name>" entry into an already-loaded street's card list, purely at runtime, no file/OBB/game_cache changes): hook MapTrack::AddEvent (sub_369AB0, 0x369AB0) and, after the real RefreshEvents loop finishes populating a chosen pin (or by directly calling AddEvent again on an already-loaded MapTrack* at any later time, e.g. when a LAN lobby is discovered), call it a second time with a synthetic event handle. Because AddEvent already does all the real bookkeeping — vector growth, completion-percentage accounting, lock/availability flags — this reuses the exact same engine machinery real events go through, matching the general "call the engine's own function with fake data" strategy favored since §6s (far lower-risk than hand-building a UI node or a fake MapTrackEventList child directly).

Not yet determined: the exact shape/type of the RaceEventHandle argument AddEvent expects (a2 in the decompile) — it's passed by pointer and resolved through what looks like this engine's entity-component-system indirection (sub_7566C/sub_173350-style "resolve a component from a handle" calls, matching the "Dereferencing a NULL component pointer" ECS error strings seen elsewhere in the binary, §6u), not a raw RaceEvent*. Determining the exact handle format (likely a small ID/generation-pair struct rather than a pointer) is the next concrete step before this can actually be called with synthetic data.

6w. AddEvent's handle format decoded: a 32-bit FNV-1a hash keying a global resource cache — AddEvent doesn't need the TrackId registry at all

Continuation of §6v. Decompiled the two functions in the resolution chain to pin down exactly what MapTrack::AddEvent's RaceEventHandle argument actually is.

sub_173350 (called from AddEvent/sub_369AB0 as sub_173350(&out, context+320, &handle)) is a generic "resolve an ID into a cached, refcounted resource" lookup against an intrusive hash map: buckets at context+4/count at context+8 (relative to the context pointer it's given), bucket index computed via sub_97D23C(hashKey, bucketCount), each node laid out as [0]=key(int), ..., [3]=+12, [4]=+16, [5]=+20 refcounted-ptr, [6]=+24 next-in-chain. On a hit it returns the {+12, +16, +20} triple (incrementing the refcount at +20+8) — this is the same generic "prefab instance cache keyed by ID, refcounted" system already known from sub_7CE58 (§6s: "Failed to load prefab %s... already present in this database, ignoring"). On a miss it builds and logs "Database of <name> prefabs has no entry with ID <N>" — confirming the key really is just an integer ID, not a pointer or struct.

sub_242778 (called from RefreshEvents/sub_369040, not from AddEvent itself) is a different, string-keyed hash map: FNV-1a-32 hashes (offset basis -2128831035 / prime 16777619 — the literal, unmistakable FNV-1a constants) the TrackId-derived string passed in, looks it up in a hash map rooted in some outer registry context (a1+68/a1+72), and on a hit returns a pointer to a {begin,end} pair at foundNode+16 — exactly the int* array RefreshEvents iterates to get the list of matching hash IDs for that track. On a miss, returns a pointer to a static empty triple (&dword_AD4710) — i.e., an empty, safely-iterable result rather than null.

So the full resolution is two independent hash maps chained together: TrackId string --[sub_242778, FNV-1a keyed]--> vector<uint32 hash> (one entry per matching RaceEvent), then each hash --[sub_173350, int keyed]--> refcounted RaceEvent-prefab-instance pointer. Critically, AddEvent (sub_369AB0) only ever touches the second map (sub_173350 directly) — it takes an already-resolved hash and looks it up in the prefab-instance cache; it never calls sub_242778 or touches the TrackId registry itself. That lookup is entirely RefreshEvents's job, upstream of AddEvent.

This meaningfully simplifies the injection plan from §6v: to add a synthetic "LAN: <lobby>" card to an already-loaded street pin, there is no need to touch the TrackId→events registry at all. It's enough to:

  1. Fabricate one entry in the prefab-instance cache that sub_173350 queries — pick an unused 32-bit ID, construct a fake RaceEvent-shaped object (using the already-known field layout from §6p: TrackName, EventName = "LAN: <lobby name>", Completion, class_restriction, event_type, CashReward Gold/Silver/Bronze, etc.), wrap it in the expected {+12, +16, +20-refcounted-ptr} node shape, and insert it into that hash map's bucket chain under the chosen ID.
  2. Call MapTrack::AddEvent(existingPinPtr, &chosenId) (0x369AB0) directly on an already-loaded, real MapTrack* (obtainable from the same hit-test/selection machinery already mapped in §6u).

AddEvent then does everything else itself using real engine code: grows the pin's own event vector, updates the completion-percentage badge, and sets lock/availability flags — exactly the "reuse the engine's own logic with fake data" strategy favored since §6s, and now with a concrete, minimal (single hash-map entry) fabrication requirement instead of needing to replicate the whole TrackId registry or a VFS/prefab load.

Not yet determined: the exact in-memory shape of a loaded RaceEvent prefab instance (i.e., what the refcounted pointer at cache-node +20 actually points to — presumably the same object sub_7D2E8 extracts a RaceEvent component from, per much earlier sessions) precisely enough to hand-construct a convincing fake one; and the exact hash-map node/bucket-array construction details (allocation sizes, sub_97D23C's bucket-index formula) needed to splice a new node in safely at runtime without corrupting the real map.

6x. The cache's insert side (sub_7CE58) confirms the value triple's meaning — and reveals a much cheaper injection strategy: relabel a real RaceEvent, don't fabricate one

Continuation of §6w. Decompiled sub_7CE58 — already known from §6s ("Failed to load prefab %s... already present in this database, ignoring") to be the generic prefab-loading/caching function — and confirmed it's the write side of the exact same hash map sub_173350 reads (same bucket-array-at-+4/count-at-+8 layout, same sub_97D23C bucket-index call, same "already present, ignoring" log path).

What it does: given a prefab resource path string, hashes it (via sub_67223C — a different hash function than the FNV-1a used for the TrackId registry in §6w, so the two maps use different hashing even though both are keyed by strings-derived-to-ints at the storage layer), checks the cache, and on a miss actually loads the prefab (sub_671330/sub_6714E0) and extracts its RaceEvent component via sub_7D2E8 — the same function identified across many earlier sessions as "extracts the RaceEvent component from a loaded prefab via dynamic_cast." The two outputs of sub_7D2E8 are then written into a newly-inserted cache node's +12/+16 fields, and the loaded prefab's own refcounted instance pointer goes into +20.

This confirms precisely what AddEvent receives once it resolves a handle: the +12 field is the actual RaceEvent component pointer (the same kind of pointer sub_7D2E8 has always produced, that downstream code like sub_208C88 reads m_StartLine/m_FinishLine/etc. from at fixed offsets, per much earlier sessions), +16 is an accompanying tag/generation word, and +20 is the refcounted owning Actor/prefab-instance pointer.

This changes the practical injection recommendation: rather than hand-fabricating a fake RaceEvent-shaped C++ object from scratch (risky — would need its exact vtable, RTTI, and full field layout, only partially known from §6p), a much cheaper and lower-risk approach is to reuse an already loaded, real, well-formed RaceEvent object: pick any currently-loaded event's component pointer (a guaranteed-valid, correctly-vtabled object), insert a new cache node under a fresh unused hash ID whose +12/+16/+20 fields simply copy that real object's identity (bumping its refcount, mirroring the same refcount-increment pattern sub_7CE58/sub_173350 already do), then use the existing property-setter machinery (sub_15F2DC/sub_406644, already reverse-engineered in §6u Region A and sub_369040) to overwrite just its display fields — EventName"LAN: <lobby name>", TrackName, class_restriction, event_type, etc. — in place on that shared object, or on a shallow copy of it if mutating the original is undesirable. This "clone an existing valid object and relabel it" strategy avoids ever needing to construct a RaceEvent instance's vtable/RTTI/full binary layout by hand, at the cost of needing a real donor event to already be loaded somewhere (which is true for every street with at least one authored event, per every street observed so far in this project).

Two viable concrete strategies now on the table (not yet chosen/implemented):

  1. Clone-and-relabel (recommended, lower risk): reuse a real, already-loaded RaceEvent* as the cache entry's payload; overwrite only its display properties via the engine's own property setters before/after calling AddEvent.
  2. Fabricate-from-scratch (higher risk, more complete control): hand-construct a new RaceEvent-shaped object with a real vtable pointer and correctly-laid-out fields, matching the full memory layout (not just the SB-file field layout already known from §6p) — would need further work to pin down RaceEvent's actual C++ class layout (vtable location, exact field offsets in the live object, not just the on-disk SB representation).

6y. RaceEvent's real vtable, constructor, and a byte-precise field map — the concrete basis for fabricating one from scratch (per user's choice of "Вариант Б")

Continuation of §6x. The user chose the fabricate-from-scratch strategy, so the next task was pinning down RaceEvent's actual live C++ object layout (not just the on-disk SB field names already known from §6p).

Vtable/RTTI: found via the same entity_query-on-names technique as §6v — _ZTVN2im3app4race9RaceEventE at 0xaa78a8 (real vtable symbol), _ZTIN2im3app4race9RaceEventE typeinfo immediately after at 0xaa78d8. xrefs_to on the vtable surfaced exactly 3 functions: sub_2A4B58 (constructor), sub_2A4C70 (presumably destructor, not yet decompiled), and sub_2A7BF4 (not yet decompiled, 476 bytes — likely another virtual method or a clone/copy function).

Constructor (sub_2A4B58): malloc(0xE4)the live object is exactly 228 bytes. Calls a base-class constructor (sub_670454 — sets a temporary base vtable off_ABB274, a 4-byte field at +4 to 0, and a 2-byte field at +8 to 256/0x100; this looks like a generic ECS Component base: owner/actor pointer + a type-tag default), then overwrites the vtable pointer with the real one (off_AA78B0 — the usable, RTTI-header-skipped vtable, same Itanium-ABI convention as MapTrack in §6v), then zero/default-initializes every field through offset +220.

Field map (cross-referenced against sub_2A4D70, the RaceEvent field deserializer from §6w/§6x, now fully decompiled — every write in it targets a1+<offset> with an explicit property-name string literal right next to it, giving a byte-precise, high-confidence mapping):

Offset Field (property name) Type Default
+0 vtable ptr ptr off_AA78B0
+4 (base class: owner/actor?) int 0
+8 (base class: type tag) word 256
+12 (unnamed — not written by sub_2A4D70; set elsewhere) string (12B: begin/end/cap) empty
+24 RaceType string (12B) empty
+40 RaceFSMPrefabOverride string (12B) empty
+56 CarRestriction string (12B) empty
+72 TrackName string (12B) empty
+88 EventName (via an indirect "Name" lookup on a sub-list, then interned via sub_406644) interned string ptr (4B) empty
+92 Location (same indirect-lookup + intern pattern) interned string ptr (4B) empty
+96 Zonecomputed, not a raw property: "ZONE_" + uppercase(Location), then interned interned string ptr (4B) empty
+100 EnvironmentPrefab string (12B) empty
+116 TrafficCarCount (indirect lookup) int (4B) 0/unset
+120 OpenWorldTrack (indirect lookup) bool (1B) 0
+124 AutologID (indirect lookup) int (4B) -1
+128 BlacklistEvent (indirect lookup) bool (1B) 0
+132 ClassRestriction (indirect lookup) int/enum (4B) 0/unset
+136 PursuitType string (12B) empty
+152 StartLineNoSpawnZone float (4B) 1000.0 (0x447A0000, confirmed via the exact literal 1148846080)
+156 FinishLineNoSpawnZone float (4B) 1000.0
+160 SpawnDistance float (4B) 1000.0
+164 StartLine string (12B) empty
+180 FinishLine string (12B) empty
+196 EndOfTrack string (12B) empty
+212 CheckpointCollection string (12B) empty

StartLine/FinishLine/EndOfTrack/CheckpointCollection at +164/180/196/212 match, to the byte, the raceDef+164/180/196/212 offsets already established across much earlier sessions (§6p and before) for m_StartLine/m_FinishLine/m_EndOfTrack/checkpoint-container — strong cross-session consistency check, both derivations agree exactly.

Important correction: earlier in this session (§6v/§6x speculation) the 3 consecutive floats at +152/+156/+160 were guessed as a plausible match for CashReward Gold/Silver/Bronze. That guess is now disproven — they're confirmed to be StartLineNoSpawnZone/FinishLineNoSpawnZone/SpawnDistance (spawn-safety-radius tuning values), all defaulting to the same 1000.0. CashReward and the UI-facing properties read reflectively elsewhere (class_restriction, event_type, Completion — read via sub_15F2DC/sub_406644 in sub_1781BC §6u Region A and sub_369040 §6v) are not present anywhere in this 228-byte struct. They must live on a separate component attached to the same Actor/prefab instance (this engine's ECS allows multiple components per entity, already established via the component_weak_ptr<RaceEvent> string constants in §6y's own vtable search) or be computed at read-time rather than stored — not yet confirmed which.

Practical implication for fabrication: a synthetic RaceEvent needs, at minimum: the real vtable pointer (off_AA78B0), a plausible base-class header (owner pointer + type tag), and populated TrackName (to match the target MapTrack's own TrackId so RefreshEvents's registry lookup — if that path is used — or manual placement resolves correctly) plus EventName (interned, e.g. "LAN: <lobby name>") at minimum for a card to display meaningfully. CashReward/class_restriction/event_type/Completion — everything the actual UI card visibly shows beyond the name — are not part of this struct and remain the next concrete unknown: need to find the reflective property-descriptor table sub_15F2DC consults (likely a separate registered table mapping name strings to getter functions/offsets, not necessarily this struct at all) to know what a synthetic object must expose for those properties to resolve.

6z. Correction: sub_15F2DC is a named-widget lookup (FindOrCreateLayoutEntity<Text>), not a property read — re-interprets §6u Region A and narrows what's genuinely still missing from RaceEvent

Decompiled sub_15F2DC in full (previously only inferred as "a generic property getter" by analogy with its call pattern). It is not that — it's the exact same pattern as sub_17A4CC (§6s: FindOrCreateLayoutEntity<T>, searching a linked list of named layout entities at scene[23]/scene[24], strcmp against the requested name, falling back to "Unable to locate layout entity: " — the identical error string), just instantiated for im::scene2d_new::Text instead of a generic Node. It looks up a named child widget in a scene graph and dynamic_casts it to Text (a text-label widget), returning that widget pointer — it does not read or return a value at all.

This re-interprets §6u Region A: sub_1781BC's block at 0x178afc-0x178e40 does not read TrackName/Completion/EventName/class_restriction/event_type as properties off a RaceEvent-like object. It looks up named Text widgets — child nodes of some scene rooted at [MapScreen+0x120] — by exactly those fixed names, and caches the resulting widget pointers into MapScreen+0x1F8..+0x21C for later use (almost certainly so a separate, not-yet-located binding/formatting step can set each widget's displayed text from the real data). [MapScreen+0x120] is therefore a scene/Node pointer (the event-detail-card template instance), not a RaceEvent* — consistent with sub_15F2DC's second argument being treated as a scene object with an [23]/[24] named-entity list, exactly like sub_17A4CC's.

This narrows, rather than widens, the remaining gap from §6y: "class_restriction" and "event_type" are very likely just the names of the Text/icon widgets that get filled in from RaceEvent.ClassRestriction (+132) and RaceEvent.RaceType (+24) respectively — both of which are already in the byte-precise field map from §6y. What's still genuinely unaccounted-for in the 228-byte RaceEvent struct is narrower than previously stated: only CashReward (Gold/Silver/Bronze) and Completion (already known, per §6v, to be computed by MapTrack::AddEvent from player-progress data, not read off RaceEvent at all — so not actually missing, just not stored on this object by design). CashReward remains the one open question — plausibly read via the same indirect "sub-list" lookup pattern (sub_4F99F0/sub_4F9A80) used for EventName/Location/TrafficCarCount/AutologID/BlacklistEvent/ClassRestriction in sub_2A4D70, just for a nested "Rewards" key not yet located, or extracted by an entirely separate deserializer function for a second component on the same prefab Actor (this engine's ECS allows several components per entity, as already established).

Practical implication for the fabrication plan (§6y): the synthetic RaceEvent object's field requirements are essentially unchanged from §6y's table — TrackName, EventName, RaceType, ClassRestriction cover what the visible card UI needs (name, class icon, restriction), and Completion is handled automatically by AddEvent's own bookkeeping (§6v) rather than needing to be set on the object at all. CashReward is the only remaining unknown, and is optional in the sense that a card lacking it would very likely just show a blank/default reward rather than fail outright (not yet confirmed, but low-risk either way, given every other field the deserializer sets defaults for missing prefab data rather than erroring).

6aa. CashReward's real layout found — closes the last gap for fabricating a complete synthetic RaceEvent

Continuation of §6z. CashReward (im::app::metagame::CashReward) is confirmed to be a genuinely separate C++ class, not a RaceEvent field, part of a small class family also including im::app::metagame::Reward (base) and im::app::metagame::RewardsCollection (container) — found via the same entity_query-on-names / xrefs_to-on-vtable technique used for MapTrack (§6v) and RaceEvent (§6y).

CashReward's constructor (sub_23E2C4): malloc(0x1C)28 bytes total, the smallest object found this session. Calls Reward's base constructor (sub_25D4F0), which itself calls the same generic Component base constructor already seen for RaceEvent (sub_670454 — sets a temporary vtable, +4=owner ptr default 0, +8=type-tag word default 256) before setting its own vtable (off_AA60AC). CashReward's own constructor then overwrites the vtable a third time with its own (off_AA5B54, the real usable vtable) and sets:

Offset Field Default
+0 vtable ptr off_AA5B54
+4 (Component base: owner ptr) 0
+8 (Component base: type tag) 256
+12 unknown (not a reward amount — separate field, possibly a type/flags int) 0
+16 Bronze cash amount 10000
+20 Silver cash amount 30000
+24 Gold cash amount 50000

(Tier-to-offset mapping is inferred from ascending magnitude matching the conventional Bronze < Silver < Gold ordering — not confirmed via an explicit property-name deserializer the way RaceEvent's fields were in §6y, since no CashReward-specific field-deserializer function was located this pass; still high-confidence given the exact 3-tier shape and clean ascending defaults.)

This closes the fabrication gap identified in §6y/§6z. A synthetic event needs two objects, both now fully specified: a 228-byte RaceEvent (§6y's table: TrackName, EventName, RaceType, ClassRestriction at minimum for a meaningful card) and a 28-byte CashReward (this section) for the reward amounts shown on the card. Both constructors are simple, malloc-and-fill patterns with no complex dependencies — straightforward to replicate for a fabricated pair of objects at runtime.

Still open, not yet located: exactly how a RaceEvent and its CashReward are associated on the same prefab Actor (i.e., which field/mechanism lets code go from "I have this RaceEvent" to "here is its CashReward") — this wasn't needed to build the two objects individually, but will matter if the actual card-rendering code fetches the reward via that association rather than via a hardcoded second cache lookup. Resolved — see §6bb.

The user specifically asked to pin down this relationship, and flagged the important constraint that different races have different rewards (i.e. the link cannot be to one shared/global CashReward).

Found the accessor: sub_164540(out, actorPtr) = Actor::GetComponent<CashReward>() — confirmed via the profiling string "GetComponent" plus the exact same generic pattern used everywhere else in this engine (iterate an Actor's component array at actor[5]..actor[6], dynamic_cast each Component* to the target type, return the first match as a weak/shared handle). Crucially, its second argument (a2) is an Actor* — the same kind of object sub_7D2E8 (the long-established "extract RaceEvent from a loaded prefab" function, referenced since much earlier sessions) also operates on. This settles the relationship: RaceEvent and CashReward are not linked to each other directly at all — they are independent sibling components attached to the same Actor (the loaded .prefabs.sb instance). Getting from "this RaceEvent" to "its CashReward" means going through the owning Actor's component list, not through any field on RaceEvent itself. (A second function, sub_25D998 = Actor::GetComponents<Reward>() — plural, base-class Reward rather than CashReward specifically — confirms the same component-list mechanism generically collects all reward-type components on an Actor, in case a race ever has more than one.)

Found CashReward's actual field deserializer: sub_23E47C, in the same code region as CashReward's constructor (§6aa). It reads exactly three named properties from the shared per-prefab property table (parameter a3 — the same kind of indexed property-list object sub_2A4D70/RaceEvent's deserializer read its own indirect properties from, e.g. Location/TrafficCarCount/ClassRestriction) via the identical sub_4F99F0/sub_4F9A80/sub_50CE64 helper chain: "Bronze"+16 (default 10000), "Silver"+20 (default 30000), "Gold"+24 (default 50000) — confirming, with explicit property-name strings this time (not just inferred from default ordering as in §6aa), the exact tier-to-offset mapping.

This closes the loop architecturally: a single .prefabs.sb file's property table is evidently shared across all of that prefab's component deserializers at Actor-construction time — RaceEvent's deserializer (sub_2A4D70) and CashReward's deserializer (sub_23E47C) each independently pull their own named subset of properties from the same source. Since every race has its own separate .prefabs.sb file (confirmed since the earliest sessions of this project, e.g. event_02_timetrial.prefabs.sb), every race's Actor gets its own freshly-constructed CashReward instance with that specific race's own Bronze/Silver/Gold values baked in at load time — exactly matching the user's point that rewards differ per race, and explaining precisely how that variation is represented (per-Actor component instances, not a shared/global reward table).

Practical implication for fabrication (final piece): a synthetic race's fake Actor needs a component list containing both the fake RaceEvent* and the fake CashReward* (with whatever Bronze/Silver/Gold values are desired for the synthetic "LAN: " entry). sub_7D2E8 and sub_164540/GetComponent<CashReward>() will then resolve correctly against it exactly as they do for any real, loaded race — no need to intercept or special-case either accessor.

6cc. Live breakpoint-based debugging CONFIRMED WORKING — Samsung Galaxy A9 (2018), genuine AArch32 hardware

Per the user's own research (§6t "Root cause identified"), the Fairphone 5's failure to sustain breakpoints was attributed to its SoC lacking native AArch32 hardware. The user then obtained a Samsung Galaxy A9 (2018), SM-A920F, Snapdragon 660 (Kryo 260 = Cortex-A73/A53-based — genuinely pre-dates Qualcomm's native-32-bit-hardware removal), Android 10, rooted via Magisk, and asked for a debugger to be attached as a direct test of that hypothesis.

Setup: reused the native ARM32 gdbserver already extracted from NDK r16b in an earlier session, but this time paired it with the matching-generation GDB client rather than a modern gdb-multiarch — extracted gdb-orig (GDB 7.11) from the same NDK r16b archive's prebuilt/linux-x86_64/bin/, working around its legacy libncurses.so.5 dependency via a local LD_LIBRARY_PATH symlink to the system's libncurses.so.6 (no matching system package exists for that old ABI, and this avoided any system-wide install). This eliminates the ~7-year GDB/gdbserver protocol-version skew that was the leading suspect for the earlier continue-crashes-gdbserver failure on Fairphone 5's own gdbserver test.

Result — decisive and clean: the exact same "sanity check" that disproved breakpoints on the Fairphone 5 (a software breakpoint on malloc, expected to fire within seconds of any activity) fired correctly on the very first continue, on a background GLThread, with a correct pc and a real caller return-address backtrace frame. gdbserver then detached cleanly ("Detaching from process ...", not a crash), and the game process remained alive and undisturbed afterward. This is a genuine, reproducible, working breakpoint — the first of this entire project.

Two practical gotchas hit and solved along the way (now also recorded in [[reference-native-arm32-debugging-requirement]]):

  • gdbserver --attach pauses the process immediately via ptrace; attaching mid-loading-screen freezes the app there (looked like a hang until understood — detaching let it resume immediately).
  • This device (Android 10) mmaps native libraries directly from inside the APK's zip (uncompressed, page-aligned storage) rather than extracting a standalone libapp.so — so /proc/PID/maps never shows a libapp.so-named mapping to grep for, and the runtime load base has to be computed manually: get libapp.so's data offset within base.apk via Python's zipfile module, match that offset against a /proc/PID/maps line, and that line's start address is the load base. Verified byte-for-byte against the reference libapp.so's own ELF header before trusting it. This load base changes on every process (re)launch (ASLR) and must be recomputed each time.

Practical implication: this project's live-debugging capability is no longer read-only. The Galaxy A9 is now the reference device for any future "does function X actually get called" investigation — a question that blocked several static-analysis threads earlier this session (e.g. confirming exactly where a MapTrack's pin gets its RaceEvent handles attached, §6v-§6y, was done entirely via static analysis precisely because live debugging wasn't available at the time).

6dd. Live-verified, end-to-end: MapTrack::HandleEvent → RefreshEvents → AddEvent — the full §6v-§6w chain confirmed exactly as reverse-engineered, plus base-address-resolution sanity check

Continuation of §6cc, now that live breakpoint debugging works on the Galaxy A9. Two things were checked, both live, on-device.

Base-address resolution — no divergence found, single algorithm is sufficient. The user's concern: since this device (Android 10) mmaps native libraries directly from inside the APK zip rather than extracting a standalone libapp.so (§6cc), does the existing get_libapp_base() (dl_iterate_phdr-based, in launcher/mpcore/src/main/cpp/main.cpp) compute the same address as manual inspection would? Since the installed APK on this device already bundles libmpcore.so (not stripped, full debug info), this was tested directly rather than synthetically: located libmpcore.so's own runtime load address (same zip-offset technique as for libapp.so, verified byte-for-byte against its ELF header), then read the live value of its libapp_base global variable (found via readelf -sW, offset 0x3b6c) straight out of process memory. Result: 0xb8798000, exactly matching the value independently computed by hand for the same process. dl_iterate_phdr already correctly abstracts over the "loaded from an extracted file" vs. "loaded directly from within an APK zip" distinction — this is precisely what it's designed to do, and Android's own linker keeps dlpi_addr correct either way. No fallback/dual-algorithm logic is needed; the existing single implementation in main.cpp is correct as-is on this device.

Live chain verification. Computed runtime addresses for MapTrack::HandleEvent (sub_368DFC), RefreshEvents (sub_369040), and AddEvent (sub_369AB0) using the confirmed load bias, attached gdbserver to a freshly relaunched game process early enough to catch the map screen's first-ever layout pass (revisiting the map screen after a Garage trip, tried first, turned out not to refire the event — MapScreen evidently persists underneath Garage rather than being torn down and recreated, so a fresh app launch was needed instead), and set:

  • break *HandleEvent if *(int*)($r1+4) == 1025 (only stop on the real FlowSetLayoutScreenEvent dispatch, filtering out the very frequent, unrelated event type 31 noise already seen and characterized in this pass)
  • plain breakpoints on RefreshEvents and AddEvent

Result — the entire chain fired exactly as predicted, for real, back to back, for multiple pins in sequence:

  1. HandleEvent hit with evtype==1025 confirmed live (first time this specific condition was ever directly observed, not just inferred from static analysis).
  2. Immediately followed by RefreshEvents hit with the identical this pointer — confirming HandleEvent's sub_369040(a1) call live.
  3. Immediately followed by multiple consecutive AddEvent hits, all with that same this — 6 calls for the first pin observed, 4 for the second, 3 for the third (a fourth pin's HandleEvent was still starting when the test's stop budget ran out) — confirming RefreshEvents's per-match loop calling AddEvent once per resolved RaceEvent hash, live, and giving the first-ever real observed count of how many events a single street pin can carry (previously only "each street pin has its own event vector, supports more than one" was established structurally, §6v; now concretely: real streets in this save have pins with 3-6 events attached, not just 1).

This closes the loop on the multi-session MapTrack/AddEvent investigation with actual runtime evidence, not just static inference — every claim in §6v-§6y about this call chain is now independently confirmed.

6ee. Implementation: the injection hook works end-to-end (no crash) — two real Actor-layout bugs found and fixed live on-device

Implemented the plan from /home/megboyzz/.claude/plans/dreamy-giggling-hearth.md on a new branch lan-event-injection-poc (based on track-hook-toggle-flag, not bare master — that branch is missing the working hook-trampoline infrastructure master was assumed to have; master's main.cpp turned out to be a much earlier, scratch-code-laden state that was never actually merged forward). New file: launcher/mpcore/src/main/cpp/lan_event_injection.h, wired into main.cpp behind kEnableLanEventInjectionHook.

Iterative on-device debugging found two real bugs in the "fabricate a fake Actor" approach from §6y, both only surfaced by actually running it (exactly the residual risk the plan called out in advance):

  1. Actor+4 must be non-null. AddEvent (sub_369AB0) checks *(actorPtr+4) and logs "Dereferencing a component pointer whose actor has been deleted." (this string is Android's tombstone "Abort message" field even though the actual signal is SIGSEGV, not SIGABRT — a genuine tombstone quirk, not indicative of an abort() call) when it's zero. Fixed by giving the fake Actor a self-pointer at that offset.
  2. Actor+8 must start at exactly 1, not 0. This is a refcount: sub_173350 (the handle resolver) increments it by 1 on every successful resolve (a temporary borrowed reference), and AddEvent releases that same reference before returning — if the release brings it to exactly 0, it invokes a virtual "release" method through the object's own vtable ((*(int*)(*(int*)actorPtr + 12))(actorPtr)). With refcount starting at 0, this net-negative pattern hit 0 and crashed on the null vtable (fault addr 0xc = NULL+12, confirmed via live objdump disassembly of the exact crash offset). Fixed by starting the refcount at 1, so the borrow-then-release cycle nets back to 1 and never triggers that call — safe for any future number of resolves, since each is always a matched borrow+release pair.

A third, unrelated bug was found and fixed in the diagnostic/observation logging code (not the core injection path): reading a MapTrack's event vector (this+0x240/0x244) from within the hook occasionally caught a garbage begin pointer (0x100) paired with a small, plausible-looking element count — most likely a torn read racing a concurrent update from a different thread (the crash always occurred on a GLThread, and AddEvent's own push_back isn't atomic: realloc + memmove + three separate pointer writes). Fixed with a defensive plausibility check (the pointer must look like a real heap address, and the count must be small) before trusting it enough to dereference.

Result after both fixes: injection completes cleanly for all ~12 MapTrack pins this hook currently reaches, with zero crashes across repeated fresh-launch tests — a real, meaningful validation that the whole RaceEvent/CashReward/fake-Actor/hash-insert/AddEvent chain from §6v-§6bb works as designed against the live game.

Not yet achieved: visual confirmation of the synthetic card actually rendering on a real, currently-visible street. The three streets visible on this save's map ("РЭЙНОЛЬДЗ ЛЭЙН" — a "Most Wanted #10" boss battle + a real event; "КЭМЕРОН ДРАЙВ"; "КРЮГЕР АВЕНЮ" — a real event) were each checked directly (tapped, event-list panel inspected) and show no injected card — meaning none of them are among the ~12 MapTrack instances this hook's evtype==1025 condition catches. Since HandleEvent's dispatch mechanism itself is confirmed correct (§6dd) and injection is confirmed to work once a pin is reached, the remaining gap is purely "which pin is which" / "why do these 3 special-content streets not go through this same broadcast path" — plausibly because Most-Wanted-boss and other hand-authored story content gets attached through a different, not-yet-identified mechanism than the generic TrackId-registry-driven path this hook intercepts. This is the natural next investigative thread if visible confirmation on a known street is wanted, but is a separate question from "does the injection mechanism itself work," which is now answered: yes.

6ff. Delayed-crash root cause narrowed to the "Blacklist" rival system — exact faulting instruction not yet pinned down

Continuation of §6ee's flagged delayed crash (fault addr 0xc0ffee00, our first injected cache key, dereferenced directly as a pointer roughly 5 minutes after injection). Investigated via a mix of static analysis and, eventually, successful live breakpoint-catching once a gdb/gdbserver state-sync quirk was worked around.

Static trail: the crash backtrace's frame #01 static offset (0x233b70, from libapp.so (offset 0x4d8000)) traces into sub_233684 — a function that checks a fixed table of hardcoded event names ("event_60_blacklist_1" down to "event_04_blacklist_10", all in a literal pool at 0x233b10) to determine an event's Blacklist rival rank (NFS Most Wanted's rival-racer ranking system — matches the "Рэйнольдз Лэйн" street's observed "Most Wanted #10" boss card from §6ee). This function then walks a boost::function-keyed map/tree structure, invoking a stored callback per entry via a generic invoker thunk (sub_234F38, confirmed via decompile to be boost::function's standard dispatch pattern, not itself buggy).

Live-caught: after working around a recurring gdb/gdbserver synchronization bug (continue intermittently failing with "Cannot execute this command while the target is running" on already-running, multi-threaded processes — reliably avoided by attaching immediately after process spawn, before the game's worker threads fully start, rather than to an already-running instance), set a breakpoint at 0x233b70 and caught it firing 28+ times in a row with completely stable, valid register values (r0=0xb8c4f0f4, a genuine stack address). Disassembling the actual bytes there (not a defined IDA function — literal pool followed by unrecognized code) revealed 0x233b70 is itself a BL instruction into a fourth, previously unknown function at 0x233b4c (only reachable via an address taken and stored elsewhere, e.g. as a registered callback — never called directly by name anywhere), which itself calls into 0x40602C.

Conclusion so far: this whole call chain (sub_233684 → per-entry callback → sub_233b4csub_40602C) runs frequently and safely under normal conditions — it's a routine, periodic Blacklist-rival scan over some always-present collection, not something our injection specifically triggers. The crash is a rare condition within this hot path: at least once, an entry in whatever collection this scan walks contained our raw injected cache key (0xc0ffee00) instead of a properly-resolved value, and something downstream dereferenced it directly as a pointer. The exact single faulting instruction is not yet pinned down — live-catching it requires either a much longer soak (the crash took ~5 minutes to occur naturally in the two observed cases) or a smarter conditional breakpoint (e.g., break only when a register looks like one of our 0xC0FFEE0X-range keys), neither attempted yet due to time already invested in this thread.

Practical implication / mitigation direction (untested): real cache keys are hash values (computed via sub_67223C, not human-chosen constants), and this Blacklist-scan code appears to expect either a resolvable-through-sub_173350 handle or a real hash-shaped value at some point in its processing — using an easily-recognizable sentinel range (0xC0FFEE00+) for synthetic keys, while convenient for debugging, may itself be more likely to look "plausible enough to use directly" to whatever misbehaving check exists here than a value that looks like a real hash would. Worth testing empirically: switch the injection's key-generation scheme to something structurally closer to a real sub_67223C-style hash (e.g., actually hash a fake resource-path string through that same function) and re-run the ~5-minute soak test to see if the crash still reproduces — this doesn't require finding the exact faulting instruction to potentially resolve the crash, though it's a mitigation-by-avoidance rather than a confirmed fix.

6gg. Delayed crash: EXACT root cause found — it's the QA-only "Soak Test" auto-race feature, not the Blacklist system; clean, cheap mitigation identified

Correction/completion of §6ff: that section traced the wrong backtrace frame (frame #01, a caller/return-address site inside the Blacklist rank-lookup code, which turned out to be an unrelated red herring that just happens to execute very frequently and safely). Re-examining the original crash report's frame #00 (the actual faulting instruction, previously not converted to a static offset) gives the real answer.

The faulting instruction: static offset 0x406cd0, inside sub_406CAC(int **a1, const void **a2) — a generic "construct an eastl-style string object from a C-string" helper (matches its call sites elsewhere in the binary, e.g. sub_17C120's sub_406CAC(&v150, v101)). Its very first operation is v2 = *a2; (treat the input as a pointer to a C-string pointer) followed by a classic strlen-style scan: v4 = v2 - 1; while (*++v4); — this is the exact line at 0x406cd0. If *a2 isn't a real string pointer, this immediately faults trying to read the "string"'s first byte — and the original crash's fault address was exactly 0xc0ffee00, our first injected cache key, confirming *a2 held our raw integer key value directly, not a string pointer.

Where this gets called with bad data — the QA "Soak Test" auto-race feature (already identified and named in §6v/§6bb, sub_17C120): once every ~4 seconds of active gameplay (flt_AD417C > 4.0), it picks an entry by numeric index (a persistent, decrementing global counter dword_AD4180, wrapping around based on an array's element count) from an array v156 obtained via sub_242904(&v156, someContext + 320, 0) — the same +320-offset context pattern established (§6w) as this project's prefab-instance cache — and calls sub_406CAC(&v150, v101) on that entry to build a debug log line ("Soak Test Run No: N - <name>"). This is QA/debug-only instrumentation, not player-facing functionality: it auto-picks and logs (likely also auto-launches) random cached races purely for automated soak testing.

Root cause of the crash, precisely: sub_242904 appears to return a separate, parallel list (of resource-path-string pointers, one per real cached prefab) rather than reading the same hash-table sub_7D638/sub_173350 operate on directly. Our injection only inserts into the primary hash-table cache (via sub_7D638) — it never adds a corresponding entry to this other parallel list. This desyncs the two structures' sizes/contents, so dword_AD4180's index-based lookup into v156 eventually reads memory that doesn't correspond to a real entry — landing, in the observed crash, on memory holding our raw injected key rather than a valid string pointer.

Practical mitigation — cheap and clean, doesn't require touching the parallel list at all: this whole code path is a QA-only feature with no player-facing purpose (auto-launches random races for soak testing — not something the mod needs to preserve, and almost certainly disabled in normal retail play already via whatever build/debug flag gates sub_15811C(*(a1+8)), one of the two conditions guarding it). The simplest fix is to prevent this branch from ever running rather than trying to keep a second, not-yet-reverse-engineered list in sync with every injected entry — e.g., hook sub_17C120 (or more surgically, force flt_AD417C to never exceed 4.0, or short-circuit right before the sub_406CAC call) so the Soak Test logic never fires. This fully eliminates the crash's trigger condition without needing to understand or replicate whatever sub_242904's parallel list actually is.

7. Open questions / next steps (see plan presented to user for full detail)

  1. Locate the real vtables (not just RTTI name strings) for §3.1/§3.2 classes in this binary, starting from the confirmed string addresses (walk backwards from name_ptr to the type_info/vtable structure, same technique demonstrated working this session for RaceLoaderTask's sp_counted_impl_p wrapper at 0xd7a2c8).
  2. Decompile (Hex-Rays) the actual RaceLoaderTask/OpponentCollection/TrackNavigator/RaceStartingGrid virtual functions once addresses are known, to get real field offsets and signatures (replacing the old chat's guesses).
  3. Confirm whether Health, Nitro, DamageDealtMultiplier, CarDamage, SpikeStrip exist under the same names in this binary (not yet searched this session — lower priority, not core to the multiplayer subtasks).
  4. Read game_cache/published/prefabs/racefsms/*.sb and game_cache/published/flow/race/*.sb directly (via NFSMW12MobileTools) to get the authoritative, binary-RE-free picture of the race-start Flow sequence and FSM structure — likely faster and more reliable than reversing InRaceState from disassembly alone.
  5. Confirm car-selection screen invocation path (how the "native car-selection menu" is invoked/returns a result) — needed for the "Choose Car" lobby button requirement.

Not yet confirmed: whether the "3 slots" cap is a real per-street constant (worth checking a street with 2 or 3 unlocked real events, if one exists in this save, to see whether it ever shows 4+ cards) or coincidental to the two streets tested so far (both had only 1 real event authored). If a street with 3 unlocked events still shows only 3 slots and a street with fewer shows fewer non-locked ones, that would strongly confirm the fixed-pool theory.

Resolved — see §6v. MapTrack::AddEvent (sub_369AB0, 0x369AB0) is the exact function that attaches a RaceEvent to a pin (into a growable per-pin vector at MapTrack+0x240..0x248, not the +0xB8 field originally suspected in §6u — that turned out to be a red herring from a different, secondary "closest track under touch" cache read by sub_1781BC, unrelated to the pin's actual owned event list). Next step: determine the exact RaceEventHandle argument shape AddEvent expects, so it can be called with synthetic data.

6hh. Subtask 2 groundwork — RaceStartingGrid/StreetRaceStartingGrid fully mapped (grid is procedural, not per-track data), Opponent/OpponentCollection live layout found, TrackNavigator's spline→world resolver identified (reusable for subtask 4), cop-spawn scheduler located

Session 2026-08-26 (overnight, autonomous per explicit instruction — "работай до исхода лимита токенов, в конце расскажешь"). Scoped in ARCHITECTURE.md §3b the prior session; this entry answers all four of that section's open questions, all via static SB-data inspection + IDA decompilation, no live device needed.

Q1 (is the street-race grid layout per-track or shared) — ANSWERED: shared, procedural, not per-track data

First checked the DATA side: unpacked and diffed 15+ different event_*_race.prefabs.sb files (NFSMW12MobileTools) spanning every region — every single one has exactly 5 Opponent entries (confirmed field-by-field, see Q3 below), regardless of track. Then unpacked two full track scene files (region4_chicago_track1.scene.sb, region1_foothills_track1.scene.sb, ~75k DATA_Elements each) and searched every actor's name field for grid/spawn-related strings: found exactly one actor literally named "start" per track (plus many "mesh_start" visual props, not gameplay locators) — no numbered spawn-point actors (start_1, grid_pos_2, etc.) anywhere in either track.

This ruled out "per-track authored grid" and pointed at "single locator + code-computed offsets," confirmed by decompiling RaceStartingGrid/StreetRaceStartingGrid (RTTI: im::app::race::description::{RaceStartingGrid,StreetRaceStartingGrid}, vtables _ZTVN2im3app4race11description{16RaceStartingGrid,22StreetRaceStartingGrid}E at 0xaa7d28/0xaa7da8 — the old 0xcfeb5c/0xcfeb8c/0xcfebb8 addresses recorded in §3.1 are from a stale .i64, do not use them). StreetRaceStartingGrid's constructor (sub_2B884C) hardcodes 5 float defaults directly in the C++ constructor:

Offset Field name (confirmed via deserializer sub_2B8A60) Default Notes
+12 MinDistanceBetweenRacers 10.0
+16 MaxDistanceBetweenRacers 15.0
+20 MaxTrackWidthFraction 0.8 fraction, not an absolute value — proportional to whatever track's actual width
+24 PlayerStartingSpeed 27.778 (= 100 km/h, 100/3.6) read as km/h from data, converted *0.27778 at load time; ctor default is pre-converted
+28 OpponentsStartingSpeed 36.111 (= 130 km/h) opponents start faster than the player by default

The deserializer (sub_2B8A60) reads these same 5 property names reflectively (sub_4F99F0/sub_4F9A80, the same generic property-getter used throughout this codebase) from a property table, falling back to the ctor defaults if absent. None of the 62 unpacked event files' schemas contain any struct/field with these names — confirmed via a full struct-name enumeration across every event_*.sb.json in the repo. Conclusion: every regular street race uses the identical 5 hardcoded grid parameters; the only per-track variation is the natural, unavoidable difference in where each track's own single "start" locator sits in world space — which the existing code already resolves correctly for any track via TrackNavigator (see Q4). No per-track special-casing is needed for subtask 2's grid work.

StreetRaceStartingGrid is itself instantiated through a generic by-name component factory (sub_2B8654, registers the string "StreetRaceStartingGrid" → constructor sub_2B87C4 via sub_670758, the same reflective-registration idiom already seen for other Component-derived classes) — i.e. it's a component on some Actor's shared race-FSM prefab (matching every checked event's RaceFSMPrefabOverride field, e.g. "point_to_point_fsm_newintro"), not something authored per-track or per-event.

Q2 (is "player always last" an index or an insertion-order effect) — ANSWERED: insertion-order effect, in the racer-placement algorithm itself

StreetRaceStartingGrid overrides 4 of RaceStartingGrid's 13 vtable slots (compared both vtables word-for-word: off_AA7D28/off_AA7DA8, slots 1/2/5/10 differ, slots 0/3/4/6/7/8/9/11 are inherited unchanged). Slot 5 is the deserializer above (Q1); slot 10 (sub_2B88BC) is the actual placement algorithm:

sub_2B88BC(this, raceContext, playerHandle, opponentsVector):
    distance = 0.0
    for (index, opponentEntry) in enumerate(opponentsVector):   // 12-byte elements
        PlaceCar(raceContext, opponentEntry, distance, LateralOffset(trackWidthFraction, index), OpponentsStartingSpeed)
        distance += MinDistanceBetweenRacers + random(0,1) * (MaxDistanceBetweenRacers - MinDistanceBetweenRacers)
    PlaceCar(raceContext, playerHandle, distance, 0 /* lateral: dead-center */, PlayerStartingSpeed)

The player is placed once, after the loop, using whatever distance the loop accumulated — not a fixed index and not an authored "last" flag. This is a structural consequence of the function's own two-phase design (place every opponent first, accumulating randomized spacing; place the player last, at the tail). Practical implication for subtask 2: real multiplayer players do not need to fight or bypass this rule at all — inserting them as entries in opponentsVector (the same vector Opponent/OpponentCollection builds, see Q3) gives them completely normal, correctly-spaced, correctly-jittered grid slots via the exact same code path a real AI opponent would get. Only the local device's own player stays in the dedicated last slot via the separate playerHandle call — which is fine, since each device's own player is already a distinct, privileged local entity (input/camera) regardless.

Lateral placement for opponents (the LateralOffset(...) call, inlined in sub_2B88BC) is a 3-lane zigzag: ((index+1) % 3) * 0.5 * trackWidthFraction + (1 - trackWidthFraction) * 0.5, scaled by the track's actual width (resolved by PlaceCar/TrackNavigator, not baked into this formula) — so lane assignment naturally cycles through 3 lateral positions as the opponent index increases, using whatever MaxTrackWidthFraction (0.8 default) allows.

Q3 (opponent car model + color write path) — ANSWERED: byte-precise live Opponent layout found, plus its owning OpponentCollection

Data-side confirmed first: every event_*_race.prefabs.sb's Opponent struct schema is DriverName (string), CarDescriptionName (string, e.g. "ford_mustang_boss_302_2012_desc" — same format as the already-known GetCurrentCarId() result), ColourIndex (int32 — same field this project already reads for the player's own car via LookupCarRecord, cont.63), plus AI-tuning floats (RacingLineScale/SpeedFactor/CorneringFactor/RubberBandingDifficulty/RubberBandingTweaksToUse/RubberBandingTargetDifficulty/PathfindingSkill), Stationary (bool), MaxHealth (float). Every regular race event has exactly 5 Opponent entries (see Q1).

Found Opponent's deserializer (sub_2B5DD0, located via the "CarDescriptionName" string xref) and its only caller, OpponentCollection::PopulateFromProperties (sub_2B649C, located via "Opponents" string xref) — both fully decompiled, giving the real live C++ layout (80-byte malloc, matches Opponent's highest field offset +76+4):

Offset Field Type
+0 vtable ptr off_AA7C78
+4 / +8 / +12 DriverName {begin,end,capacity} eastl string (same 12-byte string idiom as RaceEvent's TrackName)
+20 / +24 / +28 CarDescriptionName same string idiom — the field to overwrite for opponent substitution
+36 ColourIndex plain intthe field to overwrite for opponent color
+40..+75 AI-tuning sub-object built by sub_2B4B08/sub_2B4B44, not yet mapped field-by-field (not needed for subtask 2 — a substituted real-player slot doesn't need AI tuning)
+76 Stationary bool

OpponentCollection::PopulateFromProperties (sub_2B649C) reads the "Opponents" property (a map), and for each entry: malloc(0x50) a new Opponent, deserializes it via sub_2B5DD0, wraps it in a 16-byte refcounted handle (vtable=off_AA7CCC, refcount=1, +12=Opponent*), and push_backs that wrapper into a vector living on the OpponentCollection object itself at this+12/+16/+20 (begin/end/capacity — standard vector, 8-byte/2-word elements).

This is the exact vector sub_2B88BC (Q2) iterates to place opponents on the grid — not yet cross-confirmed by directly tracing the argument at the call site (a remaining, low-risk verification step), but the shapes match exactly (OpponentCollection's built vector of wrapped Opponent* vs. the grid placement loop's 12-byte-strided vector — the 12-byte stride matches the wrapper's own {vtable-tag, Opponent*}-plus-something shape closely enough to be the same data, needs one direct trace to fully nail down before implementing).

Practical implication for subtask 2.1: hook OpponentCollection::PopulateFromProperties (sub_2B649C), let it build the normal AI opponent list unmodified (matches this whole project's established "hook after, tweak fields" pattern — never originate, always let the real engine construct first), then for up to N real lobby players, overwrite the corresponding Opponent entries' CarDescriptionName (via sub_7B524, the same string-append/set helper already used for RaceEvent.TrackName in the track-substitution hook) and ColourIndex (plain int write) with that player's own already-captured GetCurrentCarId()/color-index values (car_selection.h). No grid-specific code needed — Q2 already established substituted entries get correct placement for free.

Q4 (bonus — found while chasing Q2/Q3, directly relevant to subtask 4) — TrackNavigator's real spline→world resolver

PlaceCar (sub_2914DC, own assert strings confirm the name: "m_Navigator"/"PlaceCar"/"foundHeight", "Navigator must be created before placing cars") is the single, universal placement primitive both the opponent loop and the player call use — signature PlaceCar(gridThis, racerHandle, distanceAlongSpline, lateralOffset, startingSpeed). Internally:

  1. sub_3261B0(navigator, outBuffer, distanceAlongSpline, lateralOffset)this is the real TrackNavigator::Resolve implementation: reads a vector of 12-byte spline-segment records at navigator+308/+312, binary-searches for the segment containing distanceAlongSpline (sub_327824), then resolves world position + tangent direction (sub_32273C) into outBuffer. Exactly the "distance_along_spline, lateral_offset ↔ world Vector3" transform ARCHITECTURE.md §5 predicted subtask 4 would need — found here, as a side effect of subtask 2 work, not yet independently verified live but the decompile is unambiguous.
  2. Resolves ground height via sub_85660 (a raycast/height-query, own assert "foundHeight").
  3. Builds a heading quaternion from the resolved tangent direction (atan2f/sinf/cosf).
  4. Writes the car's initial velocity as direction * startingSpeed directly into a physics component at a fixed sub-offset (+320/+324/+328 off a resolved pointer chain) — confirms starting speed isn't just cosmetic, it's a real initial-velocity write.
  5. Writes the resolved transform (position + heading quaternion) to two separate components (sub_10B09C and sub_D5138 — likely physics/simulation vs. render/visual transform, not yet distinguished which is which).

Not yet found: the inverse resolver (world position → spline distance/lateral offset), which subtask 4 will need for reading a moving car's current position to broadcast over the network (this entry only found the forward direction, used for placement at race start). Worth checking TrackNavigator's other vtable slots when subtask 4 starts.

Task 4 (traffic/cop removal for multiplayer) — cops: exact scheduler found; civilian traffic: data-level path clear, runtime spawner not yet traced

Cops: found SpawnCopCar itself (sub_F85B8, own assert string confirms the name) — large (0x11a0 bytes), spawns+places+configures a cop actor, not fully mapped field-by-field (not needed). Found both its callers: sub_F7E9C is a spawn scheduler/gate — checks a cooldown timer (a1+4020/+4028/+4032) and an active-cop-count-vs-max check (a1+4188+16), and only calls SpawnCopCar if both pass. This is the natural hook point: skip sub_F7E9C's body entirely (return early, no call to SpawnCopCar ever happens) when a multiplayer session is active — structurally identical to the already-proven CopSoundsTick skip hook (crash_workarounds.h), same low-risk pattern, just a different target function. (sub_F8078, the second caller, not yet decompiled — likely a second trigger context, e.g. scripted/pursuit-specific vs. ambient.)

Confirmed via RTTI that cops are a substantial, separate subsystem (im::app::car::CopAICarController, CopAttackBehaviour, CopBustBehaviour, im::app::race::description::CopDescription — its own PrefabDatabase<CopDescription>, mirroring CarDescription's own pattern) — full understanding of cop AI behavior is out of scope for "just stop them from spawning," which the scheduler-skip hook achieves without touching any of that.

Civilian traffic: RaceEvent.TrafficCarCount (int, confirmed in every event's SB data, e.g. 1 for event_05_race) and a sibling TrafficFlow component (MaxIncomingCarsOverride/MaxOutgoingCarsOverride/congestion-distance tuning/TrafficCarSpawnDescs) fully describe ambient traffic density per-event, at the data level — same shape as every other per-race tunable already reverse-engineered in this project (CashReward, MedalPosition, etc.). The runtime spawner that actually consumes these fields was not traced this session (found TrafficFlow's own deserializer, sub_33A108, but not its downstream spawn-trigger function) — lower priority than cops since the data-level override (set TrafficCarCount to 0 post-deserialization, same "hook after, tweak field" pattern as everywhere else) is very likely sufficient on its own and doesn't require finding the runtime spawner at all. Worth a quick live A/B test (does TrafficCarCount=0 actually produce an empty road?) before investing further RE time here.

Snapshot / Outcome

Pure research this session — no code written, no hooks installed, nothing live-tested on device (all findings are static SB-data inspection + IDA decompilation). All four of ARCHITECTURE.md §3b's open questions now have concrete, address-level answers; ARCHITECTURE.md §3b itself needs a rewrite to reflect this (queued as immediate next step). Remaining gaps before subtask 2 can be implemented: (1) directly confirm sub_2B88BC's opponent vector argument really is OpponentCollection's own vector (high confidence, not yet byte-traced), (2) decompile sub_F8078 (cop spawn scheduler's second caller), (3) live-test whether TrafficCarCount=0 actually suppresses civilian traffic, (4) map Opponent's AI-tuning sub-object if a substituted real-player slot ever needs to suppress AI behavior explicitly (may not be necessary — a real player's own input should simply override AI control, but this hasn't been confirmed).

6ii. Subtask 2.1 — opponent car substitution CONFIRMED LIVE end-to-end, despite the two vectors being genuinely different objects

Direct follow-up to §6hh, same session (2026-08-26, continued autonomously per explicit instruction to keep working through open items without waiting). Implemented opponent_substitution.h: two diagnostic hooks on OpponentCollection::PopulateFromProperties (sub_2B649C) and StreetRaceStartingGrid's placement method (sub_2B88BC), logging every pointer both touch.

Confirmed live: the two vectors are genuinely different objects, not the same data viewed two ways. StreetRaceGrid::Place's own 12-byte-element vector held addresses (0xb03xxxxx/0x7fcxxxxx range) that never appeared anywhere in OpponentCollection's own 8-byte-element vector (Opponent* values all in a 0xbc2xxxxx/0xd7dxxxxx-range heap arena) across two separate live captures. There is a real intermediate step - likely a "spawn the actual racer Actor" stage - between OpponentCollection building its data-only Opponent list (confirmed to happen once, early, at map load for every nearby event, not per actual race start - all ~20-45 OpponentCollections for surrounding events populate within about 1 second of the map screen appearing) and the grid actually placing physical, rendered cars at race start (confirmed to happen once, ~minutes later, exactly when the player actually starts a race).

Despite that gap, overwriting Opponent.CarDescriptionName/ColourIndex at Populate time was confirmed, live, to reach the actual rendered car at the starting grid. Test: hooked Populate to unconditionally force opponent-slot-0 of every collection to "ford_focus_rs500_2010_desc"/ColourIndex=0, rebuilt, replayed a real (non-synthetic) event ("Перед вами FAIRHAVEN", event_05_race-shaped, 5 opponents) end-to-end via adb UI taps through to the starting-grid cinematic. Screenshot comparison: same race, same car, before the hook showed a white sedan + red car at the front of the pack; after the hook, a silver/gray Ford Focus RS500 is unmistakably the lead car. Logcat confirms the in-memory write held (CarDescriptionName="ford_focus_rs500_2010_desc" ColourIndex=0) across every collection populated that map-load pass.

Practical conclusion for subtask 2.1: whatever the intermediate spawn step is, it reads car/color off the same Opponent objects OpponentCollection builds (or a copy taken strictly after Populate has already run) - it does not use some earlier-fixed snapshot from before Populate. This means the hook point identified in §6hh (write immediately after OpponentCollection::PopulateFromProperties returns) is sufficient on its own; no need to trace or hook the intermediate spawn step at all. The only remaining gap before this becomes a real (not blanket-test) feature is a data source: a lobby/session layer that can tell the hook which OpponentCollection belongs to the race the player is actually about to start, and which real player's car/color to write into which slot - neither exists yet (no lobby UI/data model built). The intermediate spawn step's own identity/location remains unknown and is not needed for this subtask.

Snapshot: opponent_substitution.h's substitution write is gated behind g_enableBlanketOpponentSubstitutionTest (default false) - the live-tested build had it hardcoded on for this one test, reverted to gated-off before ending the session so normal play isn't affected. Diagnostic logging (both hooks, generous budget) stays on by default - low-risk, budget-capped, matches this project's established diagnostic-hook pattern.

6jj. Subtask 2.4 — cop-spawn scheduler hook implemented and installs cleanly; civilian traffic hook implemented and CONFIRMED LIVE (corrects this section's own earlier mistake)

  • Cops: implemented cop_traffic_disable.h, hooking sub_F7E9C (the cop-spawn scheduler identified in §6hh) to skip its body entirely when a test flag is on. Confirmed live: installs cleanly (Installed Cop spawn scheduler skip hook), process stays stable through map/menu navigation, no crash. Not yet confirmed: the scheduler never actually fired during map/menu browsing in this test session (0 log lines from the hook while idle on the map) - it's very likely gated on active gameplay/a pursuit context, not a continuous background process, so a real visual "no cops spawned" confirmation needs an active-driving or pursuit-triggering test longer than this session's adb-tap-only navigation could practically set up. Structurally sound and ready; visual confirmation is the remaining step.
  • Civilian traffic — this section's own first pass was wrong, corrected by re-reading the same decompile more carefully: this entry originally claimed TrafficCarCount needed a keyed reflective-lookup hook because sub_2A4D70 (RaceEvent's deserializer) reads it via sub_4F99F0/sub_4F9A80. On closer reading, that reflective read happens exactly once, during deserialization - identical in shape to every other field sub_2A4D70 reads (RaceType, Location, ClassRestriction, etc.) - and the resolved value is then stored as a plain int at a fixed offset, RaceEvent+116, on the live object (*(a1+116) = resolvedValue, confirmed directly in the decompile, right next to the already-known ClassRestriction-at-+132 pattern). The original §6hh plan (hook after deserialization, overwrite the fixed offset) was correct all along.
  • Implemented and confirmed live: cop_traffic_disable.h's second hook, on sub_2A4D70 itself, overwrites *(a1+116) to 0 after the real deserializer runs. Live-tested: fired once per event at map-load time (same timing as OpponentCollection::Populate/§6ii), correctly zeroed real, non-trivial values (1, 2, even 10 for one event) to 0, no crash, process stayed stable. Not independently visually confirmed (no ambient traffic was visible in either the before- or after-hook starting-grid screenshot, but that's expected either way - traffic wouldn't render at a stationary pre-race moment regardless of the hook - a real visual check needs actual driving, not just reaching the grid).
  • Snapshot: both cop_traffic_disable.h hooks gated off by default (g_enableCopSpawnSkipTest/g_enableTrafficCarCountZeroTest, both false), same pattern as opponent_substitution.h.

6kk. Subtask 2 — full multi-car/color roster AND random player grid position, both CONFIRMED LIVE simultaneously

Direct follow-up to §6ii/§6jj, same overnight session, per explicit request to emulate a fuller mock session (varied opponents + a non-last player slot).

  • Varied roster: extended opponent_substitution.h's blanket test from one fixed car to a 5-entry roster (ford_focus_rs500_2010_desc, bmw_m3_e92_2009_desc, dodge_challenger_srt8_392_2011_desc, ford_mustang_boss_302_2012_desc, lancia_delta_hf_integrale_evoluzione_1993_desc, distinct colour indices), cycling by opponent slot index in Hook_OpponentCollectionPopulate. Live-tested on the same real event replay: screenshot shows three visibly distinct cars (white BMW M3, white/blue Dodge Challenger, dark Ford Focus RS500) on the grid simultaneously, logcat confirms all 5 slots got their distinct roster entry, in order, every time.
  • Random player grid position — required reimplementing the placement algorithm, not just data tweaks: §6hh Q2 already established "player always last" is a call-order artifact of sub_2B88BC (loop places every opponent first, accumulating randomized spacing, THEN places the player once at the tail in a separate call) - there's no field to flip for this, since the ordering itself is the behavior. Implemented a full reimplementation in Hook_StreetRaceGridPlace (gated behind g_enableRandomPlayerGridPositionTest, falls through to the untouched original otherwise): calls the same two real primitives orig uses - sub_291BA4 (PlaceOpponent, keeps its own lateral-zigzag math) for opponents, sub_2914DC (PlaceCar) directly for the player (lateral=0, matching orig's own player call) - in a loop of count+1 slots, with one slot chosen by rand() % (count+1) for the player and the rest going to opponents in original order. Distance accumulation mirrors orig's own shape (place, then advance by a random offset in [MinDistanceBetweenRacers, MaxDistanceBetweenRacers]) but uses plain rand() instead of replicating sub_75680/sub_61C9F8's own RNG chain (seeded from the just-placed car's return value in a way not fully understood - not worth the risk for a test hook).
  • Live-tested successfully, simultaneously with the varied roster: logcat showed RANDOM GRID TEST: 5 opponents, player placed at slot 0/5 (i.e. the player was placed FIRST this run, not last) - no crash, process stayed stable through the same real-event replay used throughout this session. Both features work together: the same test run that produced the 3-distinct-cars screenshot also had the player at a randomized (non-last) slot.
  • Snapshot: both extensions live in opponent_substitution.h, gated off by default (g_enableBlanketOpponentSubstitutionTest/g_enableRandomPlayerGridPositionTest, both false) before ending the test - device left in a safe, normal-play state.
  • Outcome: this is now a fairly complete emulation of what a real multi-player race grid would look like (N distinct cars/colors, player at an unpredictable position) - purely as a local, single-device demonstration (all "opponents" are still AI, not real network peers). The only remaining gap to a real feature is, as before, the lobby/session data layer to source real per-player car/color/slot-order choices instead of this hardcoded test roster and rand().

6ll. Subtask 2.4 — cop-spawn scheduler's real two-path shape found and CONFIRMED LIVE via a before/after roadblock; ambient-traffic spawner found, reducible but not fully chokeable

User caught a police car live, mid-race, in the exact same race that produced the random-grid-position spawn-collision bug documented in PROGRESS.md cont.73 - directly contradicting §6jj's "never fires" observation. That contradiction was the trigger to dig one level deeper on both the cop scheduler and (independently, on a hunch that the same "shallow hook" mistake might be repeated) the traffic hook.

Cops — the missing second path. §6hh/§6jj only decompiled sub_F7E9C. Decompiling its neighbor sub_F8078 (previously unexamined) and both functions' own callers found the real shape:

sub_F5BB4 (CopManager::Update, per-tick, also runs unrelated bust-timer/etc. bookkeeping)
  -> sub_F5EA4 (dispatcher, gated on `sub_33FF1C(a1)+88` byte + `a1+3736` byte + `sub_31A8A4(a1+3952)`)
       branches on `*(byte*)(a1+4036)`:
         true  -> sub_F7E9C (cooldown-timer, single-candidate scheduler)
         false -> sub_F8078 (distance-sorted candidate LIST, multiple checks/spawns per tick)
       both leaves call sub_F85B8 (SpawnCopCar, own assert string confirms name)

xrefs_to(0xF85B8) confirms exactly 2 callers - sub_F7E9C (0xf8018) and sub_F8078 (0xf822c) - so these two are the complete set of spawn paths, nothing else calls SpawnCopCar. xrefs_to(0xF7E9C) and xrefs_to(0xF8078) both resolve to the same single caller, sub_F5EA4 (0xf5f4c/0xf5f58 respectively) - confirming the dispatcher is the correct, minimal hook point that covers both leaves without touching sub_F5BB4's other per-tick logic (bust timers etc., via sub_F9D34/FA4F8/F6640/F67A8/F6BCC - explicitly NOT wanted to be disturbed).

Fix: moved the hook from sub_F7E9C to sub_F5EA4 (renamed Hook_CopSpawnSchedulerHook_CopSpawnDispatcher in cop_traffic_disable.h), skipping the dispatcher's body entirely (return 0, discarded by the caller anyway) when g_enableCopSpawnSkipTest is on.

Live A/B proof (same event, "Битва на шоссе"/Reynolds Lane, replayed via "Начать заново" between runs):

  • Flag off (old, single-leaf-hooked build, or the new build with the flag toggled off): drove through and hit a full, unambiguous police roadblock mid-race - multiple marked "POLICE"-liveried cars with light bars, cops standing in the road, a stop sign, on a completely plain street race with no Most-Wanted branding whatsoever. Screenshotted.
  • Flag on (new dispatcher hook): replayed the identical route. Logcat showed DIAG CopSpawnDispatcher: skipped fire 7 times in a tight ~230ms burst right around the same point in the route (matching sub_F8078's "check several distance-sorted candidates per tick" shape). The road was completely clear at the equivalent location - no roadblock, no cops, clean pass-through, screenshotted.

This is the strongest live confirmation in the project so far - an actual, reproducible visual difference on the identical content, not just an absence-of-crash or a logcat line.

Ambient traffic — the same "shallow hook" mistake, found and only partially fixed. §6jj's TrafficCarCount=0 hook was re-tested by actually driving the race (not just checking logcat, which is what "confirmed" it before) - civilian traffic (a red pickup truck, a blue sedan) was still clearly visible with the hook active. Traced the real spawner:

  • sub_33D734TrafficCarSpawner's populate function (own assert string: "TrafficCarSpawner"). Signature (int a1/*this*/, int a2/*road context*/, char a3/*direction: 0 or 1*/, int a4/*ptr to a 4-float struct incl. spacing*/, int a5/*max candidate count*/). Builds a per-direction candidate list: for each road "node" in a waypoint/spline list (count v66, read from a road-context sub-object's own +108/+112 list bounds - track-authored, nothing to do with a5), a do { ...push candidate...; } while (++n < ceil(a5/(v66+something))) loop runs. Because it's a do-while, the body executes at least once per node regardless of a5 - forcing a5=0 lowers the additional iterations to zero but can't prevent the guaranteed-first one.
  • sub_33C020 calls sub_33D734 exactly twice - (v7, a1, 0, v8, *(a1[3]+16)) and (v17, a1, 1, v19, *(a1[3]+20)) - once per traffic direction, with each call's max-count read from offsets +16/+20 on a different object (a1[3]) than RaceEvent (where TrafficCarCount lives at +116, per §6jj) - confirming TrafficCarCount was never the right field to begin with.
  • sub_2A8CE8 calls sub_33C020 exactly once, at race setup (xrefs_to(0x33c020) → single caller) - meaning the two candidate lists sub_33D734 builds are very likely the entire traffic roster for the race, built once, not a queue that's topped up per-tick the way cops are. This reframes the residual cars as this hook's structural floor, not a sign of an unrelated live spawner still running.

Fix implemented: Hook_TrafficCarSpawnerPopulate on sub_33D734, forcing a5 to 0 when g_enableTrafficSpawnerZeroTest is on. Live-tested: logcat confirmed maxCount=1 -> 0 and maxCount=2 -> 0 for the two direction calls on the same event - and a single red pickup was still visible on-road, consistent with the do-while floor (1 node in one direction group, 2 in the other, before any hook involvement - already a small roster) rather than the hook silently failing.

Left open, honestly scoped: full elimination needs either (a) the track-waypoint-count source itself (harder - track-authored scene data, not a simple parameter), or (b) whatever consumes sub_33C020's two candidate-list handles (stored at a1[28]/a1[30] on its own this) to actually instantiate world actors - not yet located. Worth checking that owning object's other methods for a read of those two slots before assuming a new decompile pass is needed from scratch.

Snapshot: cop_traffic_disable.h - dispatcher hook renamed and retargeted (Hook_CopSpawnDispatcher/sub_F5EA4); new Hook_TrafficCarSpawnerPopulate/InstallTrafficCarSpawnerZeroHook on sub_33D734, gated by new g_enableTrafficSpawnerZeroTest. All test flags reverted to false and a clean final build reinstalled before ending the session.

6mm. Subtask 2.4 — civilian traffic fully eliminated, CONFIRMED LIVE over a 2+ minute drive (resolves §6ll's open item)

Direct continuation of §6ll on explicit user instruction: keep digging into traffic, the goal is literally zero cars on the road, not just fewer. §6ll's sub_33D734 do-while fix reduces the candidate list to a structural minimum but can never reach zero (the loop is do { ...push candidate... } while, so it always runs at least once per track-authored waypoint regardless of the max-count parameter) - this section traces one level further, past candidate-list population to actual world placement.

Found sub_C26A0 — the real placement primitive, a third confirmed user of the PlaceCar/SpawnCopCar write pair. Own assert strings confirm the function's identity: "foundTrackInfo" and "Reset" (i.e. CarReset). Signature (int a1, uint32_t* a2, char a3, int a4). Resolves a spline distance + lateral offset to a world position (via sub_2B2D18/sub_85660, the same TrackNavigator-family shape used throughout this project) and writes the result via sub_10B09C/sub_D5138 - the exact same transform-write pair PlaceCar (sub_2914DC, §6hh) uses for the grid and SpawnCopCar (sub_F85B8, §6ll) uses for cops. xrefs_to(0xC26A0) returns exactly two callers:

  • sub_2A0470 — registered via sub_31AAEC (the same event-subscription idiom SpawnCopCar uses for its own "EndOfTrack" event, per §6hh) as the handler for a "ResetLine" event, one registration per traffic car (built in sub_2BF4C4, itself invoked - not traced further - once per traffic car object).
  • sub_C201C — the traffic car's own per-tick controller. Second parameter is a delta-time-carrying struct (*a2 * -0.001 appears twice, converting an integer millisecond tick into seconds). Calls sub_C26A0 in two places: once when an idle/wander timer (a float field, counted down by delta-time each tick) reaches <= 0.0 (computing a fresh target position via sub_690A94), and once, gated by a separate flag byte, to retry a previously deferred reset whose parameters were stashed in fields at a1+112..128 - the exact same fields sub_C26A0 itself writes on its own internal "couldn't resolve a position yet, save the request" fallback branch. This field layout (offsets 40/44/52/58/60/68/72/80/84/88/92/93/96/112-128) doesn't match Opponent, PlaceCar's context object, or anything else touched by this project so far - a dedicated, traffic-specific per-car component.

First hook attempt, live-tested and disproven: hooked sub_2A0470 on the reasonable-looking assumption that a "ResetLine" handler would cover both a car's initial placement and its ongoing recycling (analogous to how cops' single dispatcher covered both of their two paths in §6ll). Built with an unconditional diagnostic log (not gated by the test flag, specifically so its absence would be meaningful) and drove an actual race rather than just checking whether it installed. Result: the log never fired once during a ~1 minute race replay, yet a screenshot taken mid-race clearly showed the same red pickup truck from §6ll's testing, moving normally. This directly disproves the hypothesis for normal-length races - sub_2A0470 isn't reached on whatever path keeps that traffic car active. (It did fire once, later, during the longer successful test below - so it's a real, occasionally-used path, just not the primary one.) Left installed as a harmless secondary hook, explicitly not relied upon.

Real fix: hooked sub_C201C itself, skipping its entire body (g_enableTrafficControllerSkipTest, returns early without calling orig or touching any of its fields). Since this is the traffic car's dedicated per-tick controller and the only other path into the actual position-write primitive, skipping it should leave that car's actor permanently un-positioned - never rendered moving on the road.

Live-confirmed, unambiguous, over an extended drive: replayed "Битва на шоссе"/Reynolds Lane - the same event that, across every prior test this session (§6ll and cont.73-74), reliably produced a red pickup truck in the tunnel section (~14s in) and a blue sedan later on a straight. With both traffic hooks active: screenshotted the tunnel section - completely clear, no pickup. Continued driving past 2 minutes total (well past where the blue sedan always appeared before) - still completely clear, no crash. The sub_C201C hook's own diagnostic log confirmed it firing continuously and rapidly (hit its 50-entry budget cap almost immediately after the race started, consistent with a genuine per-tick function), and the sub_2A0470 hook fired exactly once late in the drive - both hooks coexisting without conflict.

Snapshot: cop_traffic_disable.h gains Hook_TrafficCarControllerTick/InstallTrafficCarControllerSkipHook on sub_C201C (the real fix, g_enableTrafficControllerSkipTest) and Hook_TrafficResetLineHandler/InstallTrafficResetLineSkipHook on sub_2A0470 (secondary/harmless, g_enableTrafficResetLineSkipTest). main.cpp installs both unconditionally in JNI_OnLoad (runtime-gated by the flags, same pattern as every other test hook in this project). All flags reverted to false, final build reinstalled, before ending the session.

Outcome: subtask 2.4 is now fully solved on both halves - cops (§6ll) and traffic (this section) - each confirmed live via an actual before/after drive, not just a logcat line or an absence of crashes. The pattern that solved both: find the genuine per-tick controller/dispatcher for the subsystem and skip it wholesale, rather than trying to starve a data-driven candidate list or count field that turns out to have its own independent structural floor. Subtask 2 as a whole is now essentially complete pending only the lobby/session data layer; subtask 4 (coordinate/position sync) is the natural next major branch.